diff --git a/.github/workflows/4_6_2_Core_Unit_Tests_Win.yaml b/.github/workflows/4_6_2_Core_Unit_Tests_Win.yaml new file mode 100644 index 000000000..4455dbedf --- /dev/null +++ b/.github/workflows/4_6_2_Core_Unit_Tests_Win.yaml @@ -0,0 +1,113 @@ +name: build and test .NET 4.6.2 Windows + +on: + push: + pull_request: + branches: [ release, development ] + paths: + - '**.cs' + - '**.csproj' + +env: + DOTNET_VERSION: '9.0.x' # The .NET SDK version to use + +jobs: + build-and-test: + # if: ${{ ! always() }} + name: build-and-test-windows + runs-on: windows-latest + steps: + - name: Clone webprofusion/certify + uses: actions/checkout@master + with: + path: ./certify + + - name: Clone webprofusion/anvil + uses: actions/checkout@master + with: + repository: webprofusion/anvil + ref: refs/heads/main + path: ./libs/anvil + + - name: Clone webprofusion/certify-plugins (development branch push) + if: ${{ github.event_name == 'push' && (contains(github.ref_name, '_dev') || github.ref_name == 'development') }} + uses: actions/checkout@master + with: + repository: webprofusion/certify-plugins + ref: refs/heads/development + path: ./certify-plugins + + - name: Clone webprofusion/certify-plugins (release branch push) + if: ${{ github.event_name == 'push' && (contains(github.ref_name, '_rel') || github.ref_name == 'release') }} + uses: actions/checkout@master + with: + repository: webprofusion/certify-plugins + ref: refs/heads/release + path: ./certify-plugins + + - name: Clone webprofusion/certify-plugins (pull request) + if: ${{ github.event_name == 'pull_request' }} + uses: actions/checkout@master + with: + repository: webprofusion/certify-plugins + ref: ${{ github.base_ref }} + path: ./certify-plugins + + - name: Setup .NET Core + uses: actions/setup-dotnet@master + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Setup Step CLI + run: | + Invoke-WebRequest -Method 'GET' -uri 'https://dl.smallstep.com/gh-release/cli/docs-cli-install/v0.24.4/step_windows_0.24.4_amd64.zip' -Outfile 'C:\temp\step_windows_0.24.4_amd64.zip' + tar -oxzf C:\temp\step_windows_0.24.4_amd64.zip -C "C:\Program Files" + echo "C:\Program Files\step_0.24.4\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Pull step-ca Docker Image + run: docker pull webprofusion/step-ca-win + + - name: Cache NuGet Dependencies + uses: actions/cache@v3 + with: + path: ~/.nuget/packages + # Look to see if there is a cache hit for the corresponding requirements file + key: ${{ runner.os }}-4.6.2-nuget-${{ hashFiles('./certify/src/Certify.Tests/Certify.Core.Tests.Unit/*.csproj') }} + restore-keys: | + ${{ runner.os }}-4.6.2-nuget + + - name: Install Dependencies & Build Certify.Core.Tests.Unit + run: | + dotnet tool install --global dotnet-reportgenerator-globaltool --version 5.2.0 + dotnet add package GitHubActionsTestLogger + dotnet build -c Debug -f net462 --property WarningLevel=0 /clp:ErrorsOnly + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + + - name: Run Certify.Core.Tests.Unit Tests + run: | + $env:GITHUB_WORKSPACE="$env:GITHUB_WORKSPACE\certify" + $env:GITHUB_STEP_SUMMARY=".\TestResults-${{ runner.os }}\test-summary.md" + dotnet test --no-build -f net462 -l "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true;annotations.messageFormat=@error\n@trace" + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + + - name: Generated Test Results Report + run: | + echo "# Test Results" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 + (Get-Content -Path .\TestResults-${{ runner.os }}\test-summary.md).Replace('
', '
') | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + if: ${{ always() }} + + - name: Generated Test Coverage Report + run: | + reportgenerator -reports:./TestResults-${{ runner.os }}/*/*.cobertura.xml -targetdir:./TestResults-${{ runner.os }} -reporttypes:MarkdownSummaryGithub "-title:Test Coverage" + Get-Content -Path ./TestResults-${{ runner.os }}/SummaryGithub.md | Out-File -FilePath $env:GITHUB_STEP_SUMMARY + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + if: ${{ always() }} + + # - name: Upload dotnet test Artifacts + # uses: actions/upload-artifact@master + # with: + # name: dotnet-results-${{ runner.os }}-${{ env.DOTNET_VERSION }} + # path: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit/TestResults-4_6_2-${{ runner.os }} + # # Use always() to always run this step to publish test results when there are test failures + # if: ${{ always() }} diff --git a/.github/workflows/DotNetCore_Unit_Tests_Linux.yaml b/.github/workflows/DotNetCore_Unit_Tests_Linux.yaml new file mode 100644 index 000000000..94acd9076 --- /dev/null +++ b/.github/workflows/DotNetCore_Unit_Tests_Linux.yaml @@ -0,0 +1,116 @@ +name: build and test .NET Core 9.0 Linux + +on: + push: + pull_request: + branches: [ release, development ] + paths: + - '**.cs' + - '**.csproj' + +env: + DOTNET_VERSION: '9.0.x' # The .NET SDK version to use + +jobs: + build-and-test: + + name: build-and-test-linux + runs-on: ubuntu-latest + steps: + - name: Clone webprofusion/certify + uses: actions/checkout@master + with: + path: ./certify + + - name: Clone webprofusion/anvil + uses: actions/checkout@master + with: + repository: webprofusion/anvil + ref: refs/heads/main + path: ./libs/anvil + + - name: Clone webprofusion/certify-plugins (development branch push) + if: ${{ github.event_name == 'push' && (contains(github.ref_name, '_dev') || github.ref_name == 'development') }} + uses: actions/checkout@master + with: + repository: webprofusion/certify-plugins + ref: refs/heads/development + path: ./certify-plugins + + - name: Clone webprofusion/certify-plugins (release branch push) + if: ${{ github.event_name == 'push' && (contains(github.ref_name, '_rel') || github.ref_name == 'release') }} + uses: actions/checkout@master + with: + repository: webprofusion/certify-plugins + ref: refs/heads/release + path: ./certify-plugins + + - name: Clone webprofusion/certify-plugins (pull request) + if: ${{ github.event_name == 'pull_request' }} + uses: actions/checkout@master + with: + repository: webprofusion/certify-plugins + ref: ${{ github.base_ref }} + path: ./certify-plugins + + - name: Setup .NET Core + uses: actions/setup-dotnet@master + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Setup Step CLI + run: | + wget https://dl.smallstep.com/gh-release/cli/docs-cli-install/v0.23.0/step-cli_0.23.0_amd64.deb + sudo dpkg -i step-cli_0.23.0_amd64.deb + + - name: Pull step-ca Docker Image + run: docker pull smallstep/step-ca + + - name: Cache NuGet Dependencies + uses: actions/cache@v3 + with: + path: ~/.nuget/packages + # Look to see if there is a cache hit for the corresponding requirements file + key: ${{ runner.os }}-${{ env.DOTNET_VERSION }}-nuget-${{ hashFiles('./certify/src/Certify.Tests/Certify.Core.Tests.Unit/*.csproj') }} + restore-keys: | + ${{ runner.os }}-${{ env.DOTNET_VERSION }}-nuget + + - name: Install Dependencies & Build Certify.Core.Tests.Unit + run: | + dotnet tool install --global dotnet-reportgenerator-globaltool --version 5.3.8 + dotnet add package GitHubActionsTestLogger + dotnet restore -f net9.0 + pwd + ls + dotnet build -c Debug -f net9.0 --property WarningLevel=0 /clp:ErrorsOnly + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + + - name: Run Certify.Core.Tests.Unit Tests + run: | + export GITHUB_WORKSPACE="$GITHUB_WORKSPACE/certify" + export GITHUB_STEP_SUMMARY="./TestResults-${{ runner.os }}/test-summary.md" + dotnet test --no-build -f net9.0 -l "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true;annotations.messageFormat=@error\n@trace" + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + + - name: Generate Test Results Report + run: | + echo "# Test Results" > $GITHUB_STEP_SUMMARY + sed -i 's/
/
/g' ./TestResults-${{ runner.os }}/test-summary.md + cat ./TestResults-${{ runner.os }}/test-summary.md >> $GITHUB_STEP_SUMMARY + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + if: ${{ always() }} + + - name: Generated Test Coverage Report + run: | + reportgenerator -reports:./TestResults-${{ runner.os }}/*/*.cobertura.xml -targetdir:./TestResults-${{ runner.os }} -reporttypes:MarkdownSummaryGithub "-title:Test Coverage" + cat ./TestResults-${{ runner.os }}/SummaryGithub.md > $GITHUB_STEP_SUMMARY + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + if: ${{ always() }} + + # - name: Upload dotnet test Artifacts + # uses: actions/upload-artifact@master + # with: + # name: dotnet-results-${{ runner.os }}-${{ env.DOTNET_VERSION }} + # path: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit/TestResults-9_0-${{ runner.os }} + # # Use always() to always run this step to publish test results when there are test failures + # if: ${{ always() }} diff --git a/.github/workflows/DotNetCore_Unit_Tests_Win.yaml b/.github/workflows/DotNetCore_Unit_Tests_Win.yaml new file mode 100644 index 000000000..1094fbe39 --- /dev/null +++ b/.github/workflows/DotNetCore_Unit_Tests_Win.yaml @@ -0,0 +1,114 @@ +name: build and test .NET Core 9.0 Windows + +on: + push: + pull_request: + branches: [ release, development ] + paths: + - '**.cs' + - '**.csproj' + +env: + DOTNET_VERSION: '9.0.x' # The .NET SDK version to use + +jobs: + build-and-test: + + name: build-and-test-windows + runs-on: windows-latest + steps: + - name: Clone webprofusion/certify + uses: actions/checkout@master + with: + path: ./certify + + - name: Clone webprofusion/anvil + uses: actions/checkout@master + with: + repository: webprofusion/anvil + ref: refs/heads/main + path: ./libs/anvil + + - name: Clone webprofusion/certify-plugins (development branch push) + if: ${{ github.event_name == 'push' && (contains(github.ref_name, '_dev') || github.ref_name == 'development') }} + uses: actions/checkout@master + with: + repository: webprofusion/certify-plugins + ref: refs/heads/development + path: ./certify-plugins + + - name: Clone webprofusion/certify-plugins (release branch push) + if: ${{ github.event_name == 'push' && (contains(github.ref_name, '_rel') || github.ref_name == 'release') }} + uses: actions/checkout@master + with: + repository: webprofusion/certify-plugins + ref: refs/heads/release + path: ./certify-plugins + + - name: Clone webprofusion/certify-plugins (pull request) + if: ${{ github.event_name == 'pull_request' }} + uses: actions/checkout@master + with: + repository: webprofusion/certify-plugins + ref: ${{ github.base_ref }} + path: ./certify-plugins + + - name: Setup .NET Core + uses: actions/setup-dotnet@master + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Setup Step CLI + run: | + Invoke-WebRequest -Method 'GET' -uri 'https://dl.smallstep.com/gh-release/cli/docs-cli-install/v0.24.4/step_windows_0.24.4_amd64.zip' -Outfile 'C:\temp\step_windows_0.24.4_amd64.zip' + tar -oxzf C:\temp\step_windows_0.24.4_amd64.zip -C "C:\Program Files" + echo "C:\Program Files\step_0.24.4\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Pull step-ca Docker Image + run: docker pull webprofusion/step-ca-win + + - name: Cache NuGet Dependencies + uses: actions/cache@v3 + with: + path: ~/.nuget/packages + # Look to see if there is a cache hit for the corresponding requirements file + key: ${{ runner.os }}-${{ env.DOTNET_VERSION }}-nuget-${{ hashFiles('./certify/src/Certify.Tests/Certify.Core.Tests.Unit/*.csproj') }} + restore-keys: | + ${{ runner.os }}-${{ env.DOTNET_VERSION }}-nuget + + - name: Install Dependencies & Build Certify.Core.Tests.Unit + run: | + dotnet tool install --global dotnet-reportgenerator-globaltool --version 5.2.0 + dotnet add package GitHubActionsTestLogger + dotnet restore -f net9.0 + dotnet build Certify.Core.Tests.Unit.csproj -c Debug -f net9.0 --property WarningLevel=0 /clp:ErrorsOnly + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + + - name: Run Certify.Core.Tests.Unit Tests + run: | + $env:GITHUB_WORKSPACE="$env:GITHUB_WORKSPACE\certify" + $env:GITHUB_STEP_SUMMARY=".\TestResults-${{ runner.os }}\test-summary.md" + dotnet test --no-build -f net9.0 -l "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true;annotations.messageFormat=@error\n@trace" + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + + - name: Generate Test Results Report + run: | + echo "# Test Results" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 + (Get-Content -Path .\TestResults-${{ runner.os }}\test-summary.md).Replace('
', '
') | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + if: ${{ always() }} + + - name: Generated Test Coverage Report + run: | + reportgenerator -reports:./TestResults-${{ runner.os }}/*/*.cobertura.xml -targetdir:./TestResults-${{ runner.os }} -reporttypes:MarkdownSummaryGithub "-title:Test Coverage" + Get-Content -Path ./TestResults-${{ runner.os }}/SummaryGithub.md | Out-File -FilePath $env:GITHUB_STEP_SUMMARY + working-directory: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit + if: ${{ always() }} + + # - name: Upload dotnet test Artifacts + # uses: actions/upload-artifact@master + # with: + # name: dotnet-results-${{ runner.os }}-${{ env.DOTNET_VERSION }} + # path: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit/TestResults-9_0-${{ runner.os }} + # # Use always() to always run this step to publish test results when there are test failures + # if: ${{ always() }} diff --git a/Directory.Build.props b/Directory.Build.props index e37c0efed..5a4901c12 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,14 +1,18 @@ - 6.1.0 - 6.1.0 + 7.0.0 + 7.0.0 Webprofusion Pty Ltd Webprofusion Pty Ltd - Certify Community Edition [via github] + Certify Certificate Manager - Community Edition [dev version via github] https://certifytheweb.com https://github.com/webprofusion/certify false true - portable + full + + + + portable diff --git a/docs/images/VS_Container_Debug_Attach_To_Process_Window.png b/docs/images/VS_Container_Debug_Attach_To_Process_Window.png new file mode 100644 index 000000000..c2ff17001 Binary files /dev/null and b/docs/images/VS_Container_Debug_Attach_To_Process_Window.png differ diff --git a/docs/images/VS_Container_Debug_Select_Code_Type_Window.png b/docs/images/VS_Container_Debug_Select_Code_Type_Window.png new file mode 100644 index 000000000..3aebaf79e Binary files /dev/null and b/docs/images/VS_Container_Debug_Select_Code_Type_Window.png differ diff --git a/docs/testing.md b/docs/testing.md index 00c00ba28..5d97e74c7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3,8 +3,15 @@ Testing Configuration - In Visual Studio (or other Test UI such as AxoCover), set execution environment to 64-bit to ensure tests load. - Units tests are for discreet function testing or limited component dependency tests. + - `CertifyManagerAccountTests` require an existing Prod and Staging ACME account for letsencrypt.org to exist. It also requires a `.env.test_accounts` file in the directory `C:\ProgramData\certify\Tests` with values for `RESTORE_KEY_PEM`, `RESTORE_ACCOUNT_URI`, and `RESTORE_ACCOUNT_EMAIL` for an existing letsencrypt.org ACME account + ```.env + RESTORE_KEY_PEM="-----BEGIN EC PRIVATE KEY-----\r\nMHcCAQEEINL5koIn4o+an+EwyDQEd4Ggnxra5j7Oro13M5klKmhaoAoGCCqGSM49\r\nAwEHoUQDQgAEPF7u1CLMe9FIBQo0MVmv7vlvqGOdSERG5nRLkNKTDUgBRxkXGqY+\r\nGbnnzXUb7j4g7VN7CuEy0SpCdFItD+63hQ==\r\n-----END EC PRIVATE KEY-----\r\n" + RESTORE_ACCOUNT_URI=https://acme-staging-v02.api.letsencrypt.org/acme/acct/123456789 + RESTORE_ACCOUNT_EMAIL=admin.8c635b@test.com - Integration tests exercise multiple components and may interact with ACME services etc. Required elements include: - IIS Installed on local machine + * A non-enabled site in IIS is needed for TestCertifyManagerGetPrimaryWebSitesIncludeStoppedSites() in CertifyManagerServerTypeTests.cs + - Must set IncludeExternalPlugins to true in C:\ProgramData\certify\appsettings.json and run copy-plugins.bat from certify-internal - The debug version of the app must be configured with a contact against staging Let's Encrypt servers - Completing HTTP challenges requires that the machine can respond to port 80 requests from the internet (such as the Let's Encrypt staging server checks) - DNS API Credentials test and DNS Challenges require the respective DNS credentials by configured as saved credentials in the UI (see config below) @@ -26,8 +33,7 @@ Testing Configuration "Cloudflare_ZoneId": "5265262gdd562s4x6xd64zxczxcv", "Cloudflare_TestDomain": "anothertest.com" } -``` -In addition, the test domain for some tests can be set using the CERTIFYSSLDOMAIN environment variable. - +- In addition, the test domain for some tests can be set using the CERTIFY_TESTDOMAIN environment variable. + diff --git a/scripts/chocolatey/tools/chocolateyinstall.ps1 b/scripts/chocolatey/tools/chocolateyinstall.ps1 index d15df1df8..fcbc5f1aa 100644 --- a/scripts/chocolatey/tools/chocolateyinstall.ps1 +++ b/scripts/chocolatey/tools/chocolateyinstall.ps1 @@ -1,4 +1,4 @@ -$ErrorActionPreference = 'Stop'; +$ErrorActionPreference = 'Stop'; $toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)" $url64 = 'https://certifytheweb.s3.amazonaws.com/downloads/archive/CertifyTheWebSetup_V6.1.0.exe' diff --git a/src/Certify.Server/Certify.Service.Worker/.dockerignore b/src/.dockerignore similarity index 78% rename from src/Certify.Server/Certify.Service.Worker/.dockerignore rename to src/.dockerignore index 3729ff0cd..fe1152bdb 100644 --- a/src/Certify.Server/Certify.Service.Worker/.dockerignore +++ b/src/.dockerignore @@ -22,4 +22,9 @@ **/secrets.dev.yaml **/values.dev.yaml LICENSE -README.md \ No newline at end of file +README.md +!**/.gitignore +!.git/HEAD +!.git/config +!.git/packed-refs +!.git/refs/heads/** \ No newline at end of file diff --git a/src/Certify.Aspire/Certify.Aspire.AppHost/Certify.Aspire.AppHost.csproj b/src/Certify.Aspire/Certify.Aspire.AppHost/Certify.Aspire.AppHost.csproj new file mode 100644 index 000000000..2eb5f1c9d --- /dev/null +++ b/src/Certify.Aspire/Certify.Aspire.AppHost/Certify.Aspire.AppHost.csproj @@ -0,0 +1,20 @@ + + + + Exe + net9.0; + enable + enable + true + + + + + + + + + + + + diff --git a/src/Certify.Aspire/Certify.Aspire.AppHost/Program.cs b/src/Certify.Aspire/Certify.Aspire.AppHost/Program.cs new file mode 100644 index 000000000..d12b890cc --- /dev/null +++ b/src/Certify.Aspire/Certify.Aspire.AppHost/Program.cs @@ -0,0 +1,7 @@ +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddProject("certifyserverapi"); + +builder.AddProject("certifyservercore"); + +builder.Build().Run(); diff --git a/src/Certify.Aspire/Certify.Aspire.AppHost/Properties/launchSettings.json b/src/Certify.Aspire/Certify.Aspire.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..1c4e230f3 --- /dev/null +++ b/src/Certify.Aspire/Certify.Aspire.AppHost/Properties/launchSettings.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:15155", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:16075", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22109" + } + } + } +} diff --git a/src/Certify.Aspire/Certify.Aspire.AppHost/appsettings.Development.json b/src/Certify.Aspire/Certify.Aspire.AppHost/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/src/Certify.Aspire/Certify.Aspire.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Certify.Aspire/Certify.Aspire.AppHost/appsettings.json b/src/Certify.Aspire/Certify.Aspire.AppHost/appsettings.json new file mode 100644 index 000000000..31c092aa4 --- /dev/null +++ b/src/Certify.Aspire/Certify.Aspire.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/Certify.Aspire/Certify.Aspire.ServiceDefaults/Certify.Aspire.ServiceDefaults.csproj b/src/Certify.Aspire/Certify.Aspire.ServiceDefaults/Certify.Aspire.ServiceDefaults.csproj new file mode 100644 index 000000000..6e74a002b --- /dev/null +++ b/src/Certify.Aspire/Certify.Aspire.ServiceDefaults/Certify.Aspire.ServiceDefaults.csproj @@ -0,0 +1,24 @@ + + + + Library + net9.0; + enable + enable + true + + + + + + + + + + + + + + + + diff --git a/src/Certify.Aspire/Certify.Aspire.ServiceDefaults/Extensions.cs b/src/Certify.Aspire/Certify.Aspire.ServiceDefaults/Extensions.cs new file mode 100644 index 000000000..7249ebecb --- /dev/null +++ b/src/Certify.Aspire/Certify.Aspire.ServiceDefaults/Extensions.cs @@ -0,0 +1,119 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +public static class Extensions +{ + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddRuntimeInstrumentation() + .AddBuiltInMeters(); + }) + .WithTracing(tracing => + { + if (builder.Environment.IsDevelopment()) + { + // We want to view all traces in development + tracing.SetSampler(new AlwaysOnSampler()); + } + + tracing.AddAspNetCoreInstrumentation() + .AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.Configure(logging => logging.AddOtlpExporter()); + builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter()); + builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); + } + + // Uncomment the following lines to enable the Prometheus exporter (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package) + // builder.Services.AddOpenTelemetry() + // .WithMetrics(metrics => metrics.AddPrometheusExporter()); + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.Exporter package) + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + + return builder; + } + + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Uncomment the following line to enable the Prometheus endpoint (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package) + // app.MapPrometheusScrapingEndpoint(); + + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + + return app; + } + + private static MeterProviderBuilder AddBuiltInMeters(this MeterProviderBuilder meterProviderBuilder) => + meterProviderBuilder.AddMeter( + "Microsoft.AspNetCore.Hosting", + "Microsoft.AspNetCore.Server.Kestrel", + "System.Net.Http"); +} diff --git a/src/Certify.CLI/Certify.CLI.csproj b/src/Certify.CLI/Certify.CLI.csproj index b2a6714ee..0656cf864 100644 --- a/src/Certify.CLI/Certify.CLI.csproj +++ b/src/Certify.CLI/Certify.CLI.csproj @@ -1,23 +1,13 @@ - + - net462 + net462;net9.0 Debug;Release;Debug;Release Certify Exe - AnyCPU;x64 + AnyCPU - x64 - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - ..\CodeAnalysis.ruleset - false - - - x64 + AnyCPU false bin\Debug\ DEBUG;TRACE @@ -35,15 +25,6 @@ 4 false - - AnyCPU - true - bin\Release\ - TRACE - prompt - 4 - false - Debug AnyCPU @@ -79,4 +60,8 @@ + + + + \ No newline at end of file diff --git a/src/Certify.CLI/CertifyCLI.Backup.cs b/src/Certify.CLI/CertifyCLI.Backup.cs index c17faf9cb..284b95f65 100644 --- a/src/Certify.CLI/CertifyCLI.Backup.cs +++ b/src/Certify.CLI/CertifyCLI.Backup.cs @@ -2,8 +2,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; - -using Certify.Config.Migration; +using Certify.Models.Config.Migration; using Newtonsoft.Json; namespace Certify.CLI @@ -27,7 +26,7 @@ public async Task PerformBackupExport(string[] args) var exportRequest = new ExportRequest { IsPreviewMode = false, Settings = new ExportSettings { EncryptionSecret = secret, ExportAllStoredCredentials = true } }; - var export = await _certifyClient.PerformExport(exportRequest); + var export = await _certifyClient.PerformExport(exportRequest, authContext: null); System.IO.File.WriteAllText(filename, JsonConvert.SerializeObject(export)); @@ -65,7 +64,7 @@ public async Task PerformBackupImport(string[] args) return; } - var importSteps = await _certifyClient.PerformImport(importRequest); + var importSteps = await _certifyClient.PerformImport(importRequest, authContext: null); foreach (var s in importSteps) { diff --git a/src/Certify.CLI/CertifyCLI.RunCertDiagnostics.cs b/src/Certify.CLI/CertifyCLI.RunCertDiagnostics.cs index 5b3b33d67..417f5f8fc 100644 --- a/src/Certify.CLI/CertifyCLI.RunCertDiagnostics.cs +++ b/src/Certify.CLI/CertifyCLI.RunCertDiagnostics.cs @@ -6,7 +6,7 @@ using System.Threading.Tasks; using Certify.Management; using Certify.Models; -using Serilog; +using Microsoft.Extensions.Logging; namespace Certify.CLI { @@ -279,11 +279,7 @@ public async Task FindPendingAuthorizations(bool autoFix) var c = new CertifyManager(); await c.Init(); - var log = new LoggerConfiguration() - .WriteTo.Debug() - .CreateLogger(); - - var logger = new Loggy(log); + var logger = new Loggy(LoggerFactory.Create(builder => builder.AddDebug()).CreateLogger()); foreach (var url in orderUrls) { diff --git a/src/Certify.Client/Certify.Client.csproj b/src/Certify.Client/Certify.Client.csproj index c224410c3..45d311d69 100644 --- a/src/Certify.Client/Certify.Client.csproj +++ b/src/Certify.Client/Certify.Client.csproj @@ -1,9 +1,9 @@  - netstandard2.0 + netstandard2.0;net9.0 AnyCPU - true + False @@ -16,17 +16,18 @@ - - - + + + - + + diff --git a/src/Certify.Client/CertifyApiClient.cs b/src/Certify.Client/CertifyApiClient.cs index 6b5dce441..e4c7dedc0 100644 --- a/src/Certify.Client/CertifyApiClient.cs +++ b/src/Certify.Client/CertifyApiClient.cs @@ -6,9 +6,11 @@ using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; -using Certify.Config.Migration; using Certify.Models; +using Certify.Models.API; using Certify.Models.Config; +using Certify.Models.Config.AccessControl; +using Certify.Models.Reporting; using Certify.Models.Utils; using Certify.Shared; using Newtonsoft.Json; @@ -52,8 +54,14 @@ protected override Task SendAsync( ); } + public class AuthContext + { + public string UserId { get; set; } + public string Token { get; set; } + } + // This version of the client communicates with the Certify.Service instance on the local machine - public class CertifyApiClient : ICertifyInternalApiClient + public partial class CertifyApiClient : ICertifyInternalApiClient { private HttpClient _client; private readonly string _baseUri = "/api/"; @@ -132,19 +140,33 @@ public void SetConnectionAuthMode(string mode) CreateHttpClient(); } - private async Task FetchAsync(string endpoint) + private void SetAuthContextForRequest(HttpRequestMessage request, AuthContext authContext) + { + if (authContext != null) + { + request.Headers.Add("X-Context-User-Id", authContext.UserId); + } + } + + private async Task FetchAsync(string endpoint, AuthContext authContext) { try { - var response = await _client.GetAsync(_baseUri + endpoint); - if (response.IsSuccessStatusCode) + using (var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri + endpoint))) { - return await response.Content.ReadAsStringAsync(); - } - else - { - var error = await response.Content.ReadAsStringAsync(); - throw new ServiceCommsException($"Internal Service Error: {endpoint}: {error} "); + SetAuthContextForRequest(request, authContext); + + var response = await _client.SendAsync(request).ConfigureAwait(false); + + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadAsStringAsync().ConfigureAwait(false); + } + else + { + var error = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new ServiceCommsException($"Internal Service Error: {endpoint}: {error} "); + } } } catch (HttpRequestException exp) @@ -158,7 +180,7 @@ public class ServerErrorMsg public string Message; } - private async Task PostAsync(string endpoint, object data) + private async Task PostAsync(string endpoint, object data, AuthContext authContext) { if (data != null) { @@ -167,30 +189,37 @@ private async Task PostAsync(string endpoint, object data) try { - var response = await _client.PostAsync(_baseUri + endpoint, content); - if (response.IsSuccessStatusCode) - { - return response; - } - else + using (var request = new HttpRequestMessage(HttpMethod.Post, new Uri(_baseUri + endpoint))) { - var error = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + SetAuthContextForRequest(request, authContext); + + request.Content = content; - if (response.StatusCode == HttpStatusCode.Unauthorized) + var response = await _client.SendAsync(request).ConfigureAwait(false); + if (response.IsSuccessStatusCode) { - throw new ServiceCommsException($"API Access Denied: {endpoint}: {error}"); + return response; } else { + var error = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - if (response.StatusCode == HttpStatusCode.InternalServerError && error.Contains("\"message\"")) + if (response.StatusCode == HttpStatusCode.Unauthorized) { - var err = JsonConvert.DeserializeObject(error); - throw new ServiceCommsException($"Internal Service Error: {endpoint}: {err.Message}"); + throw new ServiceCommsException($"API Access Denied: {endpoint}: {error}"); } else { - throw new ServiceCommsException($"Internal Service Error: {endpoint}: {error}"); + + if (response.StatusCode == HttpStatusCode.InternalServerError && error.Contains("\"message\"")) + { + var err = JsonConvert.DeserializeObject(error); + throw new ServiceCommsException($"Internal Service Error: {endpoint}: {err.Message}"); + } + else + { + throw new ServiceCommsException($"Internal Service Error: {endpoint}: {error}"); + } } } } @@ -202,42 +231,48 @@ private async Task PostAsync(string endpoint, object data) } else { - var response = await _client.PostAsync(_baseUri + endpoint, new StringContent("")); + var response = await _client.PostAsync(_baseUri + endpoint, new StringContent("")).ConfigureAwait(false); if (response.IsSuccessStatusCode) { return response; } else { - var error = await response.Content.ReadAsStringAsync(); + var error = await response.Content.ReadAsStringAsync().ConfigureAwait(false); throw new ServiceCommsException($"Internal Service Error: {endpoint}: {error}"); } } } - private async Task DeleteAsync(string endpoint) + private async Task DeleteAsync(string endpoint, AuthContext authContext) { - var response = await _client.DeleteAsync(_baseUri + endpoint); - if (response.IsSuccessStatusCode) + using (var request = new HttpRequestMessage(HttpMethod.Delete, new Uri(_baseUri + endpoint))) { - return response; - } - else - { - var error = await response.Content.ReadAsStringAsync(); - throw new ServiceCommsException($"Internal Service Error: {endpoint}: {error}"); + SetAuthContextForRequest(request, authContext); + + var response = await _client.SendAsync(request).ConfigureAwait(false); + + if (response.IsSuccessStatusCode) + { + return response; + } + else + { + var error = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new ServiceCommsException($"Internal Service Error: {endpoint}: {error}"); + } } } #region System - public async Task GetAppVersion() => await FetchAsync("system/appversion"); + public async Task GetAppVersion(AuthContext authContext = null) => await FetchAsync("system/appversion", authContext); - public async Task CheckForUpdates() + public async Task CheckForUpdates(AuthContext authContext = null) { try { - var result = await FetchAsync("system/updatecheck"); + var result = await FetchAsync("system/updatecheck", authContext); return JsonConvert.DeserializeObject(result); } catch (Exception) @@ -247,97 +282,89 @@ public async Task CheckForUpdates() } } - public async Task> PerformServiceDiagnostics() + public async Task> PerformServiceDiagnostics(AuthContext authContext = null) { - var result = await FetchAsync("system/diagnostics"); + var result = await FetchAsync("system/diagnostics", authContext); return JsonConvert.DeserializeObject>(result); } - public async Task PerformExport(ExportRequest exportRequest) - { - var result = await PostAsync("system/migration/export", exportRequest); - return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); - } - - public async Task> PerformImport(ImportRequest importRequest) + public async Task> SetDefaultDataStore(string dataStoreId, AuthContext authContext = null) { - var result = await PostAsync("system/migration/import", importRequest); + var result = await PostAsync($"system/datastores/setdefault/{dataStoreId}", null, authContext); return JsonConvert.DeserializeObject>(await result.Content.ReadAsStringAsync()); } - public async Task> SetDefaultDataStore(string dataStoreId) - { - var result = await PostAsync($"system/datastores/setdefault/{dataStoreId}", null); - return JsonConvert.DeserializeObject>(await result.Content.ReadAsStringAsync()); - } - public async Task> GetDataStoreProviders() + + public async Task> GetDataStoreProviders(AuthContext authContext = null) { - var result = await FetchAsync("system/datastores/providers"); + var result = await FetchAsync("system/datastores/providers", authContext); return JsonConvert.DeserializeObject>(result); } - public async Task> GetDataStoreConnections() + + public async Task> GetDataStoreConnections(AuthContext authContext = null) { - var result = await FetchAsync("system/datastores/"); + var result = await FetchAsync("system/datastores/", authContext); return JsonConvert.DeserializeObject>(result); } - public async Task> CopyDataStore(string sourceId, string targetId) + + public async Task> CopyDataStore(string sourceId, string targetId, AuthContext authContext = null) { - var result = await PostAsync($"system/datastores/copy/{sourceId}/{targetId}", null); + var result = await PostAsync($"system/datastores/copy/{sourceId}/{targetId}", null, authContext: authContext); return JsonConvert.DeserializeObject>(await result.Content.ReadAsStringAsync()); } - public async Task> UpdateDataStoreConnection(DataStoreConnection dataStoreConnection) + public async Task> UpdateDataStoreConnection(DataStoreConnection dataStoreConnection, AuthContext authContext = null) { - var result = await PostAsync($"system/datastores/update", dataStoreConnection); + var result = await PostAsync($"system/datastores/update", dataStoreConnection, authContext); return JsonConvert.DeserializeObject>(await result.Content.ReadAsStringAsync()); } - public async Task> TestDataStoreConnection(DataStoreConnection dataStoreConnection) + public async Task> TestDataStoreConnection(DataStoreConnection dataStoreConnection, AuthContext authContext = null) { - var result = await PostAsync($"system/datastores/test", dataStoreConnection); + var result = await PostAsync($"system/datastores/test", dataStoreConnection, authContext); return JsonConvert.DeserializeObject>(await result.Content.ReadAsStringAsync()); } #endregion System #region Server - public async Task IsServerAvailable(StandardServerTypes serverType) + public async Task IsServerAvailable(StandardServerTypes serverType, AuthContext authContext = null) { - var result = await FetchAsync($"server/isavailable/{serverType}"); + var result = await FetchAsync($"server/isavailable/{serverType}", authContext); return bool.Parse(result); } - public async Task> GetServerSiteList(StandardServerTypes serverType, string itemId = null) + public async Task> GetServerSiteList(StandardServerTypes serverType, string itemId = null, AuthContext authContext = null) { if (string.IsNullOrEmpty(itemId)) { - var result = await FetchAsync($"server/sitelist/{serverType}"); + var result = await FetchAsync($"server/sitelist/{serverType}", authContext); return JsonConvert.DeserializeObject>(result); } else { - var result = await FetchAsync($"server/sitelist/{serverType}/{itemId}"); + var result = await FetchAsync($"server/sitelist/{serverType}/{itemId}", authContext); return JsonConvert.DeserializeObject>(result); } } - public async Task GetServerVersion(StandardServerTypes serverType) + public async Task GetServerVersion(StandardServerTypes serverType, AuthContext authContext = null) { - var result = await FetchAsync($"server/version/{serverType}"); + var result = await FetchAsync($"server/version/{serverType}", authContext); var versionString = JsonConvert.DeserializeObject(result, new Newtonsoft.Json.Converters.VersionConverter()); var version = Version.Parse(versionString); return version; } - public async Task> GetServerSiteDomains(StandardServerTypes serverType, string serverSiteId) + public async Task> GetServerSiteDomains(StandardServerTypes serverType, string serverSiteId, AuthContext authContext = null) { - var result = await FetchAsync($"server/sitedomains/{serverType}/{serverSiteId}"); + var result = await FetchAsync($"server/sitedomains/{serverType}/{serverSiteId}", authContext); return JsonConvert.DeserializeObject>(result); } - public async Task> RunConfigurationDiagnostics(StandardServerTypes serverType, string serverSiteId) + public async Task> RunConfigurationDiagnostics(StandardServerTypes serverType, string serverSiteId, AuthContext authContext = null) { - var results = await FetchAsync($"server/diagnostics/{serverType}/{serverSiteId}"); + var results = await FetchAsync($"server/diagnostics/{serverType}/{serverSiteId}", authContext); return JsonConvert.DeserializeObject>(results); } @@ -345,15 +372,15 @@ public async Task> RunConfigurationDiagnostics(StandardServerTy #region Preferences - public async Task GetPreferences() + public async Task GetPreferences(AuthContext authContext = null) { - var result = await FetchAsync("preferences/"); + var result = await FetchAsync("preferences/", authContext); return JsonConvert.DeserializeObject(result); } - public async Task SetPreferences(Preferences preferences) + public async Task SetPreferences(Preferences preferences, AuthContext authContext = null) { - _ = await PostAsync("preferences/", preferences); + _ = await PostAsync("preferences/", preferences, authContext); return true; } @@ -361,9 +388,9 @@ public async Task SetPreferences(Preferences preferences) #region Managed Certificates - public async Task> GetManagedCertificates(ManagedCertificateFilter filter) + public async Task> GetManagedCertificates(ManagedCertificateFilter filter, AuthContext authContext = null) { - var response = await PostAsync("managedcertificates/search/", filter); + var response = await PostAsync("managedcertificates/search/", filter, authContext); var serializer = new JsonSerializer(); using (var sr = new StreamReader(await response.Content.ReadAsStreamAsync())) @@ -379,9 +406,40 @@ public async Task> GetManagedCertificates(ManagedCertif } } - public async Task GetManagedCertificate(string managedItemId) + /// + /// Get search results, same as GetManagedCertificates but result has count of total results available as used when paging + /// + /// + /// + public async Task GetManagedCertificateSearchResult(ManagedCertificateFilter filter, AuthContext authContext = null) + { + var response = await PostAsync("managedcertificates/results/", filter, authContext).ConfigureAwait(false); + var serializer = new JsonSerializer(); + + using (var sr = new StreamReader(await response.Content.ReadAsStreamAsync().ConfigureAwait(false))) + using (var reader = new JsonTextReader(sr)) + { + var result = serializer.Deserialize(reader); + return result; + } + } + + public async Task GetManagedCertificateSummary(ManagedCertificateFilter filter, AuthContext authContext = null) + { + var response = await PostAsync("managedcertificates/summary/", filter, authContext); + var serializer = new JsonSerializer(); + + using (var sr = new StreamReader(await response.Content.ReadAsStreamAsync())) + using (var reader = new JsonTextReader(sr)) + { + var result = serializer.Deserialize(reader); + return result; + } + } + + public async Task GetManagedCertificate(string managedItemId, AuthContext authContext = null) { - var result = await FetchAsync($"managedcertificates/{managedItemId}"); + var result = await FetchAsync($"managedcertificates/{managedItemId}", authContext); var site = JsonConvert.DeserializeObject(result); if (site != null) { @@ -391,28 +449,28 @@ public async Task GetManagedCertificate(string managedItemId return site; } - public async Task UpdateManagedCertificate(ManagedCertificate site) + public async Task UpdateManagedCertificate(ManagedCertificate site, AuthContext authContext = null) { - var response = await PostAsync("managedcertificates/update", site); + var response = await PostAsync("managedcertificates/update", site, authContext); var json = await response.Content.ReadAsStringAsync(); return JsonConvert.DeserializeObject(json); } - public async Task DeleteManagedCertificate(string managedItemId) + public async Task DeleteManagedCertificate(string managedItemId, AuthContext authContext = null) { - var response = await DeleteAsync($"managedcertificates/delete/{managedItemId}"); + var response = await DeleteAsync($"managedcertificates/delete/{managedItemId}", authContext); return JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); } - public async Task RevokeManageSiteCertificate(string managedItemId) + public async Task RevokeManageSiteCertificate(string managedItemId, AuthContext authContext = null) { - var response = await FetchAsync($"managedcertificates/revoke/{managedItemId}"); + var response = await FetchAsync($"managedcertificates/revoke/{managedItemId}", authContext); return JsonConvert.DeserializeObject(response); } - public async Task> BeginAutoRenewal(RenewalSettings settings) + public async Task> BeginAutoRenewal(RenewalSettings settings, AuthContext authContext) { - var response = await PostAsync("managedcertificates/autorenew", settings); + var response = await PostAsync("managedcertificates/autorenew", settings, authContext); var serializer = new JsonSerializer(); using (var sr = new StreamReader(await response.Content.ReadAsStreamAsync())) using (var reader = new JsonTextReader(sr)) @@ -422,11 +480,11 @@ public async Task> BeginAutoRenewal(RenewalSettin } } - public async Task BeginCertificateRequest(string managedItemId, bool resumePaused, bool isInteractive) + public async Task BeginCertificateRequest(string managedItemId, bool resumePaused, bool isInteractive, AuthContext authContext = null) { try { - var response = await FetchAsync($"managedcertificates/renewcert/{managedItemId}/{resumePaused}/{isInteractive}"); + var response = await FetchAsync($"managedcertificates/renewcert/{managedItemId}/{resumePaused}/{isInteractive}", authContext); return JsonConvert.DeserializeObject(response); } catch (Exception exp) @@ -434,94 +492,88 @@ public async Task BeginCertificateRequest(string manag return new CertificateRequestResult { IsSuccess = false, - Message = exp.ToString(), + Message = exp.Message.ToString(), Result = exp }; } } - public async Task CheckCertificateRequest(string managedItemId) + public async Task> TestChallengeConfiguration(ManagedCertificate site, AuthContext authContext = null) { - var json = await FetchAsync($"managedcertificates/requeststatus/{managedItemId}"); - return JsonConvert.DeserializeObject(json); - } - - public async Task> TestChallengeConfiguration(ManagedCertificate site) - { - var response = await PostAsync($"managedcertificates/testconfig", site); + var response = await PostAsync($"managedcertificates/testconfig", site, authContext); return JsonConvert.DeserializeObject>(await response.Content.ReadAsStringAsync()); } - public async Task> PerformChallengeCleanup(ManagedCertificate site) + public async Task> PerformChallengeCleanup(ManagedCertificate site, AuthContext authContext = null) { - var response = await PostAsync($"managedcertificates/challengecleanup", site); + var response = await PostAsync($"managedcertificates/challengecleanup", site, authContext); return JsonConvert.DeserializeObject>(await response.Content.ReadAsStringAsync()); } - public async Task> GetDnsProviderZones(string providerTypeId, string credentialsId) + public async Task> GetDnsProviderZones(string providerTypeId, string credentialsId, AuthContext authContext = null) { - var json = await FetchAsync($"managedcertificates/dnszones/{providerTypeId}/{credentialsId}"); + var json = await FetchAsync($"managedcertificates/dnszones/{providerTypeId}/{credentialsId}", authContext); return JsonConvert.DeserializeObject>(json); } - public async Task> PreviewActions(ManagedCertificate site) + public async Task> PreviewActions(ManagedCertificate site, AuthContext authContext = null) { - var response = await PostAsync($"managedcertificates/preview", site); + var response = await PostAsync($"managedcertificates/preview", site, authContext); return JsonConvert.DeserializeObject>(await response.Content.ReadAsStringAsync()); } - public async Task> RedeployManagedCertificates(bool isPreviewOnly, bool includeDeploymentTasks) + public async Task> RedeployManagedCertificates(bool isPreviewOnly, bool includeDeploymentTasks, AuthContext authContext = null) { - var response = await FetchAsync($"managedcertificates/redeploy/{isPreviewOnly}/{includeDeploymentTasks}"); + var response = await FetchAsync($"managedcertificates/redeploy/{isPreviewOnly}/{includeDeploymentTasks}", authContext); return JsonConvert.DeserializeObject>(response); } - public async Task ReapplyCertificateBindings(string managedItemId, bool isPreviewOnly, bool includeDeploymentTasks) + public async Task ReapplyCertificateBindings(string managedItemId, bool isPreviewOnly, bool includeDeploymentTasks, AuthContext authContext = null) { - var response = await FetchAsync($"managedcertificates/reapply/{managedItemId}/{isPreviewOnly}/{includeDeploymentTasks}"); + var response = await FetchAsync($"managedcertificates/reapply/{managedItemId}/{isPreviewOnly}/{includeDeploymentTasks}", authContext); return JsonConvert.DeserializeObject(response); } - public async Task RefetchCertificate(string managedItemId) + public async Task RefetchCertificate(string managedItemId, AuthContext authContext = null) { - var response = await FetchAsync($"managedcertificates/fetch/{managedItemId}/{false}"); + var response = await FetchAsync($"managedcertificates/fetch/{managedItemId}/{false}", authContext); return JsonConvert.DeserializeObject(response); } - public async Task> GetChallengeAPIList() + public async Task> GetChallengeAPIList(AuthContext authContext = null) { - var response = await FetchAsync($"managedcertificates/challengeapis/"); + var response = await FetchAsync($"managedcertificates/challengeapis/", authContext); return JsonConvert.DeserializeObject>(response); } - public async Task> GetCurrentChallenges(string type, string key) + public async Task> GetCurrentChallenges(string type, string key, AuthContext authContext = null) { - var result = await FetchAsync($"managedcertificates/currentchallenges/{type}/{key}"); + var result = await FetchAsync($"managedcertificates/currentchallenges/{type}/{key}", authContext); return JsonConvert.DeserializeObject>(result); } - public async Task> GetDeploymentProviderList() + public async Task> GetDeploymentProviderList(AuthContext authContext = null) { - var response = await FetchAsync($"managedcertificates/deploymentproviders/"); + var response = await FetchAsync($"managedcertificates/deploymentproviders/", authContext); return JsonConvert.DeserializeObject>(response); } - public async Task GetDeploymentProviderDefinition(string id, Config.DeploymentTaskConfig config) + public async Task GetDeploymentProviderDefinition(string id, Config.DeploymentTaskConfig config, AuthContext authContext) { - var response = await PostAsync($"managedcertificates/deploymentprovider/{id}", config); + var response = await PostAsync($"managedcertificates/deploymentprovider/{id}", config, authContext); return JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); } - public async Task> PerformDeployment(string managedCertificateId, string taskId, bool isPreviewOnly, bool forceTaskExecute) + public async Task> PerformDeployment(string managedCertificateId, string taskId, bool isPreviewOnly, bool forceTaskExecute, AuthContext authContext) { if (!forceTaskExecute) { if (string.IsNullOrEmpty(taskId)) { - var response = await FetchAsync($"managedcertificates/performdeployment/{isPreviewOnly}/{managedCertificateId}"); + var response = await FetchAsync($"managedcertificates/performdeployment/{isPreviewOnly}/{managedCertificateId}", authContext); return JsonConvert.DeserializeObject>(response); } else { - var response = await FetchAsync($"managedcertificates/performdeployment/{isPreviewOnly}/{managedCertificateId}/{taskId}"); + var response = await FetchAsync($"managedcertificates/performdeployment/{isPreviewOnly}/{managedCertificateId}/{taskId}", authContext); return JsonConvert.DeserializeObject>(response); } } @@ -529,32 +581,32 @@ public async Task> PerformDeployment(string managedCertificateI { if (string.IsNullOrEmpty(taskId)) { - var response = await FetchAsync($"managedcertificates/performforceddeployment/{isPreviewOnly}/{managedCertificateId}"); + var response = await FetchAsync($"managedcertificates/performforceddeployment/{isPreviewOnly}/{managedCertificateId}", authContext); return JsonConvert.DeserializeObject>(response); } else { - var response = await FetchAsync($"managedcertificates/performforceddeployment/{isPreviewOnly}/{managedCertificateId}/{taskId}"); + var response = await FetchAsync($"managedcertificates/performforceddeployment/{isPreviewOnly}/{managedCertificateId}/{taskId}", authContext); return JsonConvert.DeserializeObject>(response); } } } - public async Task> ValidateDeploymentTask(DeploymentTaskValidationInfo info) + public async Task> ValidateDeploymentTask(DeploymentTaskValidationInfo info, AuthContext authContext = null) { - var result = await PostAsync($"managedcertificates/validatedeploymenttask", info); + var result = await PostAsync($"managedcertificates/validatedeploymenttask", info, authContext); return JsonConvert.DeserializeObject>(await result.Content.ReadAsStringAsync()); } - public async Task GetItemLog(string id, int limit) + public async Task GetItemLog(string id, int limit, AuthContext authContext = null) { - var response = await FetchAsync($"managedcertificates/log/{id}/{limit}"); - return JsonConvert.DeserializeObject(response); + var response = await FetchAsync($"managedcertificates/log/{id}/{limit}", authContext); + return JsonConvert.DeserializeObject(response); } - public async Task> PerformManagedCertMaintenance(string id = null) + public async Task> PerformManagedCertMaintenance(string id = null, AuthContext authContext = null) { - var result = await FetchAsync($"managedcertificates/maintenance/{id}"); + var result = await FetchAsync($"managedcertificates/maintenance/{id}", authContext); return JsonConvert.DeserializeObject>(result); } @@ -562,50 +614,50 @@ public async Task> PerformManagedCertMaintenance(string id = #region Accounts - public async Task> GetCertificateAuthorities() + public async Task> GetCertificateAuthorities(AuthContext authContext = null) { - var result = await FetchAsync("accounts/authorities"); + var result = await FetchAsync("accounts/authorities", authContext); return JsonConvert.DeserializeObject>(result); } - public async Task UpdateCertificateAuthority(CertificateAuthority ca) + public async Task UpdateCertificateAuthority(CertificateAuthority ca, AuthContext authContext = null) { - var result = await PostAsync("accounts/authorities", ca); + var result = await PostAsync("accounts/authorities", ca, authContext); return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); } - public async Task DeleteCertificateAuthority(string id) + public async Task DeleteCertificateAuthority(string id, AuthContext authContext = null) { - var result = await DeleteAsync("accounts/authorities/" + id); + var result = await DeleteAsync("accounts/authorities/" + id, authContext); return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); } - public async Task> GetAccounts() + public async Task> GetAccounts(AuthContext authContext = null) { - var result = await FetchAsync("accounts"); + var result = await FetchAsync("accounts", authContext); return JsonConvert.DeserializeObject>(result); } - public async Task AddAccount(ContactRegistration contact) + public async Task AddAccount(ContactRegistration contact, AuthContext authContext = null) { - var result = await PostAsync("accounts", contact); + var result = await PostAsync("accounts", contact, authContext); return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); } - public async Task UpdateAccountContact(ContactRegistration contact) + public async Task UpdateAccountContact(ContactRegistration contact, AuthContext authContext = null) { - var result = await PostAsync($"accounts/update/{contact.StorageKey}", contact); + var result = await PostAsync($"accounts/update/{contact.StorageKey}", contact, authContext); return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); } - public async Task RemoveAccount(string storageKey, bool deactivate) + public async Task RemoveAccount(string storageKey, bool deactivate, AuthContext authContext = null) { - var result = await DeleteAsync($"accounts/remove/{storageKey}/{deactivate}"); + var result = await DeleteAsync($"accounts/remove/{storageKey}/{deactivate}", authContext); return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); } - public async Task ChangeAccountKey(string storageKey, string newKeyPEM) + public async Task ChangeAccountKey(string storageKey, string newKeyPEM, AuthContext authContext = null) { - var result = await PostAsync($"accounts/changekey/{storageKey}", new { newKeyPem = newKeyPEM }); + var result = await PostAsync($"accounts/changekey/{storageKey}", new { newKeyPem = newKeyPEM }, authContext); return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); } @@ -613,42 +665,42 @@ public async Task ChangeAccountKey(string storageKey, string newKe #region Credentials - public async Task> GetCredentials() + public async Task> GetCredentials(AuthContext authContext = null) { - var result = await FetchAsync("credentials"); + var result = await FetchAsync("credentials", authContext); return JsonConvert.DeserializeObject>(result); } - public async Task UpdateCredentials(StoredCredential credential) + public async Task UpdateCredentials(StoredCredential credential, AuthContext authContext = null) { - var result = await PostAsync("credentials", credential); + var result = await PostAsync("credentials", credential, authContext); return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); } - public async Task DeleteCredential(string credentialKey) + public async Task DeleteCredential(string credentialKey, AuthContext authContext = null) { - var result = await DeleteAsync($"credentials/{credentialKey}"); + var result = await DeleteAsync($"credentials/{credentialKey}", authContext); return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); } - public async Task TestCredentials(string credentialKey) + public async Task TestCredentials(string credentialKey, AuthContext authContext = null) { - var result = await PostAsync($"credentials/{credentialKey}/test", new { }); + var result = await PostAsync($"credentials/{credentialKey}/test", new { }, authContext); return JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); } #endregion #region Auth - public async Task GetAuthKeyWindows() + public async Task GetAuthKeyWindows(AuthContext authContext) { - var result = await FetchAsync("auth/windows"); + var result = await FetchAsync("auth/windows", authContext); return JsonConvert.DeserializeObject(result); } - public async Task GetAccessToken(string key) + public async Task GetAccessToken(string key, AuthContext authContext) { - var result = await PostAsync("auth/token", new { Key = key }); + var result = await PostAsync("auth/token", new { Key = key }, authContext); _accessToken = JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); if (!string.IsNullOrEmpty(_accessToken)) @@ -659,9 +711,9 @@ public async Task GetAccessToken(string key) return _accessToken; } - public async Task GetAccessToken(string username, string password) + public async Task GetAccessToken(string username, string password, AuthContext authContext = null) { - var result = await PostAsync("auth/token", new { Username = username, Password = password }); + var result = await PostAsync("auth/token", new { Username = username, Password = password }, authContext); _accessToken = JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); if (!string.IsNullOrEmpty(_accessToken)) @@ -672,9 +724,9 @@ public async Task GetAccessToken(string username, string password) return _accessToken; } - public async Task RefreshAccessToken() + public async Task RefreshAccessToken(AuthContext authContext) { - var result = await PostAsync("auth/refresh", new { Token = _accessToken }); + var result = await PostAsync("auth/refresh", new { Token = _accessToken }, authContext); _accessToken = JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()); if (!string.IsNullOrEmpty(_accessToken)) @@ -685,6 +737,17 @@ public async Task RefreshAccessToken() return _refreshToken; } + public async Task> GetAccessSecurityPrinciples(AuthContext authContext) + { + var result = await FetchAsync("access/securityprinciples", authContext); + return JsonToObject>(result); + } + #endregion + + private T JsonToObject(string json) + { + return JsonConvert.DeserializeObject(json); + } } } diff --git a/src/Certify.Client/CertifyServiceClient.cs b/src/Certify.Client/CertifyServiceClient.cs index 0760be343..2f743fc06 100644 --- a/src/Certify.Client/CertifyServiceClient.cs +++ b/src/Certify.Client/CertifyServiceClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Threading.Tasks; using Certify.Models; @@ -156,5 +156,10 @@ public ServerConnection GetConnectionInfo() { return _connectionConfig; } + + public string GetStatusHubUri() + { + return _statusHubUri; + } } } diff --git a/src/Certify.Client/ICertifyClient.cs b/src/Certify.Client/ICertifyClient.cs index 844e8994e..28ac176fe 100644 --- a/src/Certify.Client/ICertifyClient.cs +++ b/src/Certify.Client/ICertifyClient.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using Certify.Config.Migration; using Certify.Models; using Certify.Models.Config; +using Certify.Models.Reporting; using Certify.Models.Utils; using Certify.Shared; @@ -13,122 +13,119 @@ namespace Certify.Client /// /// Base API /// - public interface ICertifyInternalApiClient + public partial interface ICertifyInternalApiClient { #region System - Task GetAppVersion(); + Task GetAppVersion(AuthContext authContext = null); - Task CheckForUpdates(); + Task CheckForUpdates(AuthContext authContext = null); - Task> PerformServiceDiagnostics(); - Task> PerformManagedCertMaintenance(string id = null); + Task> PerformServiceDiagnostics(AuthContext authContext = null); + Task> PerformManagedCertMaintenance(string id = null, AuthContext authContext = null); - Task PerformExport(ExportRequest exportRequest); - Task> PerformImport(ImportRequest importRequest); - - Task> SetDefaultDataStore(string dataStoreId); - Task> GetDataStoreProviders(); - Task> GetDataStoreConnections(); - Task> CopyDataStore(string sourceId, string targetId); - Task> UpdateDataStoreConnection(DataStoreConnection dataStoreConnection); - Task> TestDataStoreConnection(DataStoreConnection dataStoreConnection); + Task> SetDefaultDataStore(string dataStoreId, AuthContext authContext = null); + Task> GetDataStoreProviders(AuthContext authContext = null); + Task> GetDataStoreConnections(AuthContext authContext = null); + Task> CopyDataStore(string sourceId, string targetId, AuthContext authContext = null); + Task> UpdateDataStoreConnection(DataStoreConnection dataStoreConnection, AuthContext authContext = null); + Task> TestDataStoreConnection(DataStoreConnection dataStoreConnection, AuthContext authContext = null); #endregion System #region Server + Task IsServerAvailable(StandardServerTypes serverType, AuthContext authContext = null); - Task IsServerAvailable(StandardServerTypes serverType); - - Task> GetServerSiteList(StandardServerTypes serverType, string itemId = null); + Task> GetServerSiteList(StandardServerTypes serverType, string itemId = null, AuthContext authContext = null); - Task GetServerVersion(StandardServerTypes serverType); + Task GetServerVersion(StandardServerTypes serverType, AuthContext authContext = null); - Task> GetServerSiteDomains(StandardServerTypes serverType, string serverSiteId); + Task> GetServerSiteDomains(StandardServerTypes serverType, string serverSiteId, AuthContext authContext = null); - Task> RunConfigurationDiagnostics(StandardServerTypes serverType, string serverSiteId); + Task> RunConfigurationDiagnostics(StandardServerTypes serverType, string serverSiteId, AuthContext authContext = null); - Task> GetCurrentChallenges(string type, string key); + Task> GetCurrentChallenges(string type, string key, AuthContext authContext = null); #endregion Server #region Preferences - Task GetPreferences(); + Task GetPreferences(AuthContext authContext = null); - Task SetPreferences(Preferences preferences); + Task SetPreferences(Preferences preferences, AuthContext authContext = null); #endregion Preferences #region Credentials - Task> GetCredentials(); + Task> GetCredentials(AuthContext authContext = null); - Task UpdateCredentials(StoredCredential credential); + Task UpdateCredentials(StoredCredential credential, AuthContext authContext = null); - Task DeleteCredential(string credentialKey); + Task DeleteCredential(string credentialKey, AuthContext authContext = null); - Task TestCredentials(string credentialKey); + Task TestCredentials(string credentialKey, AuthContext authContext = null); #endregion Credentials #region Managed Certificates - Task> GetManagedCertificates(ManagedCertificateFilter filter); - - Task GetManagedCertificate(string managedItemId); + Task> GetManagedCertificates(ManagedCertificateFilter filter, AuthContext authContext = null); + Task GetManagedCertificateSearchResult(ManagedCertificateFilter filter, AuthContext authContext = null); + Task GetManagedCertificateSummary(ManagedCertificateFilter filter, AuthContext authContext = null); - Task UpdateManagedCertificate(ManagedCertificate site); + Task GetManagedCertificate(string managedItemId, AuthContext authContext = null); - Task DeleteManagedCertificate(string managedItemId); + Task UpdateManagedCertificate(ManagedCertificate site, AuthContext authContext = null); - Task RevokeManageSiteCertificate(string managedItemId); + Task DeleteManagedCertificate(string managedItemId, AuthContext authContext = null); - Task> BeginAutoRenewal(RenewalSettings settings); + Task RevokeManageSiteCertificate(string managedItemId, AuthContext authContext = null); - Task> RedeployManagedCertificates(bool isPreviewOnly, bool includeDeploymentTasks); + Task> BeginAutoRenewal(RenewalSettings settings, AuthContext authContext = null); - Task ReapplyCertificateBindings(string managedItemId, bool isPreviewOnly, bool includeDeploymentTasks); + Task> RedeployManagedCertificates(bool isPreviewOnly, bool includeDeploymentTasks, AuthContext authContext = null); - Task RefetchCertificate(string managedItemId); + Task ReapplyCertificateBindings(string managedItemId, bool isPreviewOnly, bool includeDeploymentTasks, AuthContext authContext = null); - Task BeginCertificateRequest(string managedItemId, bool resumePaused, bool isInteractive); + Task RefetchCertificate(string managedItemId, AuthContext authContext = null); - Task CheckCertificateRequest(string managedItemId); + Task BeginCertificateRequest(string managedItemId, bool resumePaused, bool isInteractive, AuthContext authContext = null); - Task> TestChallengeConfiguration(ManagedCertificate site); - Task> PerformChallengeCleanup(ManagedCertificate site); + Task> TestChallengeConfiguration(ManagedCertificate site, AuthContext authContext = null); + Task> PerformChallengeCleanup(ManagedCertificate site, AuthContext authContext = null); - Task> GetDnsProviderZones(string providerTypeId, string credentialsId); + Task> GetDnsProviderZones(string providerTypeId, string credentialsId, AuthContext authContext = null); - Task> PreviewActions(ManagedCertificate site); + Task> PreviewActions(ManagedCertificate site, AuthContext authContext = null); - Task> GetChallengeAPIList(); + Task> GetChallengeAPIList(AuthContext authContext = null); - Task> GetDeploymentProviderList(); + Task> GetDeploymentProviderList(AuthContext authContext = null); - Task GetDeploymentProviderDefinition(string id, Config.DeploymentTaskConfig config); + Task GetDeploymentProviderDefinition(string id, Config.DeploymentTaskConfig config, AuthContext authContext = null); - Task> PerformDeployment(string managedCertificateId, string taskId, bool isPreviewOnly, bool forceTaskExecute); + Task> PerformDeployment(string managedCertificateId, string taskId, bool isPreviewOnly, bool forceTaskExecute, AuthContext authContext = null); - Task> ValidateDeploymentTask(DeploymentTaskValidationInfo info); + Task> ValidateDeploymentTask(DeploymentTaskValidationInfo info, AuthContext authContext = null); - Task GetItemLog(string id, int limit); + Task GetItemLog(string id, int limit, AuthContext authContext = null); #endregion Managed Certificates #region Accounts - Task> GetCertificateAuthorities(); - Task UpdateCertificateAuthority(CertificateAuthority ca); - Task DeleteCertificateAuthority(string id); - Task> GetAccounts(); - Task AddAccount(ContactRegistration contact); - Task UpdateAccountContact(ContactRegistration contact); - Task RemoveAccount(string storageKey, bool deactivate); - Task ChangeAccountKey(string storageKey, string newKeyPEM = null); + Task> GetCertificateAuthorities(AuthContext authContext = null); + Task UpdateCertificateAuthority(CertificateAuthority ca, AuthContext authContext = null); + Task DeleteCertificateAuthority(string id, AuthContext authContext = null); + Task> GetAccounts(AuthContext authContext = null); + Task AddAccount(ContactRegistration contact, AuthContext authContext = null); + Task UpdateAccountContact(ContactRegistration contact, AuthContext authContext = null); + Task RemoveAccount(string storageKey, bool deactivate, AuthContext authContext = null); + Task ChangeAccountKey(string storageKey, string newKeyPEM = null, AuthContext authContext = null); #endregion Accounts + } /// @@ -136,7 +133,6 @@ public interface ICertifyInternalApiClient /// public interface ICertifyClient : ICertifyInternalApiClient { - event Action OnMessageFromService; event Action OnRequestProgressStateUpdated; diff --git a/src/Certify.Client/ManagementServerClient.cs b/src/Certify.Client/ManagementServerClient.cs new file mode 100644 index 000000000..ed9cc01cf --- /dev/null +++ b/src/Certify.Client/ManagementServerClient.cs @@ -0,0 +1,138 @@ +using System; +using System.Threading.Tasks; +using Certify.API.Management; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.DependencyInjection; + +namespace Certify.Client +{ + /// + /// Implements hub communication with a central management server + /// + public class ManagementServerClient + { + + public event Action OnConnectionReconnecting; + + public event Action OnConnectionReconnected; + + public event Action OnConnectionClosed; + + public event Func OnGetInstanceItems; + public event Func> OnGetCommandResult; + + private HubConnection _connection; + + private string _hubUri = ""; + + private ManagedInstanceInfo _instanceInfo; + + public ManagementServerClient(string hubUri, ManagedInstanceInfo instanceInfo) + { + _hubUri = $"{hubUri}"; + _instanceInfo = instanceInfo; + } + + public bool IsConnected() + { + if (_connection == null || _connection?.State == HubConnectionState.Disconnected) + { + return false; + } + + return true; + } + + public async Task ConnectAsync() + { + var allowUntrusted = true; + + _connection = new HubConnectionBuilder() + + .WithUrl(_hubUri, opts => + { + opts.HttpMessageHandlerFactory = (message) => + { + if (message is System.Net.Http.HttpClientHandler clientHandler) + { + if (allowUntrusted) + { + // allow invalid/untrusted tls cert + clientHandler.ServerCertificateCustomValidationCallback += + (sender, certificate, chain, sslPolicyErrors) => true; + } + } + + return message; + }; + }) + .WithAutomaticReconnect() + .AddMessagePackProtocol() + .Build(); + + _connection.On(ManagementHubMessages.SendCommandRequest, (Action)((s) => + { + PerformRequestedCommand(s); + })); + + await _connection.StartAsync(); + + _connection.Closed += async (error) => + { + await Task.Delay(new Random().Next(0, 5) * 1000); + await _connection.StartAsync(); + }; + + } + + public async Task Disconnect() + { + await _connection.StopAsync(); + + } + private void PerformRequestedCommand(InstanceCommandRequest cmd) + { + System.Diagnostics.Debug.WriteLine($"Got command from management server {cmd}"); + + if (cmd.CommandType == ManagementHubCommands.GetInstanceInfo) + { + SendInstanceInfo(cmd.CommandId); + } + else + { + var task = OnGetCommandResult?.Invoke(cmd); + if (task != null) + { + if (cmd.CommandType != ManagementHubCommands.Reconnect) + { + _connection.SendAsync(ManagementHubMessages.ReceiveCommandResult, task.Result).Wait(); + } + else + { + task.Wait(); + } + } + } + } + + /// + /// Send instance info back to the management hub + /// + /// Unique ID for this command, New Guid if command is not a response + /// If false, message is not being sent in response to an existing query + public void SendInstanceInfo(Guid commandId, bool isCommandResponse = true) + { + // send this clients instance ID back to the hub to identify it in the connection: should send a shared secret before this to confirm this client knows and is not impersonating another instance + var result = new InstanceCommandResult + { + CommandId = commandId, + CommandType = ManagementHubCommands.GetInstanceInfo, + Value = System.Text.Json.JsonSerializer.Serialize(_instanceInfo), + IsCommandResponse = isCommandResponse + }; + + result.ObjectValue = _instanceInfo; + _connection.SendAsync(ManagementHubMessages.ReceiveCommandResult, result); + } + } +} diff --git a/src/Certify.Core.Service.sln b/src/Certify.Core.Service.sln index a6b5fb3f5..410c05d24 100644 --- a/src/Certify.Core.Service.sln +++ b/src/Certify.Core.Service.sln @@ -69,6 +69,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Certify.Providers.ACME.Anvi EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Certify.ACME.Anvil", "..\..\libs\anvil\src\Certify.ACME.Anvil\Certify.ACME.Anvil.csproj", "{443202E1-B6E5-4625-BC3E-B3CB54CF4055}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Certify.Server.Api.Public", "Certify.Server\Certify.Server.Api.Public\Certify.Server.Api.Public.csproj", "{2DB50C13-7535-4D01-8EA5-1839F1472D7B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -277,6 +279,14 @@ Global {443202E1-B6E5-4625-BC3E-B3CB54CF4055}.Release|Any CPU.Build.0 = Release|Any CPU {443202E1-B6E5-4625-BC3E-B3CB54CF4055}.Release|x64.ActiveCfg = Release|Any CPU {443202E1-B6E5-4625-BC3E-B3CB54CF4055}.Release|x64.Build.0 = Release|Any CPU + {2DB50C13-7535-4D01-8EA5-1839F1472D7B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2DB50C13-7535-4D01-8EA5-1839F1472D7B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2DB50C13-7535-4D01-8EA5-1839F1472D7B}.Debug|x64.ActiveCfg = Debug|Any CPU + {2DB50C13-7535-4D01-8EA5-1839F1472D7B}.Debug|x64.Build.0 = Debug|Any CPU + {2DB50C13-7535-4D01-8EA5-1839F1472D7B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2DB50C13-7535-4D01-8EA5-1839F1472D7B}.Release|Any CPU.Build.0 = Release|Any CPU + {2DB50C13-7535-4D01-8EA5-1839F1472D7B}.Release|x64.ActiveCfg = Release|Any CPU + {2DB50C13-7535-4D01-8EA5-1839F1472D7B}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Certify.Core/Certify.Core.csproj b/src/Certify.Core/Certify.Core.csproj index 144ad0de9..21b639062 100644 --- a/src/Certify.Core/Certify.Core.csproj +++ b/src/Certify.Core/Certify.Core.csproj @@ -1,96 +1,71 @@ - - - net462;netstandard2.0;netstandard2.1 - Debug;Release; - AnyCPU - - - - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - x64 - - - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - x64 - - - - true - bin\Release\ - TRACE - prompt - 4 - x64 - - - - true - bin\Release\ - TRACE - prompt - 4 - x64 - - - - bin\x64\Debug\ - DEBUG;TRACE - - x64 - prompt - MinimumRecommendedRules.ruleset - - - bin\x64\Release\ - TRACE - true - - x64 - prompt - MinimumRecommendedRules.ruleset - - - Debug - AnyCPU - {58881E46-4A76-47B9-9725-FA7C5F0090D0} - Library - Properties - Certify.Core - Certify.Core - v4.6.2 - 512 - - PackageReference - - - - - - - - - - - - - - - - - + + + net462;net9.0 + Debug;Release; + AnyCPU + + - - - CertifyManager.cs - - + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + AnyCPU + + + + + true + bin\Release\ + TRACE + prompt + 4 + AnyCPU + + + + + Debug + AnyCPU + {58881E46-4A76-47B9-9725-FA7C5F0090D0} + Library + Properties + Certify.Core + Certify.Core + v4.6.2 + 512 + + PackageReference + + + 1701;1702;CA1068 + + + 1701;1702;CA1068 + + + 1701;1702;CA1068 + + + 1701;1702;CA1068 + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Certify.Core/Management/Access/AccessControl.cs b/src/Certify.Core/Management/Access/AccessControl.cs index 4c79e939a..5610ccc25 100644 --- a/src/Certify.Core/Management/Access/AccessControl.cs +++ b/src/Certify.Core/Management/Access/AccessControl.cs @@ -1,245 +1,281 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Security.Cryptography; +using System.Text; using System.Threading.Tasks; +using Certify.Models.API; +using Certify.Models.Config.AccessControl; using Certify.Models.Providers; +using Certify.Providers; namespace Certify.Core.Management.Access { - public enum SecurityPrincipleType - { - User = 1, - Application = 2 - } - - public class SecurityPrinciple - { - public string Id { get; set; } - public string Username { get; set; } - public string Password { get; set; } - public string Email { get; set; } - public string Description { get; set; } - - /// - /// If true, user is a mapping to an external AD/LDAP group or user - /// - public bool IsDirectoryMapping { get; set; } - - public List SystemRoleIds { get; set; } - - public SecurityPrincipleType PrincipleType { get; set; } - - public string AuthKey { get; set; } - } - - public class StandardRoles - { - public static Role Administrator { get; } = new Role("sysadmin", "Administrator", "Certify Server Administrator"); - public static Role DomainOwner { get; } = new Role("domain_owner", "Domain Owner", "Controls certificate access for a given domain"); - public static Role DomainRequestor { get; } = new Role("subdomain_requestor", "Subdomain Requestor", "Can request new certs for subdomains on a given domain"); - public static Role CertificateConsumer { get; } = new Role("cert_consumer", "Certificate Consumer", "User of a given certificate"); - - } - - public class ResourceTypes - { - public static string System { get; } = "system"; - public static string Domain { get; } = "domain"; - } - public class Role + public class AccessControl : IAccessControl { - public string Id { get; set; } - public string Title { get; set; } - public string Description { get; set; } + private IAccessControlStore _store; + private ILog _log; - public Role() { } - public Role(string id, string title, string description) + public AccessControl(ILog log, IAccessControlStore store) { - Id = id; - Title = title; - Description = description; + _store = store; + _log = log; } - } - public class ResourceAssignedRole - { - public string PrincipleId { get; set; } - public string RoleId { get; set; } - } - /// - /// Define a domain or resource and who the controlling users are - /// - public class ResourceProfile - { - public string Id { get; set; } = new Guid().ToString(); - public string ResourceType { get; set; } - public string Identifier { get; set; } - public List AssignedRoles { get; set; } - // public List DefaultChallenges { get; set; } - } - - public interface IObjectStore - { - Task Save(string id, object item); - Task Load(string id); - } - - public class AccessControl - { - private IObjectStore _store; - private ILog _log; + public async Task AuditWarning(string template, params object[] propertyvalues) + { + _log?.Warning(template, propertyvalues); + } - public AccessControl(ILog log, IObjectStore store) + public async Task AuditError(string template, params object[] propertyvalues) { - _store = store; - _log = log; + _log?.Error(template, propertyvalues); } - public async Task> GetSystemRoles() + public async Task AuditInformation(string template, params object[] propertyvalues) { + _log?.Information(template, propertyvalues); + } - return await Task.FromResult(new List + /// + /// Check if the system has been initialized with a security principle + /// + /// + public async Task IsInitialized() + { + var list = await GetSecurityPrinciples("system"); + if (list.Count != 0) { - StandardRoles.Administrator, - StandardRoles.DomainOwner, - StandardRoles.CertificateConsumer - }); + return true; + } + else + { + return false; + } } - public async Task> GetSecurityPrinciples() + public async Task> GetRoles() { - return await _store.Load>("principles"); + return await _store.GetItems(nameof(Role)); } - public async Task AddSecurityPrinciple(SecurityPrinciple principle, string contextUserId, bool bypassIntegrityCheck = false) + public async Task> GetSecurityPrinciples(string contextUserId) { - if (!await IsPrincipleInRole(contextUserId, StandardRoles.Administrator.Id, contextUserId) && !bypassIntegrityCheck) + return await _store.GetItems(nameof(SecurityPrinciple)); + } + + public async Task AddSecurityPrinciple(string contextUserId, SecurityPrinciple principle, bool bypassIntegrityCheck = false) + { + if (!bypassIntegrityCheck && !await IsPrincipleInRole(contextUserId, contextUserId, StandardRoles.Administrator.Id)) { - _log?.Warning($"User {contextUserId} attempted to use AddSecurityPrinciple [{principle?.Id}] without being in required role."); + await AuditWarning("User {contextUserId} attempted to use AddSecurityPrinciple [{principleId}] without being in required role.", contextUserId, principle?.Id); return false; } - var principles = await GetSecurityPrinciples(); - principles.Add(principle); - await _store.Save>("principles", principles); + var existing = await GetSecurityPrinciple(contextUserId, principle.Id); + if (existing != null) + { + await AuditWarning("User {contextUserId} attempted to use AddSecurityPrinciple [{principleId}] which already exists.", contextUserId, principle?.Id); + return false; + } - _log?.Information($"User {contextUserId} added security principle [{principle?.Id}] {principle?.Username}"); + if (!string.IsNullOrWhiteSpace(principle.Password)) + { + principle.Password = HashPassword(principle.Password); + } + else + { + principle.Password = HashPassword(Guid.NewGuid().ToString()); + } + + principle.AvatarUrl = GetAvatarUrlForPrinciple(principle); + + await _store.Add(nameof(SecurityPrinciple), principle); + + await AuditInformation("User {contextUserId} added security principle [{principleId}] {username}", contextUserId, principle?.Id, principle?.Username); return true; } - public async Task UpdateSecurityPrinciple(SecurityPrinciple principle, string contextUserId) + public string GetAvatarUrlForPrinciple(SecurityPrinciple principle) + { + return string.IsNullOrWhiteSpace(principle.Email) ? "https://gravatar.com/avatar/00000000000000000000000000000000" : $"https://gravatar.com/avatar/{GetSHA256Hash(principle.Email.Trim().ToLower())}"; + } + + public async Task UpdateSecurityPrinciple(string contextUserId, SecurityPrinciple principle) { - if (!await IsPrincipleInRole(contextUserId, StandardRoles.Administrator.Id, contextUserId)) + if (!await IsPrincipleInRole(contextUserId, contextUserId, StandardRoles.Administrator.Id)) { - _log?.Warning($"User {contextUserId} attempted to use UpdateSecurityPrinciple [{principle?.Id}] without being in required role."); + await AuditWarning("User {contextUserId} attempted to use UpdateSecurityPrinciple [{principleId}] without being in required role.", contextUserId, principle?.Id); return false; } - var principles = await GetSecurityPrinciples(); + try + { + var updateSp = await _store.Get(nameof(SecurityPrinciple), principle.Id); + updateSp.Email = principle.Email; + updateSp.Description = principle.Description; + updateSp.Title = principle.Title; - var existing = principles.Find(p => p.Id == principle.Id); - if (existing != null) + updateSp.AvatarUrl = GetAvatarUrlForPrinciple(principle); + + await _store.Update(nameof(SecurityPrinciple), updateSp); + } + catch { - principles.Remove(existing); + await AuditWarning("User {contextUserId} attempted to use UpdateSecurityPrinciple [{principleId}], but was not successful", contextUserId, principle?.Id); + return false; } - principles.Add(principle); - await _store.Save>("principles", principles); - - _log?.Information($"User {contextUserId} updated security principle [{principle?.Id}] {principle?.Username}"); + await AuditInformation("User {contextUserId} updated security principle [{principleId}] {principleUsername}", contextUserId, principle?.Id, principle?.Username); return true; } /// /// delete a single security principle /// - /// /// + /// /// - public async Task DeleteSecurityPrinciple(string id, string contextUserId) + public async Task DeleteSecurityPrinciple(string contextUserId, string id, bool allowSelfDelete = false) { - if (!await IsPrincipleInRole(contextUserId, StandardRoles.Administrator.Id, contextUserId)) + if (!await IsPrincipleInRole(contextUserId, contextUserId, StandardRoles.Administrator.Id)) { - _log?.Warning($"User {contextUserId} attempted to use DeleteSecurityPrinciple [{id}] without being in required role."); + await AuditWarning("User {contextUserId} attempted to use DeleteSecurityPrinciple [{id}] without being in required role.", contextUserId, id); return false; } - if (id == contextUserId) + if (!allowSelfDelete && id == contextUserId) { - _log?.Information($"User {contextUserId} tried to delete themselves."); + await AuditWarning("User {contextUserId} tried to delete themselves.", contextUserId); return false; } - var principles = await GetSecurityPrinciples(); + var existing = await GetSecurityPrinciple(contextUserId, id); - var existing = principles.Find(p => p.Id == id); - if (existing != null) + var deleted = await _store.Delete(nameof(SecurityPrinciple), id); + + if (deleted != true) { - principles.Remove(existing); + await AuditWarning("User {contextUserId} attempted to delete security principle [{id}] {existingUsername}, but was not successful", contextUserId, id, existing?.Username); + return false; } - await _store.Save>("principles", principles); - - // TODO: remove assigned roles within all resource profiles - - var allResourceProfiles = await GetResourceProfiles(id, contextUserId); - foreach (var r in allResourceProfiles) + var assignedRoles = await GetAssignedRoles(contextUserId, id); + foreach (var a in assignedRoles) { - if (r.AssignedRoles.Any(ro => ro.PrincipleId == id)) - { - var newAssignedRoles = r.AssignedRoles.Where(ra => ra.PrincipleId != id).ToList(); - r.AssignedRoles = newAssignedRoles; - } + await _store.Delete(nameof(AssignedRole), a.Id); } - await _store.Save>("resourceprofiles", allResourceProfiles); - - _log?.Information($"User {contextUserId} deleted security principle [{id}] {existing?.Username}"); + await AuditInformation("User {contextUserId} deleted security principle [{id}] {existingUsername}", contextUserId, id, existing?.Username); return true; } - public async Task IsAuthorised(string principleId, string roleId, string resourceType, string identifier, string contextUserId) + public async Task GetSecurityPrinciple(string contextUserId, string id) { - var resourceProfiles = await GetResourceProfiles(principleId, contextUserId); - - if (resourceProfiles.Any(r => r.ResourceType == resourceType && r.Identifier == identifier && r.AssignedRoles.Any(a => a.PrincipleId == principleId && a.RoleId == roleId))) + try { - // principle has an exactly matching role granted for this resource - return true; + return await _store.Get(nameof(SecurityPrinciple), id); } + catch (Exception exp) + { + await AuditError("User {contextUserId} attempted to retrieve security principle [{id}] but was not successful : {exp}", contextUserId, id, exp); - if (resourceType == ResourceTypes.Domain && !identifier.Trim().StartsWith("*") && identifier.Contains(".")) + return default; + } + } + + public async Task GetSecurityPrincipleByUsername(string contextUserId, string username) + { + if (string.IsNullOrWhiteSpace(username)) { - // get wildcard for respective domain identifier - var identifierComponents = identifier.Split('.'); + return default; + } + + var list = await GetSecurityPrinciples(contextUserId); + + return list?.SingleOrDefault(sp => sp.Username?.ToLowerInvariant() == username.ToLowerInvariant()); + } + + public async Task IsAuthorised(string contextUserId, string principleId, string roleId, string resourceType, string actionId, string identifier) + { + // to determine is a principle has access to perform a particular action + // for each group the principle is part of + + // TODO: cache results for performance + + var allAssignedRoles = await _store.GetItems(nameof(AssignedRole)); - var wildcard = "*." + string.Join(".", identifierComponents.Skip(1)); + var spAssigned = allAssignedRoles.Where(a => a.SecurityPrincipleId == principleId); - if (resourceProfiles.Any(r => r.ResourceType == resourceType && r.Identifier == wildcard && r.AssignedRoles.Any(a => a.PrincipleId == principleId && a.RoleId == roleId))) + var allRoles = await _store.GetItems(nameof(Role)); + + var spAssignedRoles = allRoles.Where(r => spAssigned.Any(t => t.RoleId == r.Id)); + + var spSpecificAssignedRoles = spAssigned.Where(a => spAssignedRoles.Any(r => r.Id == a.RoleId)); + + var allPolicies = await _store.GetItems(nameof(ResourcePolicy)); + + var spAssignedPolicies = allPolicies.Where(r => spAssignedRoles.Any(p => p.Policies.Contains(r.Id))); + + if (spAssignedPolicies.Any(a => a.ResourceActions.Contains(actionId))) + { + // if any of the service principles assigned roles are restricted by the type of resource type, + // check for identifier matches (e.g. role assignment restricted on domains ) + if (spSpecificAssignedRoles.Any(a => a.IncludedResources.Any(r => r.ResourceType == resourceType))) + { + var allIncludedResources = spSpecificAssignedRoles.SelectMany(a => a.IncludedResources).Distinct(); + + if (resourceType == ResourceTypes.Domain && !identifier.Trim().StartsWith("*") && identifier.Contains(".")) + { + // get wildcard for respective domain identifier + var identifierComponents = identifier.Split('.'); + + var wildcard = "*." + string.Join(".", identifierComponents.Skip(1)); + + // search for matching identifier + + foreach (var includedResource in allIncludedResources) + { + if (includedResource.ResourceType == resourceType && includedResource.Identifier == wildcard) + { + return true; + } + else if (includedResource.ResourceType == resourceType && includedResource.Identifier == identifier) + { + return true; + } + } + } + + // no match + return false; + } + else { - // principle has an matching role granted for this resource as a wildcard return true; } } - - return false; + else + { + return false; + } } /// /// Check security principle is in a given role at the system level /// + /// /// /// - /// /// - public async Task IsPrincipleInRole(string id, string roleId, string contextUserId) + public async Task IsPrincipleInRole(string contextUserId, string id, string roleId) { - var resourceProfiles = await GetResourceProfiles(id, contextUserId); + var assignedRoles = await _store.GetItems(nameof(AssignedRole)); - if (resourceProfiles.Any(r => r.ResourceType == ResourceTypes.System && r.AssignedRoles.Any(a => a.PrincipleId == id && a.RoleId == roleId))) + if (assignedRoles.Any(a => a.RoleId == roleId && a.SecurityPrincipleId == id)) { return true; } @@ -249,46 +285,239 @@ public async Task IsPrincipleInRole(string id, string roleId, string conte } } - /// - /// return list of resources this user has some access to - /// - /// - /// - public async Task> GetResourceProfiles(string userId, string contextUserId) + public async Task AddResourcePolicy(string contextUserId, ResourcePolicy resourceProfile, bool bypassIntegrityCheck = false) { - var allResourceProfiles = await _store.Load>("resourceprofiles"); + if (!bypassIntegrityCheck && !await IsPrincipleInRole(contextUserId, contextUserId, StandardRoles.Administrator.Id)) + { + await AuditWarning("User {contextUserId} attempted to use AddResourcePolicy [{resourceProfileId}] without being in required role.", contextUserId, resourceProfile?.Id); + return false; + } - if (userId != null) + await _store.Add(nameof(ResourcePolicy), resourceProfile); + + await AuditInformation("User {contextUserId} added resource policy [{resourceProfile.Id}]", contextUserId, resourceProfile?.Id); + return true; + } + + public async Task UpdateSecurityPrinciplePassword(string contextUserId, SecurityPrinciplePasswordUpdate passwordUpdate) + { + if (passwordUpdate.SecurityPrincipleId != contextUserId && !await IsPrincipleInRole(contextUserId, contextUserId, StandardRoles.Administrator.Id)) { - var filteredprofiles = allResourceProfiles.Where(r => r.AssignedRoles.Any(ra => ra.PrincipleId == userId)); + await AuditWarning("User {contextUserId} attempted to use updated password for [{id}] without being in required role.", contextUserId, passwordUpdate.SecurityPrincipleId); + return false; + } + + var updated = false; + + var principle = await GetSecurityPrinciple(contextUserId, passwordUpdate.SecurityPrincipleId); - foreach (var f in filteredprofiles) + if (IsPasswordValid(passwordUpdate.Password, principle.Password)) + { + try + { + var updateSp = await _store.Get(nameof(SecurityPrinciple), principle.Id); + updateSp.Password = HashPassword(passwordUpdate.NewPassword); + await _store.Update(nameof(SecurityPrinciple), updateSp); + updated = true; + } + catch { - f.AssignedRoles = f.AssignedRoles.Where(a => a.PrincipleId == userId).ToList(); + await AuditWarning("User {contextUserId} attempted to use UpdateSecurityPrinciple password [{principleId}], but was not successful", contextUserId, principle?.Id); + return false; } + } + else + { + await AuditInformation("Previous password did not match while updating security principle password", contextUserId, principle.Username, principle.Id); + } - return filteredprofiles.ToList(); + if (updated) + { + await AuditInformation("User {contextUserId} updated password for [{username} - {id}]", contextUserId, principle.Username, principle.Id); } else { - return allResourceProfiles; + + await AuditWarning("User {contextUserId} failed to update password for [{username} - {id}]", contextUserId, principle.Username, principle.Id); } + + return updated; } - public async Task AddResourceProfile(ResourceProfile resourceProfile, string contextUserId, bool bypassIntegrityCheck = false) + public bool IsPasswordValid(string password, string currentHash) { - if (!await IsPrincipleInRole(contextUserId, StandardRoles.Administrator.Id, contextUserId) && !bypassIntegrityCheck) + if (string.IsNullOrWhiteSpace(currentHash) && string.IsNullOrWhiteSpace(password)) { - _log?.Warning($"User {contextUserId} attempted to use AddResourceProfile [{resourceProfile.Identifier}] without being in required role."); + return true; + } + + var components = currentHash.Split('.'); + + // hash provided password with same salt to compare result + return currentHash == HashPassword(password, components[1]); + } + + /// + /// Hash password, optionally using the provided salt or generating new salt + /// + /// + /// + /// + public string HashPassword(string password, string saltString = null) + { + var iterations = 600000; + var salt = new byte[24]; + + if (saltString == null) + { + RandomNumberGenerator.Create().GetBytes(salt); + } + else + { + salt = Convert.FromBase64String(saltString); + } +#if NET8_0_OR_GREATER + var pbkdf2 = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA512); +#else + var pbkdf2 = new Rfc2898DeriveBytes(password, salt, iterations); +#endif + + var hash = pbkdf2.GetBytes(24); + + var hashed = $"v1.{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}"; + + return hashed; + } + + public async Task AddRole(Role r) + { + await _store.Add(nameof(Role), r); + } + + public async Task AddAssignedRole(AssignedRole r) + { + await _store.Add(nameof(AssignedRole), r); + } + + public async Task AddResourceAction(ResourceAction action) + { + await _store.Add(nameof(ResourceAction), action); + } + + public async Task> GetAssignedRoles(string contextUserId, string id) + { + if (id != contextUserId && !await IsPrincipleInRole(contextUserId, contextUserId, StandardRoles.Administrator.Id)) + { + await AuditWarning("User {contextUserId} attempted to read assigned role for [{id}] without being in required role.", contextUserId, id); + return new List(); + } + + var assignedRoles = await _store.GetItems(nameof(AssignedRole)); + + return assignedRoles.Where(r => r.SecurityPrincipleId == id).ToList(); + } + + public async Task GetSecurityPrincipleRoleStatus(string contextUserId, string id) + { + if (id != contextUserId && !await IsPrincipleInRole(contextUserId, contextUserId, StandardRoles.Administrator.Id)) + { + await AuditWarning("User {contextUserId} attempted to read role status role for [{id}] without being in required role.", contextUserId, id); + + } + + var allAssignedRoles = await _store.GetItems(nameof(AssignedRole)); + var allRoles = await _store.GetItems(nameof(Role)); + var allPolicies = await _store.GetItems(nameof(ResourcePolicy)); + var allActions = await _store.GetItems(nameof(ResourceAction)); + + var spAssignedRoles = allAssignedRoles.Where(a => a.SecurityPrincipleId == id); + var spRoles = allRoles.Where(r => spAssignedRoles.Any(t => t.RoleId == r.Id)); + var spPolicies = allPolicies.Where(r => spRoles.Any(p => p.Policies.Contains(r.Id))); + var spActions = allActions.Where(r => spPolicies.Any(p => p.ResourceActions.Contains(r.Id))); + + var roleStatus = new RoleStatus + { + AssignedRoles = spAssignedRoles, + Roles = spRoles, + Policies = spPolicies, + Action = spActions + }; + + return roleStatus; + } + + public async Task UpdateAssignedRoles(string contextUserId, SecurityPrincipleAssignedRoleUpdate update) + { + if (!await IsPrincipleInRole(contextUserId, contextUserId, StandardRoles.Administrator.Id)) + { + await AuditWarning("User {contextUserId} attempted to update assigned role for [{id}] without being in required role.", contextUserId, update.SecurityPrincipleId); return false; } - var profiles = await GetResourceProfiles(null, contextUserId); - profiles.Add(resourceProfile); - await _store.Save>("resourceprofiles", profiles); + // remove items from assigned roles + var existing = await GetAssignedRoles(contextUserId, update.SecurityPrincipleId); + foreach (var deleted in update.RemovedAssignedRoles) + { + var e = existing.FirstOrDefault(r => r.RoleId == deleted.RoleId); + if (e != null) + { + await _store.Delete(nameof(AssignedRole), e.Id); + } + } + + // add items to assigned roles + existing = await GetAssignedRoles(contextUserId, update.SecurityPrincipleId); + foreach (var added in update.AddedAssignedRoles) + { + if (!existing.Exists(r => r.RoleId == added.RoleId)) + { + await _store.Add(nameof(AssignedRole), added); + } + } - _log?.Information($"User {contextUserId} added resource profile [{resourceProfile.Identifier}]"); return true; } + + public async Task CheckSecurityPrinciplePassword(string contextUserId, SecurityPrinciplePasswordCheck passwordCheck) + { + var principle = string.IsNullOrWhiteSpace(passwordCheck.SecurityPrincipleId) ? + await GetSecurityPrincipleByUsername(contextUserId, passwordCheck.Username) : + await GetSecurityPrinciple(contextUserId, passwordCheck.SecurityPrincipleId); + + if (principle != null && IsPasswordValid(passwordCheck.Password, principle.Password)) + { + return new SecurityPrincipleCheckResponse { IsSuccess = true, SecurityPrinciple = principle }; + } + else + { + if (principle == null) + { + return new SecurityPrincipleCheckResponse { IsSuccess = false, Message = "Invalid security principle" }; + } + else + { + return new SecurityPrincipleCheckResponse { IsSuccess = false, Message = "Invalid password" }; + } + } + } + + public string GetSHA256Hash(string val) + { + using (var sha256Hash = SHA256.Create()) + { + var data = sha256Hash.ComputeHash(Encoding.UTF8.GetBytes(val)); + var sBuilder = new StringBuilder(); + + // Loop through each byte of the hashed data + // and format each one as a hexadecimal string. + for (var i = 0; i < data.Length; i++) + { + sBuilder.Append(data[i].ToString("x2")); + } + + // Return the hexadecimal string. + return sBuilder.ToString(); + } + } } } diff --git a/src/Certify.Core/Management/Access/IAccessControl.cs b/src/Certify.Core/Management/Access/IAccessControl.cs new file mode 100644 index 000000000..8e9a8b8c9 --- /dev/null +++ b/src/Certify.Core/Management/Access/IAccessControl.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Certify.Models.API; +using Certify.Models.Config.AccessControl; + +namespace Certify.Core.Management.Access +{ + public interface IAccessControl + { + Task AddResourcePolicy(string contextUserId, ResourcePolicy resourceProfile, bool bypassIntegrityCheck = false); + Task AddSecurityPrinciple(string contextUserId, SecurityPrinciple principle, bool bypassIntegrityCheck = false); + Task DeleteSecurityPrinciple(string contextUserId, string id, bool allowSelfDelete = false); + Task> GetSecurityPrinciples(string contextUserId); + Task GetSecurityPrinciple(string contextUserId, string id); + + /// + /// Get the list of standard roles built-in to the system + /// + /// + Task> GetRoles(); + Task IsAuthorised(string contextUserId, string principleId, string roleId, string resourceType, string actionId, string identifier); + Task IsPrincipleInRole(string contextUserId, string id, string roleId); + Task> GetAssignedRoles(string contextUserId, string id); + Task GetSecurityPrincipleRoleStatus(string contextUserId, string id); + Task UpdateSecurityPrinciple(string contextUserId, SecurityPrinciple principle); + Task UpdateAssignedRoles(string contextUserId, SecurityPrincipleAssignedRoleUpdate update); + Task UpdateSecurityPrinciplePassword(string contextUserId, SecurityPrinciplePasswordUpdate passwordUpdate); + Task CheckSecurityPrinciplePassword(string contextUserId, SecurityPrinciplePasswordCheck passwordCheck); + + Task AddRole(Role role); + Task AddAssignedRole(AssignedRole assignedRole); + Task AddResourceAction(ResourceAction action); + Task IsInitialized(); + } +} diff --git a/src/Certify.Core/Management/BindingDeploymentManager.cs b/src/Certify.Core/Management/BindingDeploymentManager.cs index 96593849c..8a7f92ec0 100644 --- a/src/Certify.Core/Management/BindingDeploymentManager.cs +++ b/src/Certify.Core/Management/BindingDeploymentManager.cs @@ -1,13 +1,11 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net; using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; using Certify.Management; using Certify.Models; using Certify.Models.Providers; -using Microsoft.Web.Administration; namespace Certify.Core.Management { @@ -348,7 +346,7 @@ private async Task> DeployToAllTargetBindings(IBindingDeploymen { sslPort = int.Parse(requestConfig.BindingPort); - if (sslPort!=443) + if (sslPort != 443) { var step = new ActionStep(category, "Binding Port", $"A non-standard http port has been requested ({sslPort}) ."); bindingExplanationSteps.Add(step); @@ -408,7 +406,7 @@ private async Task> DeployToAllTargetBindings(IBindingDeploymen ); stepActions.First().Substeps = bindingExplanationSteps; - + actions.AddRange(stepActions); } else diff --git a/src/Certify.Core/Management/CertifyManager/CertifyManager.Account.cs b/src/Certify.Core/Management/CertifyManager/CertifyManager.Account.cs index c93efeb16..9db9874a8 100644 --- a/src/Certify.Core/Management/CertifyManager/CertifyManager.Account.cs +++ b/src/Certify.Core/Management/CertifyManager/CertifyManager.Account.cs @@ -7,13 +7,14 @@ using Certify.Models.Config; using Certify.Models.Providers; using Certify.Providers.ACME.Anvil; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; namespace Certify.Management { public partial class CertifyManager { - private static object _accountsLock = new object(); + private static readonly Lock _accountsLock = LockFactory.Create(); private List _accounts; /// @@ -184,7 +185,7 @@ public async Task GetAccountDetails(ManagedCertificate item, boo if (defaultMatchingAccount == null) { - var log = ManagedCertificateLog.GetLogger(item.Id, new Serilog.Core.LoggingLevelSwitch(Serilog.Events.LogEventLevel.Error)); + var log = ManagedCertificateLog.GetLogger(item.Id, LogLevel.Error); log?.Error($"Failed to match ACME account for managed certificate. Cannot continue request. :: {item.Name} CA: {currentCA} {(item.UseStagingMode ? "[Staging Mode]" : "[Production]")}"); return null; } diff --git a/src/Certify.Core/Management/CertifyManager/CertifyManager.CertificateRequest.cs b/src/Certify.Core/Management/CertifyManager/CertifyManager.CertificateRequest.cs index 31041c9a6..cb3eb504a 100644 --- a/src/Certify.Core/Management/CertifyManager/CertifyManager.CertificateRequest.cs +++ b/src/Certify.Core/Management/CertifyManager/CertifyManager.CertificateRequest.cs @@ -85,9 +85,12 @@ public async Task> PerformRenewAll(RenewalSetting _itemManager, settings, prefs, - BeginTrackingProgress, + ReportProgress, IsManagedCertificateRunning, - (ManagedCertificate item, IProgress progress, bool isPreview, string reason) => { return PerformCertificateRequest(null, item, progress, skipRequest: isPreview, skipTasks: isPreview, reason: reason); }, + (ManagedCertificate item, IProgress progress, bool isPreview, string reason) => + { + return PerformCertificateRequest(null, item, progress, skipRequest: isPreview, skipTasks: isPreview, reason: reason); + }, progressTrackers); _isRenewAllInProgress = false; @@ -1069,9 +1072,6 @@ private async Task CompleteCertificateRequest(ILog log log?.Debug($"End of CompleteCertificateRequest."); - // cleanup progress tracking - _progressResults.TryRemove(managedCertificate.Id, out _); - return result; } diff --git a/src/Certify.Core/Management/CertifyManager/CertifyManager.DataStores.cs b/src/Certify.Core/Management/CertifyManager/CertifyManager.DataStores.cs index fd0f30faa..0358b0d04 100644 --- a/src/Certify.Core/Management/CertifyManager/CertifyManager.DataStores.cs +++ b/src/Certify.Core/Management/CertifyManager/CertifyManager.DataStores.cs @@ -11,7 +11,7 @@ namespace Certify.Management { public partial class CertifyManager { - private object _dataStoreLocker = new object(); + private readonly Lock _dataStoreLocker = LockFactory.Create(); private async Task GetManagedItemStoreProvider(DataStoreConnection dataStore) { @@ -80,11 +80,11 @@ private async Task GetCredentialManagerProvider(DataStoreCo { if (provider.ProviderCategoryId == "sqlite" && string.IsNullOrEmpty(dataStore.ConnectionConfig)) { - pr.Init("credentials", _useWindowsNativeFeatures, _serviceLog); + pr.Init(string.Empty, _serviceLog); } else { - pr.Init(dataStore.ConnectionConfig, _useWindowsNativeFeatures, _serviceLog); + pr.Init(dataStore.ConnectionConfig, _serviceLog); } if (!await pr.IsInitialised()) diff --git a/src/Certify.Core/Management/CertifyManager/CertifyManager.DeploymentTasks.cs b/src/Certify.Core/Management/CertifyManager/CertifyManager.DeploymentTasks.cs index 7d5f02c99..3024b5d97 100644 --- a/src/Certify.Core/Management/CertifyManager/CertifyManager.DeploymentTasks.cs +++ b/src/Certify.Core/Management/CertifyManager/CertifyManager.DeploymentTasks.cs @@ -236,7 +236,7 @@ private async Task> PerformTaskList(ILog log, bool isPreviewOnl log?.Information($"Task [{task.TaskConfig.TaskName}] :: {taskTriggerReason}"); task.TaskConfig.DateLastExecuted = DateTimeOffset.UtcNow; - taskResults = await task.Execute(log, _credentialsManager, result, CancellationToken.None, new DeploymentContext { PowershellExecutionPolicy = _serverConfig.PowershellExecutionPolicy }, isPreviewOnly: isPreviewOnly); + taskResults = await task.Execute(log, _credentialsManager, result, new DeploymentContext { PowershellExecutionPolicy = _serverConfig.PowershellExecutionPolicy }, isPreviewOnly: isPreviewOnly, cancellationToken: CancellationToken.None); if (!isPreviewOnly) { @@ -359,7 +359,7 @@ public async Task> ValidateDeploymentTask(ManagedCertificate try { - var execParams = new DeploymentTaskExecutionParams(null, _credentialsManager, managedCertificate, taskConfig, credentials, true, provider?.GetDefinition(), CancellationToken.None, new DeploymentContext { PowershellExecutionPolicy = _serverConfig.PowershellExecutionPolicy }); + var execParams = new DeploymentTaskExecutionParams(null, _credentialsManager, managedCertificate, taskConfig, credentials, true, provider?.GetDefinition(), new DeploymentContext { PowershellExecutionPolicy = _serverConfig.PowershellExecutionPolicy }, CancellationToken.None); var validationResult = await provider.Validate(execParams); return validationResult; } diff --git a/src/Certify.Core/Management/CertifyManager/CertifyManager.ImportExport.cs b/src/Certify.Core/Management/CertifyManager/CertifyManager.ImportExport.cs index 1ec24b5fa..8a38455d5 100644 --- a/src/Certify.Core/Management/CertifyManager/CertifyManager.ImportExport.cs +++ b/src/Certify.Core/Management/CertifyManager/CertifyManager.ImportExport.cs @@ -1,9 +1,9 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Certify.Config.Migration; using Certify.Core.Management; using Certify.Models; +using Certify.Models.Config.Migration; namespace Certify.Management { @@ -25,20 +25,24 @@ public async Task> PerformImport(ImportRequest importRequest) var hasError = false; if (!importResult.Any(i => i.HasError)) { - var deploySteps = new List(); - foreach (var m in importRequest.Package.Content.ManagedCertificates) + if (importRequest.Settings.IncludeDeployment) { - var managedCert = await GetManagedCertificate(m.Id); - if (managedCert != null && !string.IsNullOrEmpty(managedCert.CertificatePath)) + var deploySteps = new List(); + foreach (var m in importRequest.Package.Content.ManagedCertificates) { - var deployResult = await DeployCertificate(managedCert, null, isPreviewOnly: importRequest.IsPreviewMode); + var managedCert = await GetManagedCertificate(m.Id); - deploySteps.Add(new ActionStep { Category = "Deployment", HasError = !deployResult.IsSuccess, Key = managedCert.Id, Description = deployResult.Message }); + if (managedCert != null && !string.IsNullOrEmpty(managedCert.CertificatePath)) + { + var deployResult = await DeployCertificate(managedCert, null, isPreviewOnly: importRequest.IsPreviewMode); + + deploySteps.Add(new ActionStep { Category = "Deployment", HasError = !deployResult.IsSuccess, Key = managedCert.Id, Description = deployResult.Message }); + } } - } - importResult.Add(new ActionStep { Title = "Deployment" + (importRequest.IsPreviewMode ? " [Preview]" : ""), Substeps = deploySteps }); + importResult.Add(new ActionStep { Title = "Deployment" + (importRequest.IsPreviewMode ? " [Preview]" : ""), Substeps = deploySteps }); + } } else { diff --git a/src/Certify.Core/Management/CertifyManager/CertifyManager.Maintenance.cs b/src/Certify.Core/Management/CertifyManager/CertifyManager.Maintenance.cs index d9b618382..0dfb52078 100644 --- a/src/Certify.Core/Management/CertifyManager/CertifyManager.Maintenance.cs +++ b/src/Certify.Core/Management/CertifyManager/CertifyManager.Maintenance.cs @@ -5,14 +5,17 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Certify.Core.Management.Access; using Certify.Models; using Certify.Models.Config; +using Certify.Models.Config.AccessControl; using Certify.Models.Shared; namespace Certify.Management { - public partial class CertifyManager : ICertifyManager, IDisposable + public partial class CertifyManager { + /// /// Upgrade/migrate settings from previous version if applicable /// @@ -34,6 +37,87 @@ private async Task UpgradeSettings() CoreAppSettings.Current.CurrentServiceVersion = systemVersion; SettingsManager.SaveAppSettings(); + + var accessControl = await GetCurrentAccessControl(); + + if (await accessControl.IsInitialized() == false) + { + await BootstrapTestAdminUserAndRoles(accessControl); + } + else + { + await UpdateStandardRoles(accessControl); + } + } + } + + private static async Task BootstrapTestAdminUserAndRoles(IAccessControl access) + { + // setup roles with policies + await UpdateStandardRoles(access); + + var adminSp = new SecurityPrinciple + { + Id = "admin_01", + Description = "Primary default admin", + PrincipleType = SecurityPrincipleType.User, + Username = "admin", + Password = "admin", + Provider = StandardIdentityProviders.INTERNAL + }; + + await access.AddSecurityPrinciple(adminSp.Id, adminSp, bypassIntegrityCheck: true); + + // assign security principles to roles + var assignedRoles = new List { + // administrator + new AssignedRole{ + Id= Guid.NewGuid().ToString(), + RoleId=StandardRoles.Administrator.Id, + SecurityPrincipleId=adminSp.Id + } + }; + + foreach (var r in assignedRoles) + { + // add roles and policy assignments to store + await access.AddAssignedRole(r); + } + } + + /// + /// Add/update standard system roles, policies and resource actions + /// + /// + /// + private static async Task UpdateStandardRoles(IAccessControl access) + { + // setup roles with policies + + var actions = Policies.GetStandardResourceActions(); + + foreach (var action in actions) + { + await access.AddResourceAction(action); + } + + // setup policies with actions + + var policies = Policies.GetStandardPolicies(); + + // add policies to store + foreach (var r in policies) + { + _ = await access.AddResourcePolicy(null, r, bypassIntegrityCheck: true); + } + + // setup roles with policies + var roles = Policies.GetStandardRoles(); + + foreach (var r in roles) + { + // add roles and policy assignments to store + await access.AddRole(r); } } diff --git a/src/Certify.Core/Management/CertifyManager/CertifyManager.ManagedCertificates.cs b/src/Certify.Core/Management/CertifyManager/CertifyManager.ManagedCertificates.cs index 8bc72d6cc..29b00a230 100644 --- a/src/Certify.Core/Management/CertifyManager/CertifyManager.ManagedCertificates.cs +++ b/src/Certify.Core/Management/CertifyManager/CertifyManager.ManagedCertificates.cs @@ -5,19 +5,38 @@ using System.Linq; using System.Threading.Tasks; using Certify.Models; +using Certify.Models.API; using Certify.Models.Providers; +using Certify.Models.Reporting; using Certify.Models.Shared; namespace Certify.Management { public partial class CertifyManager { + public string InstanceId + { + get + { + return CoreAppSettings.Current.InstanceId; + } + } + /// /// Get managed certificate details by ID /// /// /// - public async Task GetManagedCertificate(string id) => await _itemManager.GetById(id); + public async Task GetManagedCertificate(string id) + { + var item = await _itemManager.GetById(id); + if (item != null) + { + item.InstanceId = InstanceId; + } + + return item; + } /// /// Get list of managed certificates based on then given filter criteria @@ -30,27 +49,43 @@ public async Task> GetManagedCertificates(ManagedCertif if (filter?.IncludeExternal == true) { - if (_pluginManager?.CertificateManagerProviders?.Any() == true) + var external = GetExternallyManagedCertificates(filter); + if (external != null) + { + list.AddRange(await external); + } + } + + list.ForEach(i => i.InstanceId = InstanceId); + + return list; + } + + private async Task> GetExternallyManagedCertificates(ManagedCertificateFilter filter) + { + var externalList = new List(); + if (_pluginManager?.CertificateManagerProviders?.Any() == true) + { + // TODO: cache providers/results + + // check if we have any external sources of managed certificates + foreach (var p in _pluginManager.CertificateManagerProviders) { - // TODO: cache providers/results - // check if we have any external sources of managed certificates - foreach (var p in _pluginManager.CertificateManagerProviders) + if (p != null) { - if (p != null) + var pluginType = p.GetType(); + var providers = p.GetProviders(pluginType); + + foreach (var cp in providers) { - var pluginType = p.GetType(); - var providers = p.GetProviders(pluginType); - foreach (var cp in providers) + if (cp?.IsEnabled == true) { try { - if (cp.IsEnabled) - { - var certManager = p.GetProvider(pluginType, cp.Id); - var certs = await certManager.GetManagedCertificates(filter); + var certManager = p.GetProvider(pluginType, cp.Id); + var certs = await certManager.GetManagedCertificates(filter); - list.AddRange(certs); - } + externalList.AddRange(certs); } catch (Exception ex) { @@ -58,15 +93,74 @@ public async Task> GetManagedCertificates(ManagedCertif } } } - else - { - _serviceLog?.Error($"Failed to create one or more certificate manager plugins"); - } + } + else + { + _serviceLog?.Error($"Failed to create one or more certificate manager plugins"); } } } - return list; + return externalList; + } + + /// + /// Get list of managed certificates based on then given filter criteria, as search result with total count + /// + /// + /// + public async Task GetManagedCertificateResults(ManagedCertificateFilter filter) + { + var result = new ManagedCertificateSearchResult(); + + var list = await _itemManager.Find(filter); + + list.ForEach(i => i.InstanceId = InstanceId); + + result.Results = list; + + if (filter?.IncludeExternal == true) + { + // TODO: overall set still has to be paged and sorted + var external = GetExternallyManagedCertificates(filter); + + if (external != null) + { + list.AddRange(await external); + list.ForEach(i => i.InstanceId = InstanceId); + result.Results = list; + } + } + + if (filter.PageSize > 0) + { + filter.PageSize = null; + filter.PageIndex = null; + result.TotalResults = await _itemManager.CountAll(filter); + } + + return result; + } + + public async Task GetManagedCertificateSummary(ManagedCertificateFilter filter) + { + var ms = await _itemManager.Find(filter); + + var summary = new StatusSummary(); + summary.InstanceId = InstanceId; + summary.Total = ms.Count; + summary.Healthy = ms.Count(c => c.Health == ManagedCertificateHealth.OK); + summary.Error = ms.Count(c => c.Health == ManagedCertificateHealth.Error); + summary.Warning = ms.Count(c => c.Health == ManagedCertificateHealth.Warning); + summary.AwaitingUser = ms.Count(c => c.Health == ManagedCertificateHealth.AwaitingUser); + summary.NoCertificate = ms.Count(c => c.CertificatePath == null); + + // count items with invalid config (e.g. multiple primary domains) + summary.InvalidConfig = ms.Count(c => c.DomainOptions.Count(d => d.IsPrimaryDomain) > 1); + + summary.TotalDomains = ms.Sum(s => s.RequestConfig.SubjectAlternativeNames.Count()); + + return summary; } /// @@ -82,6 +176,8 @@ public async Task UpdateManagedCertificate(ManagedCertificat // store managed cert in database store managedCert = await _itemManager.Update(managedCert); + managedCert.InstanceId = InstanceId; + // report request state to status hub clients _statusReporting?.ReportManagedCertificateUpdated(managedCert); @@ -147,7 +243,7 @@ private async Task UpdateManagedCertificateStatus(ManagedCertificate managedCert await ReportManagedCertificateStatus(managedCertificate); } - _tc?.TrackEvent("UpdateManagedCertificatesStatus_" + status.ToString()); + _tc?.TrackEvent("UpdateManagedCertificatesStatus_" + status); } private ConcurrentDictionary _statusReportQueue { get; set; } = new ConcurrentDictionary(); @@ -174,7 +270,7 @@ private async Task ReportManagedCertificateStatus(ManagedCertificate managedCert var report = new Models.Shared.RenewalStatusReport { - InstanceId = CoreAppSettings.Current.InstanceId, + InstanceId = this.InstanceId, MachineName = Environment.MachineName, PrimaryContactEmail = (await GetAccountDetails(managedCertificate, allowFailover: false))?.Email, ManagedSite = reportedCert, @@ -449,7 +545,7 @@ public async Task> GetDnsProviderZones(string providerTypeId, stri } } - public async Task GetItemLog(string id, int limit) + public async Task GetItemLog(string id, int limit) { var logPath = ManagedCertificateLog.GetLogPath(id); @@ -457,24 +553,38 @@ public async Task GetItemLog(string id, int limit) { try { + LogItem[] results = []; // TODO: use reverse stream reader for large files + var stream = System.IO.File.Open(logPath, System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.ReadWrite); + using (var streamReader = new System.IO.StreamReader(stream)) + { + var str = await streamReader.ReadToEndAsync(); + stream.Close(); + + var log = str.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Reverse() + .Take(limit) + .ToArray(); - var log = System.IO.File.ReadAllLines(logPath) - .Reverse() - .Take(limit) - .Reverse() - .ToArray(); + results = LogParser.Parse(log); + } - return log; + return results; } catch (Exception exp) { - return new string[] { $"Failed to read log: {exp}" }; + return new LogItem[] + { + new LogItem + { + LogLevel = "ERR", EventDate = DateTime.Now, Message = $"Failed to read log: {exp}" + } + }; } } else { - return await Task.FromResult(new string[] { "" }); + return await Task.FromResult(Array.Empty()); } } @@ -641,36 +751,32 @@ private async Task StartHttpChallengeServer() /// Stop our temporary http challenge response service /// /// - private async Task StopHttpChallengeServer() + private async Task StopHttpChallengeServer() { - if (_httpChallengeServerClient != null) + if (_httpChallengeServerClient == null) { - try + return; + } + + try + { + var response = await _httpChallengeServerClient.GetAsync($"http://127.0.0.1:{_httpChallengePort}/.well-known/acme-challenge/{_httpChallengeControlKey}"); + if (response.IsSuccessStatusCode) { - var response = await _httpChallengeServerClient.GetAsync($"http://127.0.0.1:{_httpChallengePort}/.well-known/acme-challenge/{_httpChallengeControlKey}"); - if (response.IsSuccessStatusCode) - { - return true; - } - else - { - try - { - if (_httpChallengeProcess != null && !_httpChallengeProcess.HasExited) - { - _httpChallengeProcess.CloseMainWindow(); - } - } - catch { } - } + return; } - catch + else { - return true; + if (_httpChallengeProcess?.HasExited == false) + { + _httpChallengeProcess.CloseMainWindow(); + } } } - - return true; + catch + { + // ignored + } } } } diff --git a/src/Certify.Core/Management/CertifyManager/CertifyManager.ManagementHub.cs b/src/Certify.Core/Management/CertifyManager/CertifyManager.ManagementHub.cs new file mode 100644 index 000000000..a9890800d --- /dev/null +++ b/src/Certify.Core/Management/CertifyManager/CertifyManager.ManagementHub.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Certify.API.Management; +using Certify.Client; +using Certify.Models; +using Certify.Shared.Core.Utils; + +namespace Certify.Management +{ + public partial class CertifyManager + { + private ManagementServerClient _managementServerClient; + private string _managementServerConnectionId = string.Empty; + + private async Task EnsureMgmtHubConnection() + { + // connect/reconnect to management hub if enabled + if (_managementServerClient == null || !_managementServerClient.IsConnected()) + { + var mgmtHubUri = Environment.GetEnvironmentVariable("CERTIFY_MANAGEMENT_HUB") ?? _serverConfig.ManagementServerHubUri; + + if (!string.IsNullOrWhiteSpace(mgmtHubUri)) + { + await StartManagementHubConnection(mgmtHubUri); + } + } + else + { + + // send heartbeat message to management hub + SendHeartbeatToManagementHub(); + } + } + + private void SendHeartbeatToManagementHub() + { + //_managementServerClient.SendInstanceInfo(Guid.NewGuid(), false); + } + + private async Task StartManagementHubConnection(string hubUri) + { + + _serviceLog.Debug("Attempting connection to management hub {hubUri}", hubUri); + + var instanceInfo = new ManagedInstanceInfo + { + InstanceId = $"{this.InstanceId}", + Title = Environment.MachineName + }; + + if (_managementServerClient != null) + { + _managementServerClient.OnGetCommandResult -= _managementServerClient_OnGetCommandResult; + _managementServerClient.OnConnectionReconnecting -= _managementServerClient_OnConnectionReconnecting; + } + + _managementServerClient = new ManagementServerClient(hubUri, instanceInfo); + + try + { + await _managementServerClient.ConnectAsync(); + + _managementServerClient.OnGetCommandResult += _managementServerClient_OnGetCommandResult; + _managementServerClient.OnConnectionReconnecting += _managementServerClient_OnConnectionReconnecting; + } + catch (Exception ex) + { + _serviceLog.Error(ex, "Failed to create connection to management hub {hubUri}", hubUri); + + _managementServerClient = null; + } + } + + private async Task _managementServerClient_OnGetCommandResult(InstanceCommandRequest arg) + { + object val = null; + + if (arg.CommandType == ManagementHubCommands.GetManagedItem) + { + // Get a single managed item by id + var args = JsonSerializer.Deserialize[]>(arg.Value); + var managedCertIdArg = args.FirstOrDefault(a => a.Key == "managedCertId"); + val = await GetManagedCertificate(managedCertIdArg.Value); + } + else if (arg.CommandType == ManagementHubCommands.GetManagedItems) + { + // Get all managed items + var items = await GetManagedCertificates(new ManagedCertificateFilter { }); + val = new ManagedInstanceItems { InstanceId = InstanceId, Items = items }; + } + else if (arg.CommandType == ManagementHubCommands.GetStatusSummary) + { + var s = await GetManagedCertificateSummary(new ManagedCertificateFilter { }); + s.InstanceId = InstanceId; + val = s; + } + else if (arg.CommandType == ManagementHubCommands.GetManagedItemLog) + { + var args = JsonSerializer.Deserialize[]>(arg.Value); + var managedCertIdArg = args.FirstOrDefault(a => a.Key == "managedCertId"); + var limit = args.FirstOrDefault(a => a.Key == "limit"); + + val = await GetItemLog(managedCertIdArg.Value, int.Parse(limit.Value)); + } + else if (arg.CommandType == ManagementHubCommands.GetManagedItemRenewalPreview) + { + var args = JsonSerializer.Deserialize[]>(arg.Value); + var managedCertArg = args.FirstOrDefault(a => a.Key == "managedCert"); + var managedCert = JsonSerializer.Deserialize(managedCertArg.Value); + + val = await GeneratePreview(managedCert); + } + else if (arg.CommandType == ManagementHubCommands.UpdateManagedItem) + { + // update a single managed item + var args = JsonSerializer.Deserialize[]>(arg.Value); + var managedCertArg = args.FirstOrDefault(a => a.Key == "managedCert"); + var managedCert = JsonSerializer.Deserialize(managedCertArg.Value); + + val = await UpdateManagedCertificate(managedCert); + } + else if (arg.CommandType == ManagementHubCommands.DeleteManagedItem) + { + // delete a single managed item + var args = JsonSerializer.Deserialize[]>(arg.Value); + var managedCertIdArg = args.FirstOrDefault(a => a.Key == "managedCertId"); + + await DeleteManagedCertificate(managedCertIdArg.Value); + } + else if (arg.CommandType == ManagementHubCommands.TestManagedItemConfiguration) + { + // test challenge response config for a single managed item + var args = JsonSerializer.Deserialize[]>(arg.Value); + var managedCertArg = args.FirstOrDefault(a => a.Key == "managedCert"); + var managedCert = JsonSerializer.Deserialize(managedCertArg.Value); + + var log = ManagedCertificateLog.GetLogger(managedCert.Id, _loggingLevelSwitch); + + val = await TestChallenge(log, managedCert, isPreviewMode: true); + + } + else if (arg.CommandType == ManagementHubCommands.PerformManagedItemRequest) + { + // attempt certificate order + var args = JsonSerializer.Deserialize[]>(arg.Value); + var managedCertIdArg = args.FirstOrDefault(a => a.Key == "managedCertId"); + var managedCert = await GetManagedCertificate(managedCertIdArg.Value); + + var progressState = new RequestProgressState(RequestState.Running, "Starting..", managedCert); + var progressIndicator = new Progress(progressState.ProgressReport); + + _ = await PerformCertificateRequest( + null, + managedCert, + progressIndicator, + resumePaused: true, + isInteractive: true + ); + + val = true; + } + else if (arg.CommandType == ManagementHubCommands.Reconnect) + { + await _managementServerClient.Disconnect(); + } + + var result = new InstanceCommandResult { CommandId = arg.CommandId, Value = JsonSerializer.Serialize(val) }; + + result.ObjectValue = val; + + return result; + } + + private void _managementServerClient_OnConnectionReconnecting() + { + _serviceLog.Warning("Reconnecting to Management."); + } + + private void GenerateDemoItems() + { + var items = DemoDataGenerator.GenerateDemoItems(); + foreach (var item in items) + { + _ = UpdateManagedCertificate(item); + } + } + } +} diff --git a/src/Certify.Core/Management/CertifyManager/CertifyManager.cs b/src/Certify.Core/Management/CertifyManager/CertifyManager.cs index 9812d4128..01da9df20 100644 --- a/src/Certify.Core/Management/CertifyManager/CertifyManager.cs +++ b/src/Certify.Core/Management/CertifyManager/CertifyManager.cs @@ -1,19 +1,17 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Threading.Tasks; -using Certify.Config.Migration; -using Certify.Core.Management; +using Certify.Core.Management.Access; using Certify.Core.Management.Challenges; using Certify.Datastore.SQLite; using Certify.Models; using Certify.Models.Providers; using Certify.Providers; -using Certify.Providers.ACME.Anvil; +using Microsoft.Extensions.Logging; using Serilog; namespace Certify.Management @@ -58,7 +56,7 @@ public partial class CertifyManager : ICertifyManager, IDisposable /// /// Current service log level setting /// - private Serilog.Core.LoggingLevelSwitch _loggingLevelSwitch { get; set; } + private LogLevel _loggingLevelSwitch { get; set; } /// /// If true, http challenge service is started @@ -75,11 +73,6 @@ public partial class CertifyManager : ICertifyManager, IDisposable /// private ConcurrentDictionary _currentChallenges = new ConcurrentDictionary(); - /// - /// Set of current in-progress renewals - /// - private ConcurrentDictionary _progressResults { get; set; } - /// /// Service for reporting status/progress results back to client(s) /// @@ -176,8 +169,6 @@ public async Task Init() throw (new Exception(msg)); } - _progressResults = new ConcurrentDictionary(); - LoadCertificateAuthorities(); // init remaining utilities and optionally enable telematics @@ -198,6 +189,15 @@ public async Task Init() await UpgradeSettings(); _serviceLog?.Information("Certify Manager Started"); + +#if DEBUG + if (Environment.GetEnvironmentVariable("CERTIFY_GENERATE_DEMO_ITEMS") == "true") + { + GenerateDemoItems(); + } +#endif + + await EnsureMgmtHubConnection(); } /// @@ -247,7 +247,7 @@ private async void _hourlyTimer_Elapsed(object sender, System.Timers.ElapsedEven private async void _heartbeatTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e) { - + await EnsureMgmtHubConnection(); } private async void _frequentTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e) @@ -289,7 +289,7 @@ private async Task InitDataStore() { // default sqlite storage _itemManager = new SQLiteManagedItemStore("", _serviceLog); - _credentialsManager = new SQLiteCredentialStore(_useWindowsNativeFeatures, storageSubfolder: "credentials"); + _credentialsManager = new SQLiteCredentialStore("", _serviceLog); } else { @@ -317,7 +317,7 @@ private async Task InitDataStore() else { _itemManager = new SQLiteManagedItemStore("", _serviceLog); - _credentialsManager = new SQLiteCredentialStore(_useWindowsNativeFeatures, storageSubfolder: "credentials"); + _credentialsManager = new SQLiteCredentialStore("", _serviceLog); } // attempt to create and delete a test item @@ -354,19 +354,21 @@ private async Task InitDataStore() /// private void InitLogging(Shared.ServiceConfig serverConfig) { - _loggingLevelSwitch = new Serilog.Core.LoggingLevelSwitch(Serilog.Events.LogEventLevel.Information); + _loggingLevelSwitch = LogLevel.Information; SetLoggingLevel(serverConfig?.LogLevel); - _serviceLog = new Loggy( - new LoggerConfiguration() - .MinimumLevel.ControlledBy(_loggingLevelSwitch) - .WriteTo.Debug() + var serilogLog = new Serilog.LoggerConfiguration() + .Enrich.FromLogContext() + .MinimumLevel.ControlledBy(ManagedCertificateLog.LogLevelSwitchFromLogLevel(_loggingLevelSwitch)) .WriteTo.File(Path.Combine(EnvironmentUtil.CreateAppDataPath("logs"), "session.log"), shared: true, flushToDiskInterval: new TimeSpan(0, 0, 10), rollOnFileSizeLimit: true, fileSizeLimitBytes: 5 * 1024 * 1024) - .CreateLogger() - ); + .CreateLogger(); + + var msLogger = new Serilog.Extensions.Logging.SerilogLoggerFactory(serilogLog).CreateLogger(); - _serviceLog?.Information($"-------------------- Logging started: {_loggingLevelSwitch.MinimumLevel} --------------------"); + _serviceLog = new Loggy(msLogger); + + _serviceLog?.Information($"-------------------- Logging started: {_loggingLevelSwitch} --------------------"); } /// @@ -378,15 +380,15 @@ public void SetLoggingLevel(string logLevel) switch (logLevel?.ToLower()) { case "debug": - _loggingLevelSwitch.MinimumLevel = Serilog.Events.LogEventLevel.Debug; + _loggingLevelSwitch = LogLevel.Trace; break; case "verbose": - _loggingLevelSwitch.MinimumLevel = Serilog.Events.LogEventLevel.Verbose; + _loggingLevelSwitch = LogLevel.Debug; break; default: - _loggingLevelSwitch.MinimumLevel = Serilog.Events.LogEventLevel.Information; + _loggingLevelSwitch = LogLevel.Information; break; } } @@ -399,25 +401,6 @@ public void SetStatusReporting(IStatusReporting statusReporting) { _statusReporting = statusReporting; } - /// - /// Begin/restart progress tracking for renewal requests of a given managed certificate - /// - /// - public void BeginTrackingProgress(RequestProgressState state) - { - try - { - if (state?.Id != null) - { - _progressResults.AddOrUpdate(state.Id, state, (id, s) => state); - } - } - catch (Exception) - { - // failed to add progress tracking, likely concurrency issue - _serviceLog?.Warning($"Failed to add tracking progress for {state.ManagedCertificate.Id}. Likely concurrency issue."); - } - } /// /// Update progress tracking and send status report to client(s). optionally logging to service log @@ -462,25 +445,17 @@ public void ReportProgress(IProgress progress, RequestProg Message = msg }, _loggingLevelSwitch); - /// - /// Get current progress result for the given managed certificate id - /// - /// - /// - public RequestProgressState GetRequestProgressState(string managedItemId) + public void Dispose() => Cleanup(); + + private void Cleanup() { - if (_progressResults.TryGetValue(managedItemId, out var progress)) + ManagedCertificateLog.DisposeLoggers(); + if (_tc != null) { - return progress; - } - else - { - return new RequestProgressState(RequestState.NotRunning, "No request in progress", null); + _tc.Dispose(); } } - public void Dispose() => ManagedCertificateLog.DisposeLoggers(); - /// /// Get the current service log (per line) /// @@ -538,5 +513,17 @@ public Task ApplyPreferences() return Task.FromResult(true); } + + private IAccessControl _accessControl; + public Task GetCurrentAccessControl() + { + if (_accessControl == null) + { + var store = new SQLiteAccessControlStore(); + _accessControl = new AccessControl(_serviceLog, store); + } + + return Task.FromResult(_accessControl); + } } } diff --git a/src/Certify.Core/Management/CertifyManager/ICertifyManager.cs b/src/Certify.Core/Management/CertifyManager/ICertifyManager.cs index ddbd48990..f145da2d3 100644 --- a/src/Certify.Core/Management/CertifyManager/ICertifyManager.cs +++ b/src/Certify.Core/Management/CertifyManager/ICertifyManager.cs @@ -3,9 +3,10 @@ using System.Collections.Generic; using System.Threading.Tasks; using Certify.Config; -using Certify.Config.Migration; using Certify.Models; +using Certify.Models.API; using Certify.Models.Config; +using Certify.Models.Config.Migration; using Certify.Models.Providers; using Certify.Providers; using Certify.Shared; @@ -27,6 +28,8 @@ public interface ICertifyManager Task GetManagedCertificate(string id); Task> GetManagedCertificates(ManagedCertificateFilter filter = null); + Task GetManagedCertificateResults(ManagedCertificateFilter filter = null); + Task GetManagedCertificateSummary(ManagedCertificateFilter filter = null); Task UpdateManagedCertificate(ManagedCertificate site); @@ -59,8 +62,6 @@ public interface ICertifyManager Task RemoveCertificateAuthority(string id); Task> GetPrimaryWebSites(StandardServerTypes serverType, bool ignoreStoppedSites, string itemId = null); - void BeginTrackingProgress(RequestProgressState state); - Task> RedeployManagedCertificates(ManagedCertificateFilter filter, IProgress progress = null, bool isPreviewOnly = false, bool includeDeploymentTasks = false); Task DeployCertificate(ManagedCertificate managedCertificate, IProgress progress = null, bool isPreviewOnly = false, bool includeDeploymentTasks = false); @@ -71,8 +72,6 @@ public interface ICertifyManager Task> PerformRenewAll(RenewalSettings settings, ConcurrentDictionary> progressTrackers = null); - RequestProgressState GetRequestProgressState(string managedItemId); - Task PerformRenewalTasks(); Task PerformDailyMaintenanceTasks(); @@ -93,7 +92,7 @@ public interface ICertifyManager Task GetDeploymentProviderDefinition(string id, DeploymentTaskConfig config); - Task GetItemLog(string id, int limit = 1000); + Task GetItemLog(string id, int limit = 1000); Task GetServiceLog(string logType, int limit = 10000); @@ -110,5 +109,6 @@ public interface ICertifyManager Task> RemoveDataStoreConnection(string dataStoreId); Task> TestDataStoreConnection(DataStoreConnection connection); Task TestCredentials(string storageKey); + Task GetCurrentAccessControl(); } } diff --git a/src/Certify.Core/Management/Challenges/DNS/DnsChallengeHelper.cs b/src/Certify.Core/Management/Challenges/DNS/DnsChallengeHelper.cs index a04d9c3ea..dca4b4be0 100644 --- a/src/Certify.Core/Management/Challenges/DNS/DnsChallengeHelper.cs +++ b/src/Certify.Core/Management/Challenges/DNS/DnsChallengeHelper.cs @@ -6,6 +6,7 @@ using Certify.Models; using Certify.Models.Config; using Certify.Models.Providers; +using Newtonsoft.Json; namespace Certify.Core.Management.Challenges { @@ -94,6 +95,46 @@ public async Task GetDnsProvider(string providerTypeId }; } + private Dictionary _dnsProviderCache = new Dictionary(); + private bool _useDnsProviderCaching = false; + + /// + /// Gets optionally cached DNS provider instance, caching may be based credentials/parameters to allow for zone query caching. TODO: log context will be first caller instead of current + /// + /// + /// + /// + /// + /// + private async Task GetDnsProvider(ILog log, string challengeProvider, Dictionary credentials, Dictionary parameters) + { + + IDnsProvider dnsAPIProvider = null; + + if (_useDnsProviderCaching) + { + // construct basic cache key for dns provider and credentials combo + var providerCacheKey = challengeProvider + (challengeProvider + JsonConvert.SerializeObject(credentials ?? new Dictionary()) + JsonConvert.SerializeObject(parameters ?? new Dictionary())).GetHashCode().ToString(); + if (_dnsProviderCache.ContainsKey(providerCacheKey)) + { + log.Warning("Developer Note: DNS provider log context will be first caller instead of current"); + + dnsAPIProvider = _dnsProviderCache[providerCacheKey]; + } + else + { + dnsAPIProvider = await ChallengeProviders.GetDnsProvider(challengeProvider, credentials, parameters, log); + _dnsProviderCache.Add(providerCacheKey, dnsAPIProvider); + } + } + else + { + dnsAPIProvider = await ChallengeProviders.GetDnsProvider(challengeProvider, credentials, parameters, log); + } + + return dnsAPIProvider; + } + public async Task CompleteDNSChallenge(ILog log, ManagedCertificate managedcertificate, CertIdentifierItem domain, string txtRecordName, string txtRecordValue, bool isTestMode) { // for a given managed site configuration, attempt to complete the required challenge by @@ -129,7 +170,7 @@ public async Task CompleteDNSChallenge(ILog log, Manag try { - dnsAPIProvider = await ChallengeProviders.GetDnsProvider(challengeConfig.ChallengeProvider, credentials, parameters, log); + dnsAPIProvider = await GetDnsProvider(log, challengeConfig.ChallengeProvider, credentials, parameters); } catch (ChallengeProviders.CredentialsRequiredException) { @@ -167,54 +208,52 @@ public async Task CompleteDNSChallenge(ILog log, Manag #pragma warning restore CS0618 // Type or member is obsolete } - if (dnsAPIProvider != null) - { - //most DNS providers require domains to by ASCII - txtRecordName = _idnMapping.GetAscii(txtRecordName).ToLower().Trim(); + //most DNS providers require domains to by ASCII + txtRecordName = _idnMapping.GetAscii(txtRecordName).ToLower().Trim(); - if (!string.IsNullOrEmpty(challengeConfig.ChallengeDelegationRule)) - { - var delegatedTXTRecordName = ApplyChallengeDelegationRule(domain.Value, txtRecordName, challengeConfig.ChallengeDelegationRule); - log.Information($"DNS: Challenge Delegation Domain enabled, using {delegatedTXTRecordName} in place of {txtRecordName}."); + if (!string.IsNullOrEmpty(challengeConfig.ChallengeDelegationRule)) + { + var delegatedTxtRecordName = ApplyChallengeDelegationRule(domain.Value, txtRecordName, challengeConfig.ChallengeDelegationRule); + log.Information($"DNS: Challenge Delegation Domain enabled, using {delegatedTxtRecordName} in place of {txtRecordName}."); - txtRecordName = delegatedTXTRecordName; - } + txtRecordName = delegatedTxtRecordName; + } - log.Information($"DNS: Creating TXT Record '{txtRecordName}' with value '{txtRecordValue}', [{domain.Value}] {(zoneId != null ? $"in ZoneId '{zoneId}'" : "")} using API provider '{dnsAPIProvider.ProviderTitle}'"); - try + log.Information($"DNS: Creating TXT Record '{txtRecordName}' with value '{txtRecordValue}', [{domain.Value}] {(zoneId != null ? $"in ZoneId '{zoneId}'" : "")} using API provider '{dnsAPIProvider.ProviderTitle}'"); + try + { + var result = await dnsAPIProvider.CreateRecord(new DnsRecord { - var result = await dnsAPIProvider.CreateRecord(new DnsRecord - { - RecordType = "TXT", - TargetDomainName = domain.Value.Trim(), - RecordName = txtRecordName, - RecordValue = txtRecordValue, - ZoneId = zoneId - }); + RecordType = "TXT", + TargetDomainName = domain.Value.Trim(), + RecordName = txtRecordName, + RecordValue = txtRecordValue, + ZoneId = zoneId + }); - result.Message = $"{dnsAPIProvider.ProviderTitle} :: {result.Message}"; + result.Message = $"{dnsAPIProvider.ProviderTitle} :: {result.Message}"; - var isAwaitingUser = false; + var isAwaitingUser = false; - if (challengeConfig.ChallengeProvider.Contains(".Manual") || result.Message.Contains("[Action Required]")) - { - isAwaitingUser = true; - } - - return new DnsChallengeHelperResult - { - Result = result, - PropagationSeconds = dnsAPIProvider.PropagationDelaySeconds, - IsAwaitingUser = isAwaitingUser - }; - } - catch (Exception exp) + if (challengeConfig.ChallengeProvider.Contains(".Manual") || result.Message.Contains("[Action Required]")) { - return new DnsChallengeHelperResult(failureMsg: $"Failed [{dnsAPIProvider.ProviderTitle}]: {exp}"); + isAwaitingUser = true; } - //TODO: DNS query to check for new record - /* + return new DnsChallengeHelperResult + { + Result = result, + PropagationSeconds = dnsAPIProvider.PropagationDelaySeconds, + IsAwaitingUser = isAwaitingUser + }; + } + catch (Exception exp) + { + return new DnsChallengeHelperResult(failureMsg: $"Failed [{dnsAPIProvider.ProviderTitle}]: {exp}"); + } + + //TODO: DNS query to check for new record + /* if (result.IsSuccess) { // do our own txt record query before proceeding with challenge completion @@ -245,11 +284,6 @@ public async Task CompleteDNSChallenge(ILog log, Manag return result; } */ - } - else - { - return new DnsChallengeHelperResult(failureMsg: "Error: Could not determine DNS API Provider."); - } } /// @@ -365,15 +399,15 @@ public async Task DeleteDNSChallenge(ILog log, Managed try { - dnsAPIProvider = await ChallengeProviders.GetDnsProvider(challengeConfig.ChallengeProvider, credentials, parameters); + dnsAPIProvider = await GetDnsProvider(log, challengeConfig.ChallengeProvider, credentials, parameters); } catch (ChallengeProviders.CredentialsRequiredException) { - return new DnsChallengeHelperResult(failureMsg: "This DNS Challenge API requires one or more credentials to be specified."); + return new DnsChallengeHelperResult("This DNS Challenge API requires one or more credentials to be specified."); } catch (Exception exp) { - return new DnsChallengeHelperResult(failureMsg: $"DNS Challenge API Provider could not be created. Check all required credentials are set. {exp.ToString()}"); + return new DnsChallengeHelperResult($"DNS Challenge API Provider could not be created. Check all required credentials are set. {exp.ToString()}"); } if (dnsAPIProvider == null) diff --git a/src/Certify.Core/Management/DeploymentTasks/DeploymentTask.cs b/src/Certify.Core/Management/DeploymentTasks/DeploymentTask.cs index 474dc6d6f..fad888bf3 100644 --- a/src/Certify.Core/Management/DeploymentTasks/DeploymentTask.cs +++ b/src/Certify.Core/Management/DeploymentTasks/DeploymentTask.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Certify.Config; @@ -30,23 +31,31 @@ public async Task> Execute( ILog log, ICredentialsManager credentialsManager, object subject, - CancellationToken cancellationToken, DeploymentContext deploymentContext, - bool isPreviewOnly = true + bool isPreviewOnly, + CancellationToken cancellationToken ) { if (TaskProvider != null && TaskConfig != null) { try { - var execParams = new DeploymentTaskExecutionParams(log, credentialsManager, subject, TaskConfig, _credentials, isPreviewOnly, null, cancellationToken, deploymentContext); + var execParams = new DeploymentTaskExecutionParams(log, credentialsManager, subject, TaskConfig, _credentials, isPreviewOnly, null, deploymentContext, cancellationToken); if (!isPreviewOnly) { return await TaskProvider.Execute(execParams); } else { - return new List { new ActionResult { IsSuccess = true, Message = "Task is review mode only. Not action performed." } }; + var validation = await TaskProvider.Validate(execParams); + if (validation == null || !validation.Any(r => r.IsSuccess == false)) + { + return new List { new ActionResult { IsSuccess = true, Message = "Task is valid and ready to execute." } }; + } + else + { + return validation; + } } } catch (Exception exp) diff --git a/src/Certify.Core/Management/MigrationManager.cs b/src/Certify.Core/Management/MigrationManager.cs index 215d31160..ed2aad3fb 100644 --- a/src/Certify.Core/Management/MigrationManager.cs +++ b/src/Certify.Core/Management/MigrationManager.cs @@ -8,10 +8,10 @@ using System.Text; using System.Threading.Tasks; using Certify.Config; -using Certify.Config.Migration; using Certify.Management; using Certify.Models; using Certify.Models.Config; +using Certify.Models.Config.Migration; using Certify.Models.Providers; using Certify.Providers; diff --git a/src/Certify.Core/Management/RenewalManager.cs b/src/Certify.Core/Management/RenewalManager.cs index 30f7a75c4..1da39dcc0 100644 --- a/src/Certify.Core/Management/RenewalManager.cs +++ b/src/Certify.Core/Management/RenewalManager.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Certify.Locales; using Certify.Models; using Certify.Models.Providers; using Certify.Providers; @@ -19,75 +18,42 @@ public static class RenewalManager private const int DEFAULT_CERTIFICATE_REQUEST_TASKS = 50; - public static async Task> PerformRenewAll( - ILog _serviceLog, - IManagedItemStore _itemManager, - RenewalSettings settings, - RenewalPrefs prefs, - Action BeginTrackingProgress, - Action, RequestProgressState, bool> ReportProgress, - Func> IsManagedCertificateRunning, - Func, bool, string, Task> PerformCertificateRequest, - ConcurrentDictionary> progressTrackers = null - ) + private static Progress SetupProgressTracker( + ManagedCertificate item, string renewalReason, + ConcurrentDictionary> progressTrackers, + Action, RequestProgressState, bool> reportProgress + ) { - // we can perform request in parallel but if processing many requests this can cause issues committing IIS bindings etc - var testModeOnly = false; + // track progress + var progressState = new RequestProgressState(RequestState.Running, "Starting..", item); + var progressTracker = new Progress(progressState.ProgressReport); - IEnumerable managedCertificates; + progressTrackers.TryAdd(item.Id, progressTracker); - if (settings.TargetManagedCertificates?.Any() == true) - { - var targetCerts = new List(); - foreach (var id in settings.TargetManagedCertificates) - { - targetCerts.Add(await _itemManager.GetById(id)); - } + reportProgress(progressTracker, new RequestProgressState(RequestState.Queued, $"Queued for renewal: {renewalReason}", item), false); - managedCertificates = targetCerts; - } - else - { - managedCertificates = await _itemManager.Find( - new ManagedCertificateFilter - { - IncludeOnlyNextAutoRenew = (settings.Mode == RenewalMode.Auto) - } - ); - } + return progressTracker; + } - if (settings.Mode == RenewalMode.Auto || settings.Mode == RenewalMode.RenewalsDue) - { - // auto renew enabled sites in order of oldest date renewed (or earliest attempted), items not yet attempted are first. - // if mode is just RenewalDue then we also include items that are not marked auto renew (the user may be controlling when to perform renewal). + public static async Task> PerformRenewAll( + ILog serviceLog, + IManagedItemStore itemManager, + RenewalSettings settings, + RenewalPrefs prefs, + Action, RequestProgressState, bool> reportProgress, + Func> isManagedCertificateRunning, + Func, bool, string, Task> performCertificateRequest, + ConcurrentDictionary> progressTrackers = null + ) + { - managedCertificates = managedCertificates.Where(s => s.IncludeInAutoRenew == true || settings.Mode == RenewalMode.RenewalsDue) - .OrderBy(s => s.DateRenewed ?? s.DateLastRenewalAttempt ?? DateTimeOffset.MinValue); - } - else if (settings.Mode == RenewalMode.NewItems) - { - // new items not yet completed in order of oldest renewal attempt first - managedCertificates = managedCertificates.Where(s => s.DateRenewed == null) - .OrderBy(s => s.DateLastRenewalAttempt ?? DateTimeOffset.UtcNow.AddHours(-48)); - } - else if (settings.Mode == RenewalMode.RenewalsWithErrors) + var maxRenewalTasks = prefs.MaxRenewalRequests; + if (maxRenewalTasks <= 0) { - // items with current errors in order of oldest renewal attempt first - managedCertificates = managedCertificates.Where(s => s.LastRenewalStatus == RequestState.Error) - .OrderBy(s => s.DateLastRenewalAttempt ?? DateTimeOffset.UtcNow.AddHours(-1)); + maxRenewalTasks = DEFAULT_CERTIFICATE_REQUEST_TASKS; } - // check site list and examine current certificates. If certificate is less than n days - // old, don't attempt to renew it - var sitesToRenew = new List(); - var renewalIntervalDays = prefs.RenewalIntervalDays; - var renewalIntervalMode = prefs.RenewalIntervalMode ?? RenewalIntervalModes.DaysAfterLastRenewal; - - var numRenewalTasks = 0; - - var maxRenewalTasks = prefs.MaxRenewalRequests; - var renewalTasks = new List>(); if (progressTrackers == null) @@ -95,129 +61,151 @@ public static async Task> PerformRenewAll( progressTrackers = new ConcurrentDictionary>(); } - if (managedCertificates.Count(c => c.LastRenewalStatus == RequestState.Error) > MAX_CERTIFICATE_REQUEST_TASKS) - { - _serviceLog?.Warning("Too many failed certificates outstanding. Fix failures or delete. Failures: " + managedCertificates.Count(c => c.LastRenewalStatus == RequestState.Error)); - } + List managedCertificateBatch; - foreach (var managedCertificate in managedCertificates) + if (settings.TargetManagedCertificates?.Any() == true) { - // if cert is not awaiting manual user input (manual DNS etc), proceed with renewal checks - if (managedCertificate.LastRenewalStatus != RequestState.Paused) - { - var progressState = new RequestProgressState(RequestState.Running, "Starting..", managedCertificate); - var progressIndicator = new Progress(progressState.ProgressReport); + // prepare renewal batch using just the selected set of target items + var targetCerts = new List(); - try - { - progressTrackers.TryAdd(managedCertificate.Id, progressIndicator); - } - catch + foreach (var id in settings.TargetManagedCertificates) + { + var item = await itemManager.GetById(id); + if (item != null) { - _serviceLog?.Error($"Failed to add progress tracker for {managedCertificate.Id}. Likely concurrency issue, skipping this managed cert during this run."); - continue; + targetCerts.Add(item); } + } - BeginTrackingProgress(progressState); + if (!targetCerts.Any()) + { + serviceLog?.Error("No matching target managed certificates found for renewal."); + return new List(); + } + + managedCertificateBatch = targetCerts; + + foreach (var item in managedCertificateBatch) + { + var progressTracker = SetupProgressTracker(item, "", progressTrackers, reportProgress); - // determine if this site currently requires renewal for auto mode (or renewals due mode) - // In auto mode we skip if recent failures, in Renewals Due mode we ignore recent failures + renewalTasks.Add( + new Task( + () => performCertificateRequest(item, progressTracker, settings.IsPreviewMode, "Renewal requested").Result, + TaskCreationOptions.LongRunning + ) + ); + } + } + else + { - var renewalDueCheck = ManagedCertificate.CalculateNextRenewalAttempt(managedCertificate, renewalIntervalDays, renewalIntervalMode, checkFailureStatus: false); - var isRenewalRequired = (settings.Mode != RenewalMode.Auto && settings.Mode != RenewalMode.RenewalsDue) || renewalDueCheck.IsRenewalDue; + // prepare batch of renewals until we have reached the limit of tasks we will perform in one pass, or run out of items to attempt - var renewalReason = renewalDueCheck.Reason; + // auto renew enabled sites in order of oldest date renewed (or earliest attempted), items not yet attempted are first. + var filter = new ManagedCertificateFilter + { + IncludeOnlyNextAutoRenew = (settings.Mode == RenewalMode.Auto), + OrderBy = ManagedCertificateFilter.SortMode.RENEWAL_ASC + }; - if (settings.Mode == RenewalMode.All) + /* if (settings.Mode == RenewalMode.Auto || settings.Mode == RenewalMode.RenewalsDue) { - // on all mode, everything gets an attempted renewal - isRenewalRequired = true; - renewalReason = "Renewal Mode is set to All"; - } - //if we care about stopped sites being stopped, check for that if a specific site is selected - var isSiteRunning = true; - if (prefs.IncludeStoppedSites && !string.IsNullOrEmpty(managedCertificate.ServerSiteId)) + // if mode is just RenewalDue then we also include items that are not marked auto renew (the user may be controlling when to perform renewal). + + managedCertificateBatch = managedCertificateBatch.Where(s => s.IncludeInAutoRenew == true || settings.Mode == RenewalMode.RenewalsDue) + .OrderBy(s => s.DateRenewed ?? s.DateLastRenewalAttempt ?? DateTimeOffset.MinValue); + } + else if (settings.Mode == RenewalMode.NewItems) { - isSiteRunning = await IsManagedCertificateRunning(managedCertificate.Id); + // new items not yet completed in order of oldest renewal attempt first + managedCertificateBatch = managedCertificateBatch.Where(s => s.DateRenewed == null) + .OrderBy(s => s.DateLastRenewalAttempt ?? DateTimeOffset.UtcNow.AddHours(-48)); } - - if (!renewalDueCheck.IsRenewalOnHold && isRenewalRequired && isSiteRunning && !testModeOnly) + else if (settings.Mode == RenewalMode.RenewalsWithErrors) { - //get matching progress tracker for this site - IProgress tracker = null; - if (progressTrackers != null) - { - tracker = progressTrackers[managedCertificate.Id]; - } + // items with current errors in order of oldest renewal attempt first + managedCertificateBatch = managedCertificateBatch.Where(s => s.LastRenewalStatus == RequestState.Error) + .OrderBy(s => s.DateLastRenewalAttempt ?? DateTimeOffset.UtcNow.AddHours(-1)); + }*/ - // limit the number of renewal tasks to attempt in this pass either to custom setting or max allowed - if ( - (maxRenewalTasks == 0 && numRenewalTasks < DEFAULT_CERTIFICATE_REQUEST_TASKS) - || - (maxRenewalTasks > 0 && numRenewalTasks < maxRenewalTasks && numRenewalTasks < MAX_CERTIFICATE_REQUEST_TASKS) - ) - { + var totalRenewalCandidates = await itemManager.CountAll(filter); - renewalTasks.Add( - new Task( - () => PerformCertificateRequest(managedCertificate, tracker, settings.IsPreviewMode, renewalReason).Result, - TaskCreationOptions.LongRunning - )); + var renewalIntervalDays = prefs.RenewalIntervalDays; + var renewalIntervalMode = prefs.RenewalIntervalMode ?? RenewalIntervalModes.DaysAfterLastRenewal; - ReportProgress((IProgress)progressTrackers[managedCertificate.Id], new RequestProgressState(RequestState.Queued, $"Queued for renewal: {renewalDueCheck.Reason}", managedCertificate), false); + filter.PageSize = MAX_CERTIFICATE_REQUEST_TASKS; + filter.PageIndex = 0; - } - else - { - if (!prefs.SuppressSkippedItems) - { - //send progress back to report skip - var progress = (IProgress)progressTrackers[managedCertificate.Id]; - ReportProgress(progress, new RequestProgressState(RequestState.NotRunning, "Skipped renewal because the max requests per batch has been reached. This request will be attempted again later.", managedCertificate, isSkipped: true), true); - } - else - { - _serviceLog.Debug($"Skipping item {managedCertificate.Id}:{managedCertificate.Name}, max batch size exceeded."); - } - } + var batch = new List(); + var resultsRemaining = totalRenewalCandidates; - // track number of tasks being attempted - numRenewalTasks++; + // identify items we will attempt and begin tracking progress + while (batch.Count < maxRenewalTasks && resultsRemaining > 0) + { + var results = await itemManager.Find(filter); + resultsRemaining = results.Count; - } - else + foreach (var item in results) { - var msg = renewalDueCheck.Reason; - var requestState = RequestState.Success; - - var logThisEvent = false; - - if (isRenewalRequired && !isSiteRunning) - { - msg = CoreSR.CertifyManager_SiteStopped; - } - - if (renewalDueCheck.IsRenewalOnHold) - { - logThisEvent = true; - } - - if (progressTrackers != null) + if (batch.Count < maxRenewalTasks) { - if (!renewalDueCheck.IsRenewalDue || renewalDueCheck.IsRenewalOnHold && prefs.SuppressSkippedItems) - { - _serviceLog.Debug($"Skipping item {managedCertificate.Id}:{managedCertificate.Name}, UI reporting suppressed: {msg}"); - } - else + // if cert is not awaiting manual user input (manual DNS etc), proceed with renewal checks + if (item.LastRenewalStatus != RequestState.Paused) { - //send progress back to report skip - /* var progress = (IProgress)progressTrackers[managedCertificate.Id]; - ReportProgress(progress, new RequestProgressState(requestState, msg, managedCertificate, isSkipped: true), logThisEvent);*/ + // check if item is due for renewal based on current settings + + var renewalDueCheck = ManagedCertificate.CalculateNextRenewalAttempt(item, renewalIntervalDays, renewalIntervalMode, checkFailureStatus: false); + var isRenewalRequired = (settings.Mode != RenewalMode.Auto && settings.Mode != RenewalMode.RenewalsDue) || renewalDueCheck.IsRenewalDue; + + var renewalReason = renewalDueCheck.Reason; + + if (settings.Mode == RenewalMode.All) + { + // on all mode, everything gets an attempted renewal + isRenewalRequired = true; + renewalReason = "Renewal Mode is set to All"; + } + + // if we care about stopped sites being stopped, check if a specific site is selected and if it's running + if (!prefs.IncludeStoppedSites && !string.IsNullOrEmpty(item.ServerSiteId) && item.RequestConfig.DeploymentSiteOption == DeploymentOption.SingleSite) + { + var isSiteRunning = await isManagedCertificateRunning(item.Id); + + if (!isSiteRunning) + { + isRenewalRequired = false; + renewalReason = "Target site is not running and 'Include Stopped Sites' preference is False. Renewal will not be attempted."; + } + } + + if (isRenewalRequired && !renewalDueCheck.IsRenewalOnHold) + { + batch.Add(item); + + var progressTracker = SetupProgressTracker(item, "", progressTrackers, reportProgress); + + renewalTasks.Add( + new Task( + () => performCertificateRequest(item, progressTracker, settings.IsPreviewMode, renewalReason).Result, + TaskCreationOptions.LongRunning + ) + ); + } } } } + + filter.PageIndex++; } + + managedCertificateBatch = batch; + } + + if (managedCertificateBatch.Count(c => c.LastRenewalStatus == RequestState.Error) > MAX_CERTIFICATE_REQUEST_TASKS) + { + serviceLog?.Warning("Too many failed certificates outstanding. Fix failures or delete. Failures: " + managedCertificateBatch.Count(c => c.LastRenewalStatus == RequestState.Error)); } if (!renewalTasks.Any()) @@ -228,16 +216,13 @@ public static async Task> PerformRenewAll( } else { - _serviceLog.Information($"Attempting {renewalTasks.Count} renewal tasks. Max renewal tasks is set to {maxRenewalTasks}, max supported tasks is {MAX_CERTIFICATE_REQUEST_TASKS}"); + serviceLog?.Information($"Attempting {renewalTasks.Count} renewal tasks. Max renewal tasks is set to {maxRenewalTasks}, max supported tasks is {MAX_CERTIFICATE_REQUEST_TASKS}"); } if (prefs.PerformParallelRenewals) { renewalTasks.ForEach(t => t.Start()); - - var allTaskResults = await Task.WhenAll(renewalTasks); - - return allTaskResults.ToList(); + return (await Task.WhenAll(renewalTasks)).ToList(); } else { @@ -264,42 +249,42 @@ public static async Task> PerformRenewAll( /// private static List GetAccountsWithRequiredCAFeatures(ManagedCertificate item, string defaultCA, ICollection certificateAuthorities, List accounts) { - var requiredCAFeatures = new List(); + var requiredCaFeatures = new List(); var identifiers = item.GetCertificateIdentifiers(); if (identifiers.Any(i => i.IdentifierType == CertIdentifierType.Dns && i.Value.StartsWith("*"))) { - requiredCAFeatures.Add(CertAuthoritySupportedRequests.DOMAIN_WILDCARD); + requiredCaFeatures.Add(CertAuthoritySupportedRequests.DOMAIN_WILDCARD); } if (identifiers.Count(i => i.IdentifierType == CertIdentifierType.Dns) == 1) { - requiredCAFeatures.Add(CertAuthoritySupportedRequests.DOMAIN_SINGLE); + requiredCaFeatures.Add(CertAuthoritySupportedRequests.DOMAIN_SINGLE); } if (identifiers.Count(i => i.IdentifierType == CertIdentifierType.Dns) > 2) { - requiredCAFeatures.Add(CertAuthoritySupportedRequests.DOMAIN_MULTIPLE_SAN); + requiredCaFeatures.Add(CertAuthoritySupportedRequests.DOMAIN_MULTIPLE_SAN); } - if (identifiers.Any(i => i.IdentifierType == CertIdentifierType.Ip)) + if (identifiers.Count(i => i.IdentifierType == CertIdentifierType.Ip) == 1) { - requiredCAFeatures.Add(CertAuthoritySupportedRequests.IP_SINGLE); + requiredCaFeatures.Add(CertAuthoritySupportedRequests.IP_SINGLE); } if (identifiers.Count(i => i.IdentifierType == CertIdentifierType.Ip) > 1) { - requiredCAFeatures.Add(CertAuthoritySupportedRequests.IP_MULTIPLE); + requiredCaFeatures.Add(CertAuthoritySupportedRequests.IP_MULTIPLE); } if (identifiers.Any(i => i.IdentifierType == CertIdentifierType.TnAuthList)) { - requiredCAFeatures.Add(CertAuthoritySupportedRequests.TNAUTHLIST); + requiredCaFeatures.Add(CertAuthoritySupportedRequests.TNAUTHLIST); } if (item.RequestConfig.PreferredExpiryDays > 0) { - requiredCAFeatures.Add(CertAuthoritySupportedRequests.OPTIONAL_LIFETIME_DAYS); + requiredCaFeatures.Add(CertAuthoritySupportedRequests.OPTIONAL_LIFETIME_DAYS); } var fallbackCandidateAccounts = accounts.Where(a => a.CertificateAuthorityId != defaultCA && a.IsStagingAccount == item.UseStagingMode); @@ -310,7 +295,7 @@ private static List GetAccountsWithRequiredCAFeatures(ManagedCer // select a candidate based on features required by the certificate. If a CA has no known features we assume it supports all the ones we might be interested in foreach (var ca in certificateAuthorities) { - if (!ca.SupportedFeatures.Any() || requiredCAFeatures.All(r => ca.SupportedFeatures.Contains(r.ToString()))) + if (!ca.SupportedFeatures.Any() || requiredCaFeatures.All(r => ca.SupportedFeatures.Contains(r.ToString()))) { fallbackAccounts.AddRange(fallbackCandidateAccounts.Where(f => f.CertificateAuthorityId == ca.Id)); } @@ -345,7 +330,7 @@ public static AccountDetails SelectCAWithFailover(ICollection f.CertificateAuthorityId != item.LastAttemptedCA && f.CertificateAuthorityId != defaultMatchingAccount?.CertificateAuthorityId); + var nextFallback = fallbackAccounts.FirstOrDefault(f => f.CertificateAuthorityId != item.LastAttemptedCA); if (nextFallback != null) { diff --git a/src/Certify.Core/Management/Servers/ServerProviderIIS.cs b/src/Certify.Core/Management/Servers/ServerProviderIIS.cs index b7c45a993..204bbadef 100644 --- a/src/Certify.Core/Management/Servers/ServerProviderIIS.cs +++ b/src/Certify.Core/Management/Servers/ServerProviderIIS.cs @@ -23,7 +23,7 @@ public class ServerProviderIIS : ITargetWebServer /// /// We use a lock on any method that uses CommitChanges, to avoid writing changes at the same time /// - private static readonly object _iisAPILock = new object(); + private static readonly Lock _iisAPILock = LockFactory.Create(); private ILog _log; diff --git a/src/Certify.Core/Management/SettingsManager.cs b/src/Certify.Core/Management/SettingsManager.cs index 3425fadb2..7f3dfab99 100644 --- a/src/Certify.Core/Management/SettingsManager.cs +++ b/src/Certify.Core/Management/SettingsManager.cs @@ -8,7 +8,7 @@ namespace Certify.Management public sealed class CoreAppSettings { private static volatile CoreAppSettings instance; - private static object syncRoot = new object(); + private static readonly Lock syncRoot = LockFactory.Create(); private CoreAppSettings() { @@ -188,7 +188,7 @@ public static CoreAppSettings Current public class SettingsManager { private const string COREAPPSETTINGSFILE = "appsettings.json"; - private static Object settingsLocker = new Object(); + private static readonly Lock settingsLocker = LockFactory.Create(); public static bool FromPreferences(Models.Preferences prefs) { diff --git a/src/Certify.Locales/Certify.Locales.csproj b/src/Certify.Locales/Certify.Locales.csproj index c0c1ff290..23d2cac9b 100644 --- a/src/Certify.Locales/Certify.Locales.csproj +++ b/src/Certify.Locales/Certify.Locales.csproj @@ -1,8 +1,8 @@  - netstandard2.0 + netstandard2.0;net9.0 AnyCPU - true + False Certify Certificate Manager UI Resources @@ -11,6 +11,9 @@ AnyCPU + + + True @@ -78,4 +81,4 @@ SR.resx - \ No newline at end of file + diff --git a/src/Certify.Locales/SR.Designer.cs b/src/Certify.Locales/SR.Designer.cs index 47f9f432f..299568919 100644 --- a/src/Certify.Locales/SR.Designer.cs +++ b/src/Certify.Locales/SR.Designer.cs @@ -141,6 +141,51 @@ public static string AboutControl_TrialDetailLabel { } } + /// + /// Looks up a localized string similar to To proceed, confirm that you agree to the current terms and conditions for this Certificate Authority.. + /// + public static string Account_Edit_AgreeConditions { + get { + return ResourceManager.GetString("Account_Edit_AgreeConditions", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Yes, I Agree. + /// + public static string Account_Edit_AgreeConfirm { + get { + return ResourceManager.GetString("Account_Edit_AgreeConfirm", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to To request certificates you need to register with each of the Certificate Authorities that you want to use.. + /// + public static string Account_Edit_Intro { + get { + return ResourceManager.GetString("Account_Edit_Intro", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The email address provided may be used to notify you of upcoming certificate renewals if required. Invalid email addresses will be rejected by the Certificate Authority.. + /// + public static string Account_Edit_Intro2 { + get { + return ResourceManager.GetString("Account_Edit_Intro2", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Edit ACME Account. + /// + public static string Account_Edit_SectionTitle { + get { + return ResourceManager.GetString("Account_Edit_SectionTitle", resourceCulture); + } + } + /// /// Looks up a localized string similar to Advanced. /// @@ -294,6 +339,24 @@ public static string DiscardChanges { } } + /// + /// Looks up a localized string similar to (Url for the production directory endpoint). + /// + public static string EditCertificateAuthority_ProductionDirectoryHelp { + get { + return ResourceManager.GetString("EditCertificateAuthority_ProductionDirectoryHelp", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to (Display Name for the Certificate Authority). + /// + public static string EditCertificateAuthority_TitleHelp { + get { + return ResourceManager.GetString("EditCertificateAuthority_TitleHelp", resourceCulture); + } + } + /// /// Looks up a localized string similar to Email Address. /// @@ -529,7 +592,7 @@ public static string MainWindow_KeyExpired { } /// - /// Looks up a localized string similar to This will renew certificates for all auto-renewed items. Proceed?. + /// Looks up a localized string similar to This will renew certificates for all auto-renewed items, if applicable. Proceed?. /// public static string MainWindow_RenewAllConfirm { get { @@ -582,6 +645,15 @@ public static string Managed_Sites { } } + /// + /// Looks up a localized string similar to Use Staging Mode (Test Certificates). + /// + public static string ManagedCertificate_CertificateAuthority_UseTestCertificates { + get { + return ResourceManager.GetString("ManagedCertificate_CertificateAuthority_UseTestCertificates", resourceCulture); + } + } + /// /// Looks up a localized string similar to Deployment of your certificate can be automatic or you can perform your own deployment tasks (see the Tasks tab).. /// @@ -998,7 +1070,7 @@ public static string ManagedCertificateSettings_NameRequired { } /// - /// Looks up a localized string similar to A Primary Domain must be included. + /// Looks up a localized string similar to A Primary Domain/identifier must be included. /// public static string ManagedCertificateSettings_NeedPrimaryDomain { get { @@ -1268,33 +1340,6 @@ public static string New_Contact_EmailError { } } - /// - /// Looks up a localized string similar to To proceed, confirm that you agree to the current terms and conditions for this Certificate Authority.. - /// - public static string New_Contact_NeedAgree { - get { - return ResourceManager.GetString("New_Contact_NeedAgree", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to To request certificates you need to register with each of the Certificate Authorities that you want to use.. - /// - public static string New_Contact_Tip1 { - get { - return ResourceManager.GetString("New_Contact_Tip1", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The email address provided may be used to notify you of upcoming certificate renewals if required. Invalid email addresses will be rejected by the Certificate Authority.. - /// - public static string New_Contact_Tip2 { - get { - return ResourceManager.GetString("New_Contact_Tip2", resourceCulture); - } - } - /// /// Looks up a localized string similar to OK. /// @@ -1619,6 +1664,24 @@ public static string Settings_AutoRenewalRequestLimit { } } + /// + /// Looks up a localized string similar to If you register with multiple authorities this may enable you to use automatic Certificate Authority Failover, so if your preferred Certificate Authority can't issue a new certificate an alternative compatible provider can be used automatically.. + /// + public static string Settings_CA_Fallback { + get { + return ResourceManager.GetString("Settings_CA_Fallback", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Certificate Authorities are the organisations who can issue trusted certificates. You need to register an account for each (ACME) Certificate Authority you wish to use. Accounts can either be Production (live, trusted certificates) or Staging (test, non-trusted).. + /// + public static string Settings_CA_Intro { + get { + return ResourceManager.GetString("Settings_CA_Intro", resourceCulture); + } + } + /// /// Looks up a localized string similar to Check for updates automatically. /// @@ -1691,6 +1754,15 @@ public static string Settings_EnableTelemetry { } } + /// + /// Looks up a localized string similar to You can create an export file to bundle all of the related settings and file for this instance together. Note: sensitive content is encrypted but you should not share this file with untrusted sources or use unsecured storage.. + /// + public static string Settings_Export_Intro { + get { + return ResourceManager.GetString("Settings_Export_Intro", resourceCulture); + } + } + /// /// Looks up a localized string similar to Ignore stopped IIS sites for new certificates and renewals. /// @@ -1789,14 +1861,5 @@ public static string ValidateKey { return ResourceManager.GetString("ValidateKey", resourceCulture); } } - - /// - /// Looks up a localized string similar to Yes, I Agree. - /// - public static string Yes_I_Agree { - get { - return ResourceManager.GetString("Yes_I_Agree", resourceCulture); - } - } } } diff --git a/src/Certify.Locales/SR.es-ES.resx b/src/Certify.Locales/SR.es-ES.resx index 39fb39652..7594fa083 100644 --- a/src/Certify.Locales/SR.es-ES.resx +++ b/src/Certify.Locales/SR.es-ES.resx @@ -1,6 +1,6 @@  - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Registro - + Correo electrónico Registrado - + Clave de Licencia - + Cancelar - + Validar Clave - + Nuevo certificado - + Renovar todo - + Configurar renovación automática - + Actualización disponible - + Sitios - + En Progreso - + Configuración - + Acerca de - + Correo electrónico - + Sí, estoy de acuerdo - + Registrar Contacto - + La dirección de correo electrónico provista se puede ser utilizada para notificarle acerca de la próxima renovación de certificados si es necesario. Las direcciones de correo electrónico inválidas serán rechazadas por la Autoridad de Certificación. - + Enviar comentarios - + Tu opinión es importante para nosotros. Por favor, déjanos saber cómo podemos mejorar. - + ¿Tienes alguna sugerencia? - + Su correo electrónico (si desea una respuesta): - + Enviar - + Certify - Importar - + Importar Sitios Administrados - + Está actualizando desde una versión anterior de Certify. Esto creará nuevos Sitios Administrados que luego puede editar para ajustar la configuración de renovación, etc. - + Combinar múltiples dominios/certificados por sitio en un Sitio Administrado - + Importar - + Omitir Importar - + ¡Huy! Algo salió mal. Por favor cuéntanos más detalles al respecto. - + Información - + Comentarios: - + Url: - + Método: - + Cuerpo: - + ContentType: - + Revocar Certificado - + [Edición de la comunidad] - + Certificado Revocado. - + Alerta - + Error - + OK - + Contraseña - + Gracias, tus comentarios han sido enviados. - + Lo sentimos, hubo un problema al enviar sus comentarios. - + Ingrese el correo electrónico que utilizó al registrar su clave. - + Por favor, introduzca su clave de licencia. - + Hubo un problema al tratar de validar su clave de licencia. Por favor intente de nuevo o contáctenos. - + Hubo un problema al comenzar el proceso de validación de la licencia. - + La tarea de renovación automática ya está configurada. Si es necesario, puede cambiar la cuenta de usuario de administrador utilizada para ejecutar la tarea. - + Error al crear la tarea programada con las credenciales suministradas - + Tarea programada creada - + Proporcione el nombre de usuario y la contraseña para un usuario de nivel de administrador. - + Configurar la renovación automática - + Usuario (DOMINIO\Usuario) - + Para realizar la renovación automática de certificados, Certify creará una tarea en el Programador de Tareas de Windows. Esta tarea debe ejecutarse como un usuario de nivel de Administrador para realizar tareas de administración de certificados y administración de IIS. - + Esta versión es gratuita para evaluación y gestionará un número limitado de certificados. Para eliminar esta limitación, compre una clave de registro en https://certifytheweb.com/register. Los usuarios registrados pueden obtener asistencia enviando un correo electrónico a support@certifytheweb.com - + No Registrado - + Buscar actualizaciones... - + Registrar - + Introducir clave... - + Soporte - + Enviar Comentarios - + Estás usando la última versión. - + Filtrar... - + Seleccione un Sitio Administrado o seleccione Nuevo certificado para comenzar. - + Hay cambios no guardados en el sitio seleccionado. ¿Descartar los cambios? - + No hay solicitudes actualmente en curso. - + Contacto Principal: - + Intervalo de renovación automática (días) - + Caduca en {0} días - + Sin certificado actual. - + Ruta del certificado: [establecer] - + Trigger de Webhook: - + Usar enlaces de IP/Puerto específicos - + Creación/ ctualización automática de enlaces de IIS (utiliza SNI) - + Post-solicitud PS Script: - + Traductor de idiomas actual: - + Colaboradores de Certify Certificate Manager https://certifytheweb.com - + ¿Desea visitar la página de descarga ahora? - + Error al Revocar Certificado: {0} - + ¿Está seguro de que desea revocar este certificado? - + Estás usando la versión de prueba de esta aplicación. Por favor, compre una clave de registro para actualizar. Vea la opción Registrar en la pestaña Acerca de. - + Esto renovará los certificados para todos los elementos de auto-renovación. ¿Proceder? - + No se detectó IIS en este servidor, una serie de características importantes no estarán disponible. Si IIS está instalado y ejecutándose en este servidor, informe este error a support@certifytheweb.com proporcionando detalles de la versión del sistema operativo del servidor y las versiones de IIS. - + Comience registrando un nuevo contacto, luego puede comenzar a solicitar certificados. - + Número máximo de solicitudes de renovación automática por sesión (0 = Ilimitado) - + Buscar actualizaciones automáticamente - + ... - + Eliminar - + Guardar - + Guardar Cambios - + Habilitar la aplicación de telemetría (informes de uso de características) - + Habilitar proxy API para las comprobaciones de configuración de dominio - + exitoso - + fallido - + Prueba de Webhook - + Ver Certificado - + Error al Guardar - + Habilite el Sistema de Cifrados de Archivos (EFS) para archivos confidenciales. Esta opción no funciona en todas las versiones de Windows. - + Ignorar sitios IIS detenidos para nuevos certificados y renovaciones - + Habilitar comprobaciones de validación de DNS (Resolution, CAA, DNSSEC) - + Descartar Cambios - + Solicitar Certificado - + Habilitar renovación automática - + Notificar al contacto principal sobre la falla de renovación - + Opciones - + Seleccione el sitio IIS: - + Nombre para mostrar: - + Los siguientes dominios seleccionados se incluirán como una única solicitud de certificado. El servicio Let's Encrypt debe poder acceder a todos estos dominios a través del puerto 80 (para desafíos HTTP) o el puerto 443 (para desafíos TLS-SNI) para que el proceso de certificación funcione. - + Dominios y subdominios para incluir: - + Seleccionar todo - + Quitar Selección - + Primario - + Incluir - + Dominio - + Avanzado - + Tipo de desafío: - + Directorio raíz del sitio web - + ... - + Realizar comprobaciones de configuración de respuesta de desafío - + Realizar la configuración automática de la aplicación web - + Enlace a IP específico: - + Enlace al puerto específico: - + Usar SNI (IIS 8+): - + Pre-solicitud PS Script: - + Prueba - + Nota: Para exportar este certificado, use la opción Administrar Certificados en Windows. - + brir archivo de registro - + ADVERTENCIA: este certificado ha sido revocado. - + Seleccione el sitio web para crear un certificado - + Se requiere un nombre para este elemento. - + Se debe incluir un Dominio Principal - + El desafío {0} solo está disponible para las versiones de IIS 8+. - + La URL para el webhook debe establecerse en una URL válida. - + El Método para el webhook debe estar configurado. - + No se realizaron cambios, omitiendo guardar - + Confirmar eliminación - + ¿Seguro que quieres eliminar este elemento? Eliminalo no afectará la configuración de IIS, etc. - + El archivo de registro para este elemento no ha sido creado todavía. - + El archivo de certificado para este elemento no ha sido creado todavía. - + Error de Desafío - + No se pueden verificar los desafíos si el IIS no está disponible. - + Desafío - + Configuración comprobada OK - + Error de comprobación de configuración: {0} - + Prueba de Desafío Fallida - + Prueba de Webhook Fallida - + Solicitud de Webhook {0}: HTTP {1} - + Error de solicitud de Webhook: {0} - + Para solicitar certificados, debe registrarse en cada una de las Autoridades de certificación que desee utilizar. - + Hubo un problema al registrarse con la Autoridad de Certificación utilizando este correo electrónico. Verifique que el correo electrónico sea válido y que esta computadora tenga una conexión abierta a Internet (se requiere https saliente para las llamadas API). - + Para continuar, confirme que acepta los términos y condiciones actuales de esta Autoridad de Certificación. - + Se requiere al menos un nombre de host completo (por ejemplo, 'github.com') o comodín (por ejemplo, '*.github.com) para crear un certificado. - + Cargando... - + La versión actual de la aplicación ya no es compatible. Por favor actualize para continuar. - + Nota: esta configuración solo se aplica a los nuevos enlaces https, los enlaces existentes solo se actualizan con el nuevo certificado. El uso de una IP fija para varios certificados provocará un conflicto de vinculación en Windows, utilícelo con precaución. - + Vuelva a aplicar el certificado a los enlaces - + Agregar - + Contraseña - + Agregar al panel de informes - + Panel de informes - + Puede utilizar el panel de informes para ver fácilmente el estado de renovación del certificado en uno o más servidores. - + Ver panel de informes - + Crear una nueva cuenta - + Para agregar este servidor a su panel, proporcione sus https://certifytheweb.com/profile detalles de inicio de sesión o registre una nueva cuenta: - + Usar servicio en segundo plano (se ejecuta como sistema local) - + Usar una tarea programada (se ejecuta como usuario especificado) - + Actualizar - + ¿Le gustaría descargar automáticamente la actualización? Se le notificará cuando esté listo para aplicar. - + Una nueva actualización está lista para aplicarse. ¿Le gustaría instalarlo ahora? - + Está intentando crear un enlace SNI que también tiene una dirección IP enlazada específica. En su lugar, se recomienda que los enlaces SNI utilicen Todos sin asignar. ¿Desea continuar? - + Agregar / actualizar credencial almacenada - + Lo sentimos, no se pudo descargar la actualización. Por favor, inténtelo de nuevo más tarde. - + Administrar Certificados - + Nuevo certificado administrado - + Agregue dominios al certificado: - + p.ej. test.com, www.test.com o * .test.com - + Habilitar el servidor de desafío Http - + Habilitar la limpieza de certificados - + Habilitar informes de estado en el panel - + Autoridad de Certificación preferida - + Su clave de licencia ha caducado o ya no está activa. - + \ No newline at end of file diff --git a/src/Certify.Locales/SR.ja-JP.resx b/src/Certify.Locales/SR.ja-JP.resx index cc7d4edd3..af2c187c8 100644 --- a/src/Certify.Locales/SR.ja-JP.resx +++ b/src/Certify.Locales/SR.ja-JP.resx @@ -123,7 +123,7 @@ あなたのメール アドレス (回答が必要な場合): - + はい、同意します @@ -612,7 +612,7 @@ すべての自動更新アイテムの証明書が更新されます。 続行しますか? - + 提供された電子メールアドレスは、必要に応じて今後の証明書の更新を通知するために使用することができます。 無効な電子メールアドレスはLet's Encrypt によって拒否されます。 @@ -642,4 +642,4 @@ 選択したサイトに保存されていない変更があります。 変更を破棄しますか? - + \ No newline at end of file diff --git a/src/Certify.Locales/SR.nb-NO.resx b/src/Certify.Locales/SR.nb-NO.resx index e7bb08bcb..1229266cf 100644 --- a/src/Certify.Locales/SR.nb-NO.resx +++ b/src/Certify.Locales/SR.nb-NO.resx @@ -156,13 +156,13 @@ E-postadresse - + Ja, jeg aksepterer Registrer kontakt - + E-postadressen som er oppgitt, kan brukes til å varsle deg om kommende sertifikatfornyelser hvis nødvendig. Ugyldige e-postadresser blir avvist av Let's Encrypt. diff --git a/src/Certify.Locales/SR.resx b/src/Certify.Locales/SR.resx index a9d642894..a309239e5 100644 --- a/src/Certify.Locales/SR.resx +++ b/src/Certify.Locales/SR.resx @@ -1,4 +1,4 @@ - + - + - + - + - + - + diff --git a/src/Certify.Service/Certify.Service.csproj b/src/Certify.Service/Certify.Service.csproj index a89735035..a2555e579 100644 --- a/src/Certify.Service/Certify.Service.csproj +++ b/src/Certify.Service/Certify.Service.csproj @@ -1,39 +1,22 @@ net462 - win-x64 Debug;Release; Certify.Service Exe app.manifest icon.ico - x64;AnyCPU + AnyCPU true - - x64 - DEBUG;TRACE - - - x64 - DEBUG;TRACE - - - - x64 - - x64 - - - - x64 - bin\Release\ + AnyCPU + DEBUG;TRACE - x64 + AnyCPU bin\Release\ @@ -54,8 +37,8 @@ + - @@ -65,15 +48,14 @@ - - - - + + + - + diff --git a/src/Certify.Service/Controllers/AuthController.cs b/src/Certify.Service/Controllers/AuthController.cs deleted file mode 100644 index a7bb733c9..000000000 --- a/src/Certify.Service/Controllers/AuthController.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Threading.Tasks; -using System.Web.Http; -using Certify.Management; - -namespace Certify.Service.Controllers -{ - [RoutePrefix("api/auth")] - public class AuthController : ControllerBase - { - public class AuthModel - { - public string Key { get; set; } - public string Username { get; set; } - public string Password { get; set; } - } - - private ICertifyManager _certifyManager = null; - - public AuthController(ICertifyManager manager) - { - _certifyManager = manager; - } -#if !RELEASE //feature not production ready - [HttpGet, Route("windows")] - public async Task GetWindowsAuthKey() - { - - // user is using windows authentication, return an initial secret auth token. TODO: user must be able to invalidate existing auth key - var encryptedBytes = System.Security.Cryptography.ProtectedData.Protect( - System.Text.Encoding.UTF8.GetBytes(ActionContext.RequestContext.Principal.Identity.Name), - System.Text.Encoding.UTF8.GetBytes("authtoken"), System.Security.Cryptography.DataProtectionScope.LocalMachine - ); - - var secret = Convert.ToBase64String(encryptedBytes); - - var userIdPlusSecret = ActionContext.RequestContext.Principal.Identity.Name + ":" + secret; - - // return auth secret as Base64 string suitable for Basic Authorization https://en.wikipedia.org/wiki/Basic_access_authentication - return await Task.FromResult(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(userIdPlusSecret))); - - } - - [HttpPost, Route("token")] - [AllowAnonymous] - public async Task AcquireToken(AuthModel model) - { - DebugLog(); - - // TODO: validate authkey and return new JWT - - if (model.Key == "windows123") - { - var jwt = GenerateJwt("certifyuser", GetAuthSecretKey()); - return await Task.FromResult(Ok(jwt)); - } - else - { - return await Task.FromResult(Unauthorized()); - } - } - - [HttpPost, Route("refresh")] - public async Task Refresh() - { - DebugLog(); - - // TODO: validate refresh token and return new JWT - - var jwt = GenerateJwt("certifyuser", GetAuthSecretKey()); - return await Task.FromResult(jwt); - } -#endif - } -} diff --git a/src/Certify.Service/Controllers/ManagedCertificateController.cs b/src/Certify.Service/Controllers/ManagedCertificateController.cs index 04b9170b0..94a5b12ec 100644 --- a/src/Certify.Service/Controllers/ManagedCertificateController.cs +++ b/src/Certify.Service/Controllers/ManagedCertificateController.cs @@ -7,8 +7,11 @@ using Certify.Config; using Certify.Management; using Certify.Models; +using Certify.Models.API; using Certify.Models.Config; +using Certify.Models.Reporting; using Certify.Models.Utils; +using Microsoft.Extensions.Logging; using Serilog; namespace Certify.Service.Controllers @@ -33,6 +36,21 @@ public async Task> Search(ManagedCertificateFilter filt return await _certifyManager.GetManagedCertificates(filter); } + // Get List of Top N Managed Certificates, filtered by title, as a Search Result with total count + [HttpPost, Route("results")] + public async Task GetResults(ManagedCertificateFilter filter) + { + DebugLog(); + return await _certifyManager.GetManagedCertificateResults(filter); + } + + [HttpPost, Route("summary")] + public async Task GetSummary(ManagedCertificateFilter filter) + { + DebugLog(); + return await _certifyManager.GetManagedCertificateSummary(filter); + } + [HttpGet, Route("{id}")] public async Task GetById(string id) { @@ -69,9 +87,6 @@ public async Task> TestChallengeResponse(ManagedCertificate var progressIndicator = new Progress(progressState.ProgressReport); - //begin monitoring progress - _certifyManager.BeginTrackingProgress(progressState); - // perform challenge response test, log to string list and return in result var logList = new List(); using (var log = new LoggerConfiguration() @@ -79,36 +94,13 @@ public async Task> TestChallengeResponse(ManagedCertificate .WriteTo.Sink(new ProgressLogSink(progressIndicator, managedCertificate, _certifyManager)) .CreateLogger()) { - var theLog = new Loggy(log); + var theLog = new Loggy(new Serilog.Extensions.Logging.SerilogLoggerFactory(log).CreateLogger()); var results = await _certifyManager.TestChallenge(theLog, managedCertificate, isPreviewMode: true, progress: progressIndicator); return results; } } - [HttpPost, Route("challengecleanup")] - public async Task> PerformChallengeCleanup(ManagedCertificate managedCertificate) - { - DebugLog(); - - var progressState = new RequestProgressState(RequestState.Running, "Performing Challenge Cleanup..", managedCertificate); - - var progressIndicator = new Progress(progressState.ProgressReport); - - // perform challenge response test, log to string list and return in result - var logList = new List(); - using (var log = new LoggerConfiguration() - - .WriteTo.Sink(new ProgressLogSink(progressIndicator, managedCertificate, _certifyManager)) - .CreateLogger()) - { - var theLog = new Loggy(log); - var results = await _certifyManager.PerformChallengeCleanup(theLog, managedCertificate, progress: progressIndicator); - - return results; - } - } - [HttpPost, Route("preview")] public async Task> PreviewActions(ManagedCertificate site) { @@ -184,9 +176,6 @@ public async Task BeginCertificateRequest(string manag var progressIndicator = new Progress(progressState.ProgressReport); - //begin monitoring progress - _certifyManager.BeginTrackingProgress(progressState); - //begin request var result = await _certifyManager.PerformCertificateRequest( null, @@ -198,17 +187,8 @@ public async Task BeginCertificateRequest(string manag return result; } - [HttpGet, Route("requeststatus/{managedItemId}")] - public RequestProgressState CheckCertificateRequest(string managedItemId) - { - DebugLog(); - - //TODO: check current status of request in progress - return _certifyManager.GetRequestProgressState(managedItemId); - } - [HttpGet, Route("log/{managedItemId}/{limit}")] - public async Task GetLog(string managedItemId, int limit) + public async Task GetLog(string managedItemId, int limit) { DebugLog(); return await _certifyManager.GetItemLog(managedItemId, limit); diff --git a/src/Certify.Service/Controllers/SystemController.cs b/src/Certify.Service/Controllers/SystemController.cs index e408d0900..35de239b3 100644 --- a/src/Certify.Service/Controllers/SystemController.cs +++ b/src/Certify.Service/Controllers/SystemController.cs @@ -2,10 +2,10 @@ using System.Threading.Tasks; using System.Web.Http; using System.Web.Http.Cors; -using Certify.Config.Migration; using Certify.Management; using Certify.Models; using Certify.Models.Config; +using Certify.Models.Config.Migration; using Certify.Shared; namespace Certify.Service.Controllers diff --git a/src/Certify.Service/OwinService.cs b/src/Certify.Service/OwinService.cs index ba48bee83..60d2cb0dd 100644 --- a/src/Certify.Service/OwinService.cs +++ b/src/Certify.Service/OwinService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using System.ServiceProcess; using Certify.Management; diff --git a/src/Certify.Shared.Extensions/Certify.Shared.Extensions.csproj b/src/Certify.Shared.Extensions/Certify.Shared.Extensions.csproj index 7e6e80ef6..25396eb9f 100644 --- a/src/Certify.Shared.Extensions/Certify.Shared.Extensions.csproj +++ b/src/Certify.Shared.Extensions/Certify.Shared.Extensions.csproj @@ -1,9 +1,8 @@ - + - net462;netstandard2.0;net7.0 + netstandard2.0;net9.0 AnyCPU - @@ -23,51 +22,8 @@ - - - - - - + + diff --git a/src/Certify.Shared.Extensions/Utils/PowerShellManager.cs b/src/Certify.Shared.Extensions/Utils/PowerShellManager.cs index 0502ed92d..62d2a9018 100644 --- a/src/Certify.Shared.Extensions/Utils/PowerShellManager.cs +++ b/src/Certify.Shared.Extensions/Utils/PowerShellManager.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Management.Automation; using System.Management.Automation.Runspaces; +using System.Runtime.InteropServices; using System.Security; using System.Security.AccessControl; using System.Security.Principal; @@ -16,18 +17,26 @@ namespace Certify.Management { + /// + /// PowerShell script execution manager. + /// Manage the execution of PowerShell scripts, either in-process or by launching a new process. + /// public class PowerShellManager { /// - /// + /// Run a PowerShell script, either in-process or by launching a new process. /// - /// Unrestricted etc, - /// - /// - /// - /// - /// - /// + /// Unrestricted etc, see https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_execution_policies?view=powershell-7.3 + /// Result object to pass to the script + /// Path to the script file + /// Parameters to pass to the script + /// Content of the script + /// Credentials to use for running the script + /// Logon type to use for running the script + /// Commands to ignore exceptions for + /// Timeout in minutes + /// Launch a new process + /// ActionResult public static async Task RunScript( string powershellExecutionPolicy, CertificateRequestResult result = null, @@ -60,7 +69,7 @@ public static async Task RunScript( if (launchNewProcess) { // spawn new process as the given user - return ExecutePowershellAsProcess(result, powershellExecutionPolicy, scriptFile, parameters, credentials, scriptContent, null, ignoredCommandExceptions: ignoredCommandExceptions, timeoutMinutes: timeoutMinutes); + return await ExecutePowershellAsProcess(result, powershellExecutionPolicy, scriptFile, parameters, credentials, logonType, scriptContent, null, ignoredCommandExceptions: ignoredCommandExceptions, timeoutMinutes: timeoutMinutes); } else { @@ -82,49 +91,33 @@ public static async Task RunScript( { shell.Runspace = runspace; - if (credentials != null && credentials.Any()) + // running PowerShell under credentials currently only supported for windows + var credentialsProvidedButNotSupported = false; + + if (credentials?.Any() == true && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // TODO: warn credentials not supported on this platform + credentialsProvidedButNotSupported = true; + } + + if (credentials?.Any() == true && credentialsProvidedButNotSupported == false && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { // run as windows user UserCredentials windowsCredentials = null; - if (credentials != null && credentials.Count > 0) - { - try - { - windowsCredentials = GetWindowsCredentials(credentials); - } - catch - { - var err = "Command with Windows Credentials requires username and password."; - - return new ActionResult(err, false); - } - } - - // logon type affects the range of abilities the impersonated user has - var _defaultLogonType = LogonType.NewCredentials; - - if (logonType == "network") - { - _defaultLogonType = LogonType.Network; - } - else if (logonType == "batch") + try { - _defaultLogonType = LogonType.Batch; + windowsCredentials = GetWindowsCredentials(credentials); } - else if (logonType == "service") + catch { - _defaultLogonType = LogonType.Service; - } - else if (logonType == "interactive") - { - _defaultLogonType = LogonType.Interactive; - } - else if (logonType == "newcredentials") - { - _defaultLogonType = LogonType.NewCredentials; + var err = "Command with Windows Credentials requires username and password."; + + return new ActionResult(err, false); } + var _defaultLogonType = GetLogonType(logonType); + ActionResult powerShellResult = null; using (var userHandle = windowsCredentials.LogonUser(_defaultLogonType)) { @@ -152,13 +145,37 @@ public static async Task RunScript( } } - private static string GetPowershellExePath() + private static LogonType GetLogonType(string logonType) + { + return logonType?.ToLower() switch + { + "network" => LogonType.Network, + "batch" => LogonType.Batch, + "service" => LogonType.Service, + "interactive" => LogonType.Interactive, + "newcredentials" => LogonType.NewCredentials, + _ => LogonType.NewCredentials, + }; + } + + /// + /// Get the path to the pwoershell exe, optionally using a preferred path first + /// + /// + /// + private static string GetPowershellExePath(string powershellPathPreference) { var searchPaths = new List() { "%WINDIR%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", - "%PROGRAMFILES%\\PowerShell\\7\\pwsh.exe" + "%PROGRAMFILES%\\PowerShell\\7\\pwsh.exe", + "/usr/bin/pwsh" }; + if (!string.IsNullOrWhiteSpace(powershellPathPreference)) + { + searchPaths.Insert(0, powershellPathPreference); + } + // if powershell exe path supplied, use that (with expansion) and check exe exists // otherwise detect powershell exe location foreach (var exePath in searchPaths) @@ -173,15 +190,15 @@ private static string GetPowershellExePath() return null; } - private static ActionResult ExecutePowershellAsProcess(CertificateRequestResult result, string executionPolicy, string scriptFile, Dictionary parameters, Dictionary credentials, string scriptContent, PowerShell shell, bool autoConvertBoolean = true, string[] ignoredCommandExceptions = null, int timeoutMinutes = 5) + private static async Task ExecutePowershellAsProcess(CertificateRequestResult result, string executionPolicy, string scriptFile, Dictionary parameters, Dictionary credentials, string logonType, string scriptContent, PowerShell shell, bool autoConvertBoolean = true, string[] ignoredCommandExceptions = null, int timeoutMinutes = 5, string powershellPathPreference = null) { - var _log = new StringBuilder(); - var commandExe = GetPowershellExePath(); + var commandExe = GetPowershellExePath(powershellPathPreference); + if (commandExe == null) { - return new ActionResult("Failed to locate powershell exe. Cannot launch as new process.", false); + return new ActionResult("Failed to locate powershell executable. Cannot launch as new process.", false); } if (!string.IsNullOrEmpty(scriptContent)) @@ -190,11 +207,47 @@ private static ActionResult ExecutePowershellAsProcess(CertificateRequestResult return new ActionResult("Script content is not yet supported when used with launch as new process.", false); } + var resultObj = parameters?.Where(p => p.Key == "result" && p.Value != null).FirstOrDefault().Value; + var resultJson = resultObj != null ? Newtonsoft.Json.JsonConvert.SerializeObject(resultObj) : null; + + var resultsJsonTempPath = string.Empty; + var resultsJsonExported = false; + var appBasePath = AppContext.BaseDirectory; + var wrapperScriptPath = Path.Combine(new string[] { appBasePath, "Scripts", "Internal", "Script-Wrapper.ps1" }); + var wrapperScriptSourceText = File.ReadAllText(wrapperScriptPath); + + var isUsingCredentials = (credentials != null && credentials.ContainsKey("username") && credentials.ContainsKey("password")); + + if (isUsingCredentials && (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))) + { + // The impersonating user must be able to read the script wrapper so that the process starting under their credentials can call it. They will also need to be able to read the users supplied target script (not addressed here). + // If the Results object is also being used we write that to a temp file and set the ACL to allow read by the impersonating user. + + try + { + var username = GetWindowsCredentialsUsername(credentials); - // note that the impersonating user must be able to read the script wrapper so that the process starting under their credentials can call it and the target script - // if the Results object is also being used we write that to a temp file and set the ACL to allow read by the impersonating user. + var wrapperTempPath = Path.GetTempPath(); + var wrapperTempFilePath = Path.GetTempFileName(); + wrapperScriptPath = Path.ChangeExtension(wrapperTempFilePath, ".ps1"); + File.WriteAllText(wrapperScriptPath, wrapperScriptSourceText); + ApplyFileACL(wrapperScriptPath, username); + + resultsJsonTempPath = Path.GetTempFileName(); + File.WriteAllText(resultsJsonTempPath, resultJson); + ApplyFileACL(resultsJsonTempPath, username); + + resultsJsonExported = true; + } + catch + { + var err = "A command with Windows Credentials requires a correct username and password. Check credentials."; + + return new ActionResult(err, false); + } + } var arguments = $" -File \"{wrapperScriptPath}\""; @@ -205,24 +258,27 @@ private static ActionResult ExecutePowershellAsProcess(CertificateRequestResult if (!string.IsNullOrEmpty(executionPolicy)) { - arguments = $"-ExecutionPolicy {executionPolicy} " + arguments; + arguments = $"-ExecutionPolicy {executionPolicy} {arguments}"; } arguments += $" -scriptFile \"{scriptFile}\""; - string resultsJsonTempPath = null; - if (parameters?.Any() == true) { foreach (var p in parameters) { if (p.Key == "result" && p.Value != null) { - // reserved parameter name for the ManagedCertificate object - var json = Newtonsoft.Json.JsonConvert.SerializeObject(p.Value); + if (!resultsJsonExported) + { // if results file not already exported for the impersonated user export now + + // "result" is reserved parameter name for the ManagedCertificate object + var json = Newtonsoft.Json.JsonConvert.SerializeObject(p.Value); - resultsJsonTempPath = Path.GetTempFileName(); - File.WriteAllText(resultsJsonTempPath, json); + resultsJsonTempPath = Path.GetTempFileName(); + File.WriteAllText(resultsJsonTempPath, json); + resultsJsonExported = true; + } arguments += $" -resultJsonFile \"{resultsJsonTempPath}\""; } @@ -249,48 +305,39 @@ private static ActionResult ExecutePowershellAsProcess(CertificateRequestResult // launch process with user credentials set if (credentials != null && credentials.ContainsKey("username") && credentials.ContainsKey("password")) { - var username = credentials["username"]; - var pwd = credentials["password"]; - - credentials.TryGetValue("domain", out var domain); - - if (domain == null && !username.Contains(".\\") && !username.Contains("@")) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - domain = "."; - } - - scriptProcessInfo.UserName = username; - scriptProcessInfo.Domain = domain; - - var sPwd = new SecureString(); - foreach (var c in pwd) - { - sPwd.AppendChar(c); - } - - sPwd.MakeReadOnly(); - scriptProcessInfo.Password = sPwd; + var username = credentials["username"]; + var pwd = credentials["password"]; - if (resultsJsonTempPath != null) - { - //allow this user to read the results file - var fileInfo = new FileInfo(resultsJsonTempPath); - var accessControl = fileInfo.GetAccessControl(); - var fullUser = domain == "." ? username : $"{domain}\\{username}"; - accessControl.AddAccessRule(new FileSystemAccessRule(fullUser, FileSystemRights.Read, AccessControlType.Allow)); + credentials.TryGetValue("domain", out var domain); - try + if (domain == null && !username.Contains(".\\") && !username.Contains("@")) { - fileInfo.SetAccessControl(accessControl); + domain = "."; } - catch + + // Note: process running as local system cannot start a process as different user due to lack of security token context + scriptProcessInfo.UserName = username; + scriptProcessInfo.Domain = domain; + + var sPwd = new SecureString(); + foreach (var c in pwd) { - _log.AppendLine("Running Powershell As New Process: Could not apply access control to allow this user to read the temp results file"); + sPwd.AppendChar(c); } - } - _log.AppendLine($"Launching Process {commandExe} as User: {domain}\\{username}"); + sPwd.MakeReadOnly(); + + scriptProcessInfo.Password = sPwd; + + _log.AppendLine($"Launching Process {commandExe} as User: {domain}\\{username}"); + } + else + { + _log.AppendLine($"Running PowerShell As New Process: Running as specific user credentials are not supported on this platform."); + } } try @@ -353,7 +400,11 @@ private static ActionResult ExecutePowershellAsProcess(CertificateRequestResult catch (Exception exp) { _log.AppendLine("Error: " + exp.ToString()); - return new ActionResult { IsSuccess = false, Message = _log.ToString() }; + return new ActionResult + { + IsSuccess = false, + Message = _log.ToString() + }; } finally { @@ -373,6 +424,24 @@ private static ActionResult ExecutePowershellAsProcess(CertificateRequestResult } } + private static bool ApplyFileACL(string filePath, string fullUsername) + { + var fileInfo = new FileInfo(filePath); + var accessControl = fileInfo.GetAccessControl(); + + accessControl.AddAccessRule(new FileSystemAccessRule(fullUsername, FileSystemRights.ReadAndExecute, AccessControlType.Allow)); + + try + { + fileInfo.SetAccessControl(accessControl); + return true; + } + catch + { + return false; + } + } + private static ActionResult InvokePowershell(CertificateRequestResult result, string executionPolicy, string scriptFile, Dictionary parameters, string scriptContent, PowerShell shell, bool autoConvertBoolean = true, string[] ignoredCommandExceptions = null, int timeoutMinutes = 5) { // ensure execution policy will allow the script to run, default to system default, default policy is set in service config object @@ -383,13 +452,17 @@ private static ActionResult InvokePowershell(CertificateRequestResult result, st executionPolicy = parameters.FirstOrDefault(p => p.Key.ToLower() == "executionpolicy").Value?.ToString(); } - if (!string.IsNullOrEmpty(executionPolicy)) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - shell.AddCommand("Set-ExecutionPolicy") - .AddParameter("ExecutionPolicy", executionPolicy) - .AddParameter("Scope", "Process") - .AddParameter("Force") - .Invoke(); + // on windows we may need to set execution policy depending on user preferences + if (!string.IsNullOrEmpty(executionPolicy)) + { + shell.AddCommand("Set-ExecutionPolicy") + .AddParameter("ExecutionPolicy", executionPolicy) + .AddParameter("Scope", "Process") + .AddParameter("Force") + .Invoke(); + } } // add script command to invoke @@ -563,5 +636,29 @@ public static UserCredentials GetWindowsCredentials(Dictionary c return windowsCredentials; } + + public static string GetWindowsCredentialsUsername(Dictionary credentials, bool includeAutoLocalDomain = false) + { + var username = credentials["username"]; + + credentials.TryGetValue("domain", out var domain); + + if (includeAutoLocalDomain) + { + if (domain == null && !username.Contains(".\\") && !username.Contains("@")) + { + domain = "."; + } + } + + if (domain != null) + { + return $"{domain}\\{username}"; + } + else + { + return username; + } + } } } diff --git a/src/Certify.Shared/Certify.Shared.Core.csproj b/src/Certify.Shared/Certify.Shared.Core.csproj index 254af5b42..dbfc87e30 100644 --- a/src/Certify.Shared/Certify.Shared.Core.csproj +++ b/src/Certify.Shared/Certify.Shared.Core.csproj @@ -1,27 +1,28 @@  - netstandard2.0;net6.0 - AnyCPU;x64 + netstandard2.0;net9.0 + AnyCPU - + + + + + - - - - - - + + + diff --git a/src/Certify.Shared/Management/CertificateManager.cs b/src/Certify.Shared/Management/CertificateManager.cs index a9e5d7435..13bcc8b3c 100644 --- a/src/Certify.Shared/Management/CertificateManager.cs +++ b/src/Certify.Shared/Management/CertificateManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -32,6 +32,9 @@ public static class CertificateManager public const string DEFAULT_STORE_NAME = "My"; public const string WEBHOSTING_STORE_NAME = "WebHosting"; public const string DISALLOWED_STORE_NAME = "Disallowed"; + private static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + private static readonly bool IsLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + private static readonly bool IsMac = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); public static X509Certificate2 GenerateSelfSignedCertificate(string domain, DateTimeOffset? dateFrom = null, DateTimeOffset? dateTo = null, string suffix = "[Certify]", string subject = null, string keyType = StandardKeyTypes.RSA256) { @@ -269,6 +272,72 @@ public static Org.BouncyCastle.X509.X509Certificate ReadCertificateFromPem(strin return cert; } + private static X509Store GetStore(string storeName, bool useMachineStore = true) + { + if (IsWindows) + { + if (useMachineStore) + { + return GetMachineStore(storeName); + } + + return GetUserStore(storeName); + } + else if (IsLinux) + { + // See https://github.com/dotnet/runtime/blob/main/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/StorePal.OpenSsl.cs#L142 + return GetUserStore(storeName); + } + else if (IsMac) + { + // See https://github.com/dotnet/runtime/blob/main/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/StorePal.macOS.cs#L108 + if (!useMachineStore || (storeName == CA_STORE_NAME || storeName == WEBHOSTING_STORE_NAME)) + { + return GetUserStore(storeName); + } + else if (storeName == DEFAULT_STORE_NAME || storeName == ROOT_STORE_NAME || storeName == DISALLOWED_STORE_NAME) + { + return GetMachineStore(storeName); + } + + throw new CryptographicException($"Could not open X509Store {storeName} in LocalMachine on OSX"); + } + + throw new PlatformNotSupportedException($"Could not open X509Store for unsupported OS {RuntimeInformation.OSDescription}"); + } + + private static void OpenStoreForReadWrite(X509Store store, string storeName) + { + if (IsWindows) + { + store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); + } + else if (IsLinux) + { + // See https://github.com/dotnet/runtime/blob/main/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/StorePal.OpenSsl.cs#L142 + if (storeName == DEFAULT_STORE_NAME || storeName == WEBHOSTING_STORE_NAME) + { + store.Open(OpenFlags.ReadWrite); + } + else + { + store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); + } + } + else if (IsMac) + { + // See https://github.com/dotnet/runtime/blob/main/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/StorePal.macOS.cs#L108 + if (storeName == CA_STORE_NAME || storeName == WEBHOSTING_STORE_NAME) + { + store.Open(OpenFlags.ReadWrite); + } + else + { + store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); + } + } + } + public static bool StoreCertificateFromPem(string pem, string storeName, bool useMachineStore = true) { try @@ -277,9 +346,9 @@ public static bool StoreCertificateFromPem(string pem, string storeName, bool us var cert = x509CertificateParser.ReadCertificate(System.Text.UTF8Encoding.UTF8.GetBytes(pem)); var certToStore = new X509Certificate2(DotNetUtilities.ToX509Certificate(cert)); - using (var store = useMachineStore ? GetMachineStore(storeName) : GetUserStore(storeName)) + using (var store = GetStore(storeName, useMachineStore)) { - store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); + OpenStoreForReadWrite(store, storeName); store.Add(certToStore); store.Close(); return true; @@ -367,11 +436,11 @@ public static async Task StoreCertificate( } } - public static List GetCertificatesFromStore(string issuerName = null, string storeName = DEFAULT_STORE_NAME) + public static List GetCertificatesFromStore(string issuerName = null, string storeName = DEFAULT_STORE_NAME, bool useMachineStore = true) { var list = new List(); - using (var store = GetMachineStore(storeName)) + using (var store = GetStore(storeName, useMachineStore)) { store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly); @@ -394,7 +463,7 @@ public static X509Certificate2 GetCertificateFromStore(string subjectName, strin { X509Certificate2 cert = null; - using (var store = GetMachineStore(storeName)) + using (var store = GetStore(storeName)) { store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly); @@ -420,7 +489,7 @@ public static X509Certificate2 GetCertificateByThumbprint(string thumbprint, str X509Certificate2 cert = null; - using (var store = useMachineStore ? GetMachineStore(storeName) : GetUserStore(storeName)) + using (var store = GetStore(storeName, useMachineStore)) { store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly); @@ -439,9 +508,9 @@ public static X509Certificate2 GetCertificateByThumbprint(string thumbprint, str public static X509Certificate2 StoreCertificate(X509Certificate2 certificate, string storeName = DEFAULT_STORE_NAME) { - using (var store = GetMachineStore(storeName)) + using (var store = GetStore(storeName)) { - store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); + OpenStoreForReadWrite(store, storeName); store.Add(certificate); @@ -453,9 +522,9 @@ public static X509Certificate2 StoreCertificate(X509Certificate2 certificate, st public static void RemoveCertificate(X509Certificate2 certificate, string storeName = DEFAULT_STORE_NAME) { - using (var store = GetMachineStore(storeName)) + using (var store = GetStore(storeName)) { - store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); + OpenStoreForReadWrite(store, storeName); store.Remove(certificate); store.Close(); } @@ -727,7 +796,7 @@ public static bool IsCertificateInStore(X509Certificate2 cert, string storeName { var certExists = false; - using (var store = GetMachineStore(storeName)) + using (var store = GetStore(storeName)) { store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly); @@ -772,9 +841,9 @@ public static List PerformCertificateStoreCleanup( } // get all certificates - using (var store = GetMachineStore(storeName)) + using (var store = GetStore(storeName)) { - store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); + OpenStoreForReadWrite(store, storeName); var certsToRemove = new List(); foreach (var c in store.Certificates) @@ -866,9 +935,9 @@ public static bool DisableCertificateUsage(string thumbprint, string sourceStore { var disabled = false; - using (var store = useMachineStore ? GetMachineStore(sourceStore) : GetUserStore(sourceStore)) + using (var store = GetStore(sourceStore, useMachineStore)) { - store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); + OpenStoreForReadWrite(store, sourceStore); foreach (var c in store.Certificates) { @@ -887,9 +956,9 @@ public static bool DisableCertificateUsage(string thumbprint, string sourceStore public static bool MoveCertificate(string thumbprint, string sourceStore, string destStore, bool useMachineStore = true) { var certsToMove = new List(); - using (var store = useMachineStore ? GetMachineStore(sourceStore) : GetUserStore(sourceStore)) + using (var store = GetStore(sourceStore, useMachineStore)) { - store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); + OpenStoreForReadWrite(store, sourceStore); foreach (var c in store.Certificates) { if (c.Thumbprint == thumbprint) @@ -908,9 +977,9 @@ public static bool MoveCertificate(string thumbprint, string sourceStore, string if (certsToMove.Any()) { - using (var store = useMachineStore ? GetMachineStore(destStore) : GetUserStore(destStore)) + using (var store = GetStore(destStore, useMachineStore)) { - store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadWrite); + OpenStoreForReadWrite(store, destStore); foreach (var c in certsToMove) { var foundCerts = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); diff --git a/src/Certify.Shared/Management/CredentialsManagerBase.cs b/src/Certify.Shared/Management/CredentialsUtil.cs similarity index 71% rename from src/Certify.Shared/Management/CredentialsManagerBase.cs rename to src/Certify.Shared/Management/CredentialsUtil.cs index b2626fe1d..2788419ae 100644 --- a/src/Certify.Shared/Management/CredentialsManagerBase.cs +++ b/src/Certify.Shared/Management/CredentialsUtil.cs @@ -1,25 +1,17 @@ using System; -using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; -using Certify.Models.Config; using Certify.Providers; namespace Certify.Management { - public class CredentialsManagerBase + public static class CredentialsUtil { - - protected bool _useWindowsNativeFeatures = true; - - public CredentialsManagerBase(bool useWindowsNativeFeatures = true) - { - _useWindowsNativeFeatures = useWindowsNativeFeatures; - } - - public async Task IsCredentialInUse(IManagedItemStore itemStore, string storageKey) + public static async Task IsCredentialInUse(IManagedItemStore itemStore, string storageKey) { if (itemStore == null) { @@ -39,18 +31,6 @@ public async Task IsCredentialInUse(IManagedItemStore itemStore, string st } } -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously - public virtual async Task GetCredential(string storageKey) - { - throw new NotImplementedException(); - } - - public virtual async Task> GetUnlockedCredentialsDictionary(string storageKey) - { - throw new NotImplementedException(); - } -#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously - /// /// Get protected version of a secret /// @@ -58,10 +38,10 @@ public virtual async Task> GetUnlockedCredentialsDict /// /// /// - public string Protect( + public static string Protect( string clearText, string optionalEntropy = null, - DataProtectionScope scope = DataProtectionScope.CurrentUser) + DataProtectionScope? scope = null) { // https://www.thomaslevesque.com/2013/05/21/an-easy-and-secure-way-to-store-a-password-using-data-protection-api/ @@ -70,18 +50,27 @@ public string Protect( return null; } - if (_useWindowsNativeFeatures) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + if (scope == null) + { + scope = DataProtectionScope.CurrentUser; + } var clearBytes = Encoding.UTF8.GetBytes(clearText); var entropyBytes = string.IsNullOrEmpty(optionalEntropy) ? null : Encoding.UTF8.GetBytes(optionalEntropy); - var encryptedBytes = ProtectedData.Protect(clearBytes, entropyBytes, scope); + var encryptedBytes = ProtectedData.Protect(clearBytes, entropyBytes, (DataProtectionScope)scope); return Convert.ToBase64String(encryptedBytes); } else { +#if RELEASE + Trace.Assert(true, "Using dummy encryption, not suitable for production use."); +#endif + Trace.WriteLine("Using dummy encryption, not suitable for production use."); + // TODO: dummy implementation, require alternative implementation for non-windows return Convert.ToBase64String(Encoding.UTF8.GetBytes(clearText).Reverse().ToArray()); } @@ -94,10 +83,10 @@ public string Protect( /// /// /// - public string Unprotect( + public static string Unprotect( string encryptedText, string optionalEntropy = null, - DataProtectionScope scope = DataProtectionScope.CurrentUser) + DataProtectionScope? scope = null) { // https://www.thomaslevesque.com/2013/05/21/an-easy-and-secure-way-to-store-a-password-using-data-protection-api/ @@ -106,18 +95,23 @@ public string Unprotect( throw new ArgumentNullException("encryptedText"); } - if (_useWindowsNativeFeatures) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + if (scope == null) + { + scope = DataProtectionScope.CurrentUser; + } + var encryptedBytes = Convert.FromBase64String(encryptedText); var entropyBytes = string.IsNullOrEmpty(optionalEntropy) ? null : Encoding.UTF8.GetBytes(optionalEntropy); - var clearBytes = ProtectedData.Unprotect(encryptedBytes, entropyBytes, scope); + var clearBytes = ProtectedData.Unprotect(encryptedBytes, entropyBytes, (DataProtectionScope)scope); return Encoding.UTF8.GetString(clearBytes); } else { - + Debug.WriteLine("Using dummy encryption, not suitable for production use."); // TODO: dummy implementation, implement alternative implementation for non-windows var bytes = Convert.FromBase64String(encryptedText); return Encoding.UTF8.GetString(bytes.Reverse().ToArray()); diff --git a/src/Certify.Shared/Management/PluginManager.cs b/src/Certify.Shared/Management/PluginManager.cs index 478934884..45570ced7 100644 --- a/src/Certify.Shared/Management/PluginManager.cs +++ b/src/Certify.Shared/Management/PluginManager.cs @@ -7,7 +7,9 @@ using Certify.Models; using Certify.Models.Config; using Certify.Models.Plugins; +using Microsoft.Extensions.Logging; using Serilog; +using Serilog.Extensions.Logging; namespace Certify.Management { @@ -52,12 +54,15 @@ public class PluginManager public PluginManager() { - _log = new Models.Loggy( - new LoggerConfiguration() + var serilogLogger = new LoggerConfiguration() + .Enrich.FromLogContext() .MinimumLevel.Information() .WriteTo.File(Path.Combine(EnvironmentUtil.CreateAppDataPath("logs"), "plugins.log"), shared: true, flushToDiskInterval: new TimeSpan(0, 0, 10)) - .CreateLogger() - ); + .CreateLogger(); + + var msLogger = new SerilogLoggerFactory(serilogLogger).CreateLogger(); + + _log = new Models.Loggy(msLogger); if (CurrentInstance == null) { diff --git a/src/Certify.Shared/Management/ServerConnectionManager.cs b/src/Certify.Shared/Management/ServerConnectionManager.cs index 9d7f95936..801564166 100644 --- a/src/Certify.Shared/Management/ServerConnectionManager.cs +++ b/src/Certify.Shared/Management/ServerConnectionManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; diff --git a/src/Certify.Shared/Utils/DemoDataGenerator.cs b/src/Certify.Shared/Utils/DemoDataGenerator.cs new file mode 100644 index 000000000..10903c4a0 --- /dev/null +++ b/src/Certify.Shared/Utils/DemoDataGenerator.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Certify.Models; +using Certify.Models.Shared.Validation; + +namespace Certify.Shared.Core.Utils +{ + public class DemoDataGenerator + { + public static List GenerateDemoItems() + { + var rnd = new Random(); + + var items = new List(); + var numItems = new Random().Next(10, 500); + for (var i = 0; i < numItems; i++) + { + + var item = new ManagedCertificate + { + Id = Guid.NewGuid().ToString(), + Name = GenerateName(rnd), + RequestConfig = new CertRequestConfig + { + Challenges = new System.Collections.ObjectModel.ObservableCollection { new CertRequestChallengeConfig { ChallengeType = SupportedChallengeTypes.CHALLENGE_TYPE_HTTP } } + } + }; + + item.DomainOptions.Add(new DomainOption { Domain = $"{item.Name}.dev.projectbids.co.uk", IsManualEntry = true, IsPrimaryDomain = true, IsSelected = true, Type = CertIdentifierType.Dns }); + item.RequestConfig.PrimaryDomain = item.DomainOptions[0].Domain; + item.RequestConfig.SubjectAlternativeNames = new string[] { item.DomainOptions[0].Domain }; + + var validation = CertificateEditorService.Validate(item, null, null, applyAutoConfiguration: true); + if (validation.IsValid) + { + var demoState = new Random().Next(1, 3); + var certLifetime = new Random().Next(7, 30); + var certElapsed = new Random().Next(1, certLifetime); + var certStart = DateTime.UtcNow.AddDays(-certElapsed); + + if (demoState == 1) + { + // not yet requested + item.Comments = "This is an example item note yet attempted."; + } + else if (demoState == 2) + { + // failed + item.CertificateCurrentCA = "demo-ca.org"; + item.DateStart = certStart; + item.DateLastRenewalAttempt = certStart; + item.DateExpiry = certStart.AddDays(certLifetime); + item.CertificateFriendlyName = $"{item.GetCertificateIdentifiers().First().Value} [CertifyDemo] - {item.DateStart} to {item.DateExpiry}"; + item.Comments = "This is an example item showing failure."; + item.LastAttemptedCA = item.CertificateCurrentCA; + item.LastRenewalStatus = RequestState.Error; + item.RenewalFailureCount = new Random().Next(1, 3); + item.RenewalFailureMessage = "Item failed because it is a demo item that was designed to show what failure looks like."; + + } + else if (demoState == 3) + { + //success + item.CertificateCurrentCA = "demo-ca.org"; + item.DateStart = certStart; + item.DateLastRenewalAttempt = certStart; + item.DateExpiry = certStart.AddDays(certLifetime); + item.CertificateFriendlyName = $"{item.GetCertificateIdentifiers().First().Value} [CertifyDemo] - {item.DateStart} to {item.DateExpiry}"; + item.Comments = "This is an example item showing success"; + item.LastAttemptedCA = item.CertificateCurrentCA; + item.LastRenewalStatus = RequestState.Success; + } + + items.Add(item); + } + else + { + // generated invalid test item + System.Diagnostics.Debug.WriteLine(validation.Message); + } + } + + return items; + } + + public static string GenerateName(Random rnd) + { + // generate test item names using verb,animal + var subjects = new string[] { + "Lion", + "Tiger", + "Leopard", + "Cheetah", + "Elephant", + "Giraffe", + "Rhinoceros", + "Gorilla" + }; + var adjectives = new string[] { + "active", + "adaptable", + "alert", + "clever" , + "comfortable" , + "conscientious", + "considerate", + "courageous" , + "decisive", + "determined" , + "diligent" , + "energetic", + "entertaining", + "enthusiastic" , + "fabulous" + }; + + return $"{adjectives[rnd.Next(0, adjectives.Length - 1)]}-{subjects[rnd.Next(0, subjects.Length - 1)]}".ToLower(); + } + } +} diff --git a/src/Certify.Shared/Utils/HttpChallengeServer.cs b/src/Certify.Shared/Utils/HttpChallengeServer.cs index cecdf3c95..8f3bb0a76 100644 --- a/src/Certify.Shared/Utils/HttpChallengeServer.cs +++ b/src/Certify.Shared/Utils/HttpChallengeServer.cs @@ -51,8 +51,8 @@ public class HttpChallengeServer private int _autoCloseSeconds = 60; private string _baseUri = ""; private Timer _autoCloseTimer; - private readonly object _challengeServerStartLock = new object(); - private readonly object _challengeServerStopLock = new object(); + private readonly Lock _challengeServerStartLock = LockFactory.Create(); + private readonly Lock _challengeServerStopLock = LockFactory.Create(); /// /// If true, challenge server has been started or a start has been attempted diff --git a/src/Certify.Shared/Utils/Loggy.cs b/src/Certify.Shared/Utils/Loggy.cs index c946e7b0a..01981517f 100644 --- a/src/Certify.Shared/Utils/Loggy.cs +++ b/src/Certify.Shared/Utils/Loggy.cs @@ -1,5 +1,5 @@ using System; -using Serilog; +using Microsoft.Extensions.Logging; namespace Certify.Models { @@ -12,16 +12,16 @@ public Loggy(ILogger log) _log = log; } - public void Error(string template, params object[] propertyValues) => _log.Error(template, propertyValues); + public void Error(string template, params object[] propertyValues) => _log?.LogError(template, propertyValues); - public void Error(Exception exp, string template, params object[] propertyValues) => _log.Error(exp, template, propertyValues); + public void Error(Exception exp, string template, params object[] propertyValues) => _log?.LogError(exp, template, propertyValues); - public void Information(string template, params object[] propertyValues) => _log.Information(template, propertyValues); + public void Information(string template, params object[] propertyValues) => _log?.LogInformation(template, propertyValues); - public void Debug(string template, params object[] propertyValues) => _log.Debug(template, propertyValues); + public void Debug(string template, params object[] propertyValues) => _log?.LogDebug(template, propertyValues); - public void Verbose(string template, params object[] propertyValues) => _log.Verbose(template, propertyValues); + public void Verbose(string template, params object[] propertyValues) => _log?.LogTrace(template, propertyValues); - public void Warning(string template, params object[] propertyValues) => _log.Warning(template, propertyValues); + public void Warning(string template, params object[] propertyValues) => _log?.LogWarning(template, propertyValues); } } diff --git a/src/Certify.Shared/Utils/ManagedCertificateLog.cs b/src/Certify.Shared/Utils/ManagedCertificateLog.cs index e6052b9cd..52ffb2c2f 100644 --- a/src/Certify.Shared/Utils/ManagedCertificateLog.cs +++ b/src/Certify.Shared/Utils/ManagedCertificateLog.cs @@ -2,7 +2,9 @@ using System.Collections.Concurrent; using System.IO; using Certify.Models.Providers; +using Microsoft.Extensions.Logging; using Serilog; +using Serilog.Core; namespace Certify.Models { @@ -26,11 +28,11 @@ public class ManagedCertificateLogItem public static class ManagedCertificateLog { - private static ConcurrentDictionary _managedItemLoggers { get; set; } + private static ConcurrentDictionary _managedItemLoggers { get; set; } public static string GetLogPath(string managedItemId) => Path.Combine(EnvironmentUtil.CreateAppDataPath("logs"), "log_" + managedItemId.Replace(':', '_') + ".txt"); - public static ILog GetLogger(string managedItemId, Serilog.Core.LoggingLevelSwitch logLevelSwitch) + public static ILog GetLogger(string managedItemId, LogLevel logLevelSwitch) { if (string.IsNullOrEmpty(managedItemId)) { @@ -39,10 +41,10 @@ public static ILog GetLogger(string managedItemId, Serilog.Core.LoggingLevelSwit if (_managedItemLoggers == null) { - _managedItemLoggers = new ConcurrentDictionary(); + _managedItemLoggers = new ConcurrentDictionary(); } - Serilog.Core.Logger log = _managedItemLoggers.GetOrAdd(managedItemId, (key) => + var log = _managedItemLoggers.GetOrAdd(managedItemId, (key) => { var logPath = GetLogPath(key); @@ -55,26 +57,40 @@ public static ILog GetLogger(string managedItemId, Serilog.Core.LoggingLevelSwit } catch { } - Serilog.Debugging.SelfLog.Enable(Console.Error); - - log = new LoggerConfiguration() - .MinimumLevel.ControlledBy(logLevelSwitch) -#if DEBUG - .WriteTo.Debug() -#endif + var serilogLog = new Serilog.LoggerConfiguration() + .Enrich.FromLogContext() + .MinimumLevel.ControlledBy(LogLevelSwitchFromLogLevel(logLevelSwitch)) .WriteTo.File( logPath, shared: true, flushToDiskInterval: new TimeSpan(0, 0, 10) ) .CreateLogger(); - return log; + return new Serilog.Extensions.Logging.SerilogLoggerFactory(serilogLog).CreateLogger(); + }); return new Loggy(log); } - public static void AppendLog(string managedItemId, ManagedCertificateLogItem logItem, Serilog.Core.LoggingLevelSwitch logLevelSwitch) + public static LoggingLevelSwitch LogLevelSwitchFromLogLevel(LogLevel level) + { + switch (level) + { + case LogLevel.Trace: + return new LoggingLevelSwitch(Serilog.Events.LogEventLevel.Debug); + case LogLevel.Debug: + return new LoggingLevelSwitch(Serilog.Events.LogEventLevel.Verbose); + case LogLevel.Warning: + return new LoggingLevelSwitch(Serilog.Events.LogEventLevel.Warning); + case LogLevel.Error: + return new LoggingLevelSwitch(Serilog.Events.LogEventLevel.Error); + default: + return new LoggingLevelSwitch(Serilog.Events.LogEventLevel.Information); + } + } + + public static void AppendLog(string managedItemId, ManagedCertificateLogItem logItem, LogLevel logLevelSwitch) { var log = GetLogger(managedItemId, logLevelSwitch); @@ -106,7 +122,10 @@ public static void DisposeLoggers() { foreach (var l in _managedItemLoggers.Values) { - l?.Dispose(); + if (l is IDisposable tmp) + { + tmp?.Dispose(); + } } } } diff --git a/src/Certify.Shared/Utils/PKI/OcspUtils.cs b/src/Certify.Shared/Utils/PKI/OcspUtils.cs index d47bba76f..afaa561c5 100644 --- a/src/Certify.Shared/Utils/PKI/OcspUtils.cs +++ b/src/Certify.Shared/Utils/PKI/OcspUtils.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; diff --git a/src/Certify.SourceGenerators/ApiMethods.cs b/src/Certify.SourceGenerators/ApiMethods.cs new file mode 100644 index 000000000..0ba2896f9 --- /dev/null +++ b/src/Certify.SourceGenerators/ApiMethods.cs @@ -0,0 +1,226 @@ +using System.Collections.Generic; +using SourceGenerator; + +namespace Certify.SourceGenerators +{ + internal class ApiMethods + { + public static List GetApiDefinitions() + { + // declaring an API definition here is then used by the source generators to: + // - create the public API endpoint + // - map the call from the public API to the background service API in the service API Client (interface and implementation) + // - to then generate the public API clients, run nswag when the public API is running. + + return new List { + + new GeneratedAPI { + + OperationName = "GetSecurityPrincipleAssignedRoles", + OperationMethod = "HttpGet", + Comment = "Get list of Assigned Roles for a given security principle", + PublicAPIController = "Access", + PublicAPIRoute = "securityprinciple/{id}/assignedroles", + ServiceAPIRoute = "access/securityprinciple/{id}/assignedroles", + ReturnType = "ICollection", + Params =new Dictionary{{"id","string"}} + }, + new GeneratedAPI { + + OperationName = "GetSecurityPrincipleRoleStatus", + OperationMethod = "HttpGet", + Comment = "Get list of Assigned Roles etc for a given security principle", + PublicAPIController = "Access", + PublicAPIRoute = "securityprinciple/{id}/rolestatus", + ServiceAPIRoute = "access/securityprinciple/{id}/rolestatus", + ReturnType = "RoleStatus", + Params =new Dictionary{{"id","string"}} + }, + new GeneratedAPI { + + OperationName = "GetAccessRoles", + OperationMethod = "HttpGet", + Comment = "Get list of available security Roles", + PublicAPIController = "Access", + PublicAPIRoute = "roles", + ServiceAPIRoute = "access/roles", + ReturnType = "ICollection" + }, + new GeneratedAPI { + + OperationName = "GetSecurityPrinciples", + OperationMethod = "HttpGet", + Comment = "Get list of available security principles", + PublicAPIController = "Access", + PublicAPIRoute = "securityprinciples", + ServiceAPIRoute = "access/securityprinciples", + ReturnType = "ICollection" + }, + new GeneratedAPI { + OperationName = "ValidateSecurityPrinciplePassword", + OperationMethod = "HttpPost", + Comment = "Check password valid for security principle", + PublicAPIController = "Access", + PublicAPIRoute = "validate", + ServiceAPIRoute = "access/validate", + ReturnType = "Certify.Models.API.SecurityPrincipleCheckResponse", + Params = new Dictionary{{"passwordCheck", "Certify.Models.API.SecurityPrinciplePasswordCheck" } } + }, + new GeneratedAPI { + + OperationName = "UpdateSecurityPrinciplePassword", + OperationMethod = "HttpPost", + Comment = "Update password for security principle", + PublicAPIController = "Access", + PublicAPIRoute = "updatepassword", + ServiceAPIRoute = "access/updatepassword", + ReturnType = "Models.Config.ActionResult", + Params = new Dictionary{{"passwordUpdate", "Certify.Models.API.SecurityPrinciplePasswordUpdate" } } + }, + new GeneratedAPI { + + OperationName = "AddSecurityPrinciple", + OperationMethod = "HttpPost", + Comment = "Add new security principle", + PublicAPIController = "Access", + PublicAPIRoute = "securityprinciple", + ServiceAPIRoute = "access/securityprinciple", + ReturnType = "Models.Config.ActionResult", + Params = new Dictionary{{"principle", "Certify.Models.Config.AccessControl.SecurityPrinciple" } } + }, + new GeneratedAPI { + + OperationName = "UpdateSecurityPrinciple", + OperationMethod = "HttpPost", + Comment = "Update existing security principle", + PublicAPIController = "Access", + PublicAPIRoute = "securityprinciple/update", + ServiceAPIRoute = "access/securityprinciple/update", + ReturnType = "Models.Config.ActionResult", + Params = new Dictionary{ + { "principle", "Certify.Models.Config.AccessControl.SecurityPrinciple" } + } + }, + new GeneratedAPI { + + OperationName = "UpdateSecurityPrincipleAssignedRoles", + OperationMethod = "HttpPost", + Comment = "Update assigned roles for a security principle", + PublicAPIController = "Access", + PublicAPIRoute = "securityprinciple/roles/update", + ServiceAPIRoute = "access/securityprinciple/roles/update", + ReturnType = "Models.Config.ActionResult", + Params = new Dictionary{ + { "update", "Certify.Models.Config.AccessControl.SecurityPrincipleAssignedRoleUpdate" } + } + }, + new GeneratedAPI { + + OperationName = "DeleteSecurityPrinciple", + OperationMethod = "HttpDelete", + Comment = "Delete security principle", + PublicAPIController = "Access", + PublicAPIRoute = "securityprinciple", + ServiceAPIRoute = "access/securityprinciple/{id}", + ReturnType = "Models.Config.ActionResult", + Params = new Dictionary{{"id","string"}} + }, + new GeneratedAPI { + + OperationName = "GetAcmeAccounts", + OperationMethod = "HttpGet", + Comment = "Get All Acme Accounts", + PublicAPIController = "CertificateAuthority", + PublicAPIRoute = "accounts", + ServiceAPIRoute = "accounts", + ReturnType = "ICollection" + }, + new GeneratedAPI { + + OperationName = "AddAcmeAccount", + OperationMethod = "HttpPost", + Comment = "Add New Acme Account", + PublicAPIController = "CertificateAuthority", + PublicAPIRoute = "account", + ServiceAPIRoute = "accounts", + ReturnType = "Models.Config.ActionResult", + Params =new Dictionary{{"registration", "Certify.Models.ContactRegistration" } } + }, + new GeneratedAPI { + + OperationName = "AddCertificateAuthority", + OperationMethod = "HttpPost", + Comment = "Add New Certificate Authority", + PublicAPIController = "CertificateAuthority", + PublicAPIRoute = "authority", + ServiceAPIRoute = "accounts/authorities", + ReturnType = "Models.Config.ActionResult", + Params =new Dictionary{{ "certificateAuthority", "Certify.Models.CertificateAuthority" } } + }, + new GeneratedAPI { + + OperationName = "RemoveManagedCertificate", + OperationMethod = "HttpDelete", + Comment = "Remove Managed Certificate", + PublicAPIController = "Certificate", + PublicAPIRoute = "settings/{instanceId}/{managedCertId}", + UseManagementAPI = true, + ServiceAPIRoute = "managedcertificates/delete/{managedCertId}", + ReturnType = "bool", + Params =new Dictionary{ { "instanceId", "string" },{ "managedCertId", "string" } } + }, + new GeneratedAPI { + + OperationName = "RemoveCertificateAuthority", + OperationMethod = "HttpDelete", + Comment = "Remove Certificate Authority", + PublicAPIController = "CertificateAuthority", + PublicAPIRoute = "authority/{id}", + ServiceAPIRoute = "accounts/authorities/{id}", + ReturnType = "Models.Config.ActionResult", + Params =new Dictionary{{ "id", "string" } } + }, + new GeneratedAPI { + OperationName = "RemoveAcmeAccount", + OperationMethod = "HttpDelete", + Comment = "Remove ACME Account", + PublicAPIController = "CertificateAuthority", + PublicAPIRoute = "accounts/{storageKey}/{deactivate}", + ServiceAPIRoute = "accounts/remove/{storageKey}/{deactivate}", + ReturnType = "Models.Config.ActionResult", + Params =new Dictionary{{ "storageKey", "string" }, { "deactivate", "bool" } } + }, + new GeneratedAPI { + OperationName = "RemoveStoredCredential", + OperationMethod = "HttpDelete", + Comment = "Remove Stored Credential", + PublicAPIController = "StoredCredential", + PublicAPIRoute = "storedcredential/{storageKey}", + ServiceAPIRoute = "credentials", + ReturnType = "Models.Config.ActionResult", + Params =new Dictionary{{ "storageKey", "string" } } + }, + new GeneratedAPI { + OperationName = "PerformExport", + OperationMethod = "HttpPost", + Comment = "Perform an export of all settings", + PublicAPIController = "System", + PublicAPIRoute = "system/migration/export", + ServiceAPIRoute = "system/migration/export", + ReturnType = "Models.Config.Migration.ImportExportPackage", + Params =new Dictionary{{ "exportRequest", "Certify.Models.Config.Migration.ExportRequest" } } + }, + new GeneratedAPI { + OperationName = "PerformImport", + OperationMethod = "HttpPost", + Comment = "Perform an import of all settings", + PublicAPIController = "System", + PublicAPIRoute = "system/migration/import", + ServiceAPIRoute = "system/migration/import", + ReturnType = "ICollection", + Params =new Dictionary{{ "importRequest", "Certify.Models.Config.Migration.ImportRequest" } } + } + }; + } + } +} diff --git a/src/Certify.SourceGenerators/Certify.SourceGenerators.csproj b/src/Certify.SourceGenerators/Certify.SourceGenerators.csproj new file mode 100644 index 000000000..b3ef958b5 --- /dev/null +++ b/src/Certify.SourceGenerators/Certify.SourceGenerators.csproj @@ -0,0 +1,12 @@ + + + + netstandard2.0 + true + + + + + + + diff --git a/src/Certify.SourceGenerators/PublicAPISourceGenerator.cs b/src/Certify.SourceGenerators/PublicAPISourceGenerator.cs new file mode 100644 index 000000000..44ab3f3b3 --- /dev/null +++ b/src/Certify.SourceGenerators/PublicAPISourceGenerator.cs @@ -0,0 +1,235 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using Certify.SourceGenerators; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SourceGenerator +{ + public class GeneratedAPI + { + public string OperationName { get; set; } = string.Empty; + public string OperationMethod { get; set; } = string.Empty; + public string Comment { get; set; } = string.Empty; + public string PublicAPIController { get; set; } = string.Empty; + + public string PublicAPIRoute { get; set; } = string.Empty; + public bool UseManagementAPI { get; set; } = false; + public string ServiceAPIRoute { get; set; } = string.Empty; + public string ReturnType { get; set; } = string.Empty; + public Dictionary Params { get; set; } = new Dictionary(); + } + [Generator] + public class PublicAPISourceGenerator : ISourceGenerator + { + public void Execute(GeneratorExecutionContext context) + { + + // get list of items we want to generate for our API glue + var list = ApiMethods.GetApiDefinitions(); + + Debug.WriteLine(context.Compilation.AssemblyName); + + foreach (var config in list) + { + var paramSet = config.Params.ToList(); + paramSet.Add(new KeyValuePair("authContext", "AuthContext")); + var apiParamDecl = paramSet.Any() ? string.Join(", ", paramSet.Select(p => $"{p.Value} {p.Key}")) : ""; + var apiParamDeclWithoutAuthContext = config.Params.Any() ? string.Join(", ", config.Params.Select(p => $"{p.Value} {p.Key}")) : ""; + + var apiParamCall = paramSet.Any() ? string.Join(", ", paramSet.Select(p => $"{p.Key}")) : ""; + var apiParamCallWithoutAuthContext = config.Params.Any() ? string.Join(", ", config.Params.Select(p => $"{p.Key}")) : ""; + + if (context.Compilation.AssemblyName.EndsWith("Api.Public")) + { + context.AddSource($"{config.PublicAPIController}Controller.{config.OperationName}.g.cs", SourceText.From($@" + +using Certify.Client; +using Certify.Server.Api.Public.Controllers; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Certify.Models; +using Certify.Models.Config.AccessControl; + + + namespace Certify.Server.Api.Public.Controllers + {{ + public partial class {config.PublicAPIController}Controller + {{ + /// + /// {config.Comment} [Generated by Certify.SourceGenerators] + /// + /// + [{config.OperationMethod}] + [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof({config.ReturnType}))] + [Route(""""""{config.PublicAPIRoute}"""""")] + public async Task {config.OperationName}({apiParamDeclWithoutAuthContext}) + {{ + var result = await {(config.UseManagementAPI ? "_mgmtAPI" : "_client")}.{config.OperationName}({apiParamCall.Replace("authContext", "CurrentAuthContext")}); + return new OkObjectResult(result); + }} + }} + }}", Encoding.UTF8)); + + } + + if (context.Compilation.AssemblyName.EndsWith("Certify.Client")) + { + + if (config.OperationMethod == "HttpGet") + { + context.AddSource($"{config.PublicAPIController}.{config.OperationName}.ICertifyInternalApiClient.g.cs", SourceText.From($@" +using Certify.Models; +using Certify.Models.Config.AccessControl; +using System.Collections.Generic; +using System.Threading.Tasks; + + namespace Certify.Client + {{ + public partial interface ICertifyInternalApiClient + {{ + /// + /// {config.Comment} [Generated by Certify.SourceGenerators] + /// + /// + Task<{config.ReturnType}> {config.OperationName}({apiParamDecl}); + + }} + + public partial class CertifyApiClient + {{ + /// + /// {config.Comment} [Generated by Certify.SourceGenerators] + /// + /// + public async Task<{config.ReturnType}> {config.OperationName}({apiParamDecl}) + {{ + var result = await FetchAsync($""{config.ServiceAPIRoute}"", authContext); + return JsonToObject<{config.ReturnType}>(result); + }} + + }} + }}", Encoding.UTF8)); + } + + if (config.OperationMethod == "HttpPost") + { + context.AddSource($"{config.PublicAPIController}.{config.OperationName}.ICertifyInternalApiClient.g.cs", SourceText.From($@" +using Certify.Models; +using Certify.Models.Config.AccessControl; +using System.Collections.Generic; +using System.Threading.Tasks; + + namespace Certify.Client + {{ + public partial interface ICertifyInternalApiClient + {{ + /// + /// {config.Comment} [Generated by Certify.SourceGenerators] + /// + /// + Task<{config.ReturnType}> {config.OperationName}({apiParamDecl}); + + }} + + + public partial class CertifyApiClient + {{ + /// + /// {config.Comment} [Generated by Certify.SourceGenerators] + /// + /// + public async Task<{config.ReturnType}> {config.OperationName}({apiParamDecl}) + {{ + var result = await PostAsync($""{config.ServiceAPIRoute}"", {apiParamCall}); + return JsonToObject<{config.ReturnType}>(await result.Content.ReadAsStringAsync()); + }} + + }} + }}", Encoding.UTF8)); + } + + if (config.OperationMethod == "HttpDelete") + { + context.AddSource($"{config.PublicAPIController}.{config.OperationName}.ICertifyInternalApiClient.g.cs", SourceText.From($@" +using Certify.Models; +using Certify.Models.Config.AccessControl; +using System.Collections.Generic; +using System.Threading.Tasks; + + namespace Certify.Client + {{ + public partial interface ICertifyInternalApiClient + {{ + /// + /// {config.Comment} [Generated by Certify.SourceGenerators] + /// + /// + Task<{config.ReturnType}> {config.OperationName}({apiParamDecl}); + + }} + + + public partial class CertifyApiClient + {{ + /// + /// {config.Comment} [Generated by Certify.SourceGenerators] + /// + /// + public async Task<{config.ReturnType}> {config.OperationName}({apiParamDecl}) + {{ + + var route = $""{config.ServiceAPIRoute}""; + + var result = await DeleteAsync(route, authContext); + return JsonToObject<{config.ReturnType}>(await result.Content.ReadAsStringAsync()); + + }} + + }} + }}", Encoding.UTF8)); + } + } + + if (context.Compilation.AssemblyName.EndsWith("Certify.UI.Blazor")) + { + context.AddSource($"AppModel.{config.OperationName}.g.cs", SourceText.From($@" +using System.Collections.Generic; +using System.Threading.Tasks; +using Certify.Models; +using Certify.Models.Config.AccessControl; + + namespace Certify.UI.Client.Core + {{ + public partial class AppModel + {{ + public async Task<{config.ReturnType}> {config.OperationName}({apiParamDeclWithoutAuthContext}) + {{ + return await _api.{config.OperationName}Async({apiParamCallWithoutAuthContext}); + }} + }} + }}", Encoding.UTF8)); + } + } + } + public void Initialize(GeneratorInitializationContext context) + { +#if DEBUG + // uncomment this to launch a debug session which code generation runs + // then add a watch on + if (!Debugger.IsAttached) + { + //Debugger.Launch(); + } +#endif + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/CertRequestTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/CertRequestTests.cs index 230c91ddc..facd4422e 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/CertRequestTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/CertRequestTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; @@ -11,10 +11,11 @@ using Certify.Models; using Certify.Providers.ACME.Anvil; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Serilog; namespace Certify.Core.Tests { + +#pragma warning disable CS0618 // Type or member is obsolete [TestClass] /// /// Integration tests for CertifyManager @@ -33,11 +34,7 @@ public class CertRequestTests : IntegrationTestBase, IDisposable public CertRequestTests() { - var log = new LoggerConfiguration() - .WriteTo.Debug() - .CreateLogger(); - _log = new Loggy(log); certifyManager = new CertifyManager(); certifyManager.Init().Wait(); @@ -81,6 +78,7 @@ public async Task TeardownIIS() { await iisManager.DeleteSite(testSiteName); Assert.IsFalse(await iisManager.SiteExists(testSiteName)); + certifyManager.Dispose(); } [TestMethod, TestCategory("MegaTest")] @@ -611,7 +609,7 @@ public async Task TestChallengeRequestDNSWildcard() } [TestMethod] - public async Task TestRequestTnAuthCSR() + public void TestRequestTnAuthCSR() { var pemKey = ConfigSettings["TestAuthTokenPrivateKey"]; @@ -687,37 +685,49 @@ public async Task TestRequestTnAuthList() var result = await certifyManager.PerformCertificateRequest(_log, dummyManagedCertificate); - //ensure cert request was successful - Assert.IsTrue(result.IsSuccess, "Certificate Request Not Completed"); + X509Certificate2 certInfo = null; - //check details of cert, subject alternative name should include domain and expiry must be great than 89 days in the future - var managedCertificates = await certifyManager.GetManagedCertificates(new ManagedCertificateFilter { Id = dummyManagedCertificate.Id }); - var managedCertificate = managedCertificates.FirstOrDefault(m => m.Id == dummyManagedCertificate.Id); + try + { + //ensure cert request was successful + Assert.IsTrue(result.IsSuccess, "Certificate Request Not Completed"); - //emsure we have a new managed site - Assert.IsNotNull(managedCertificate); + //check details of cert, subject alternative name should include domain and expiry must be great than 89 days in the future + var managedCertificates = await certifyManager.GetManagedCertificates(new ManagedCertificateFilter { Id = dummyManagedCertificate.Id }); + var managedCertificate = managedCertificates.FirstOrDefault(m => m.Id == dummyManagedCertificate.Id); - //have cert file details - Assert.IsNotNull(managedCertificate.CertificatePath); + //ensure we have a new managed site + Assert.IsNotNull(managedCertificate); - var fileExists = System.IO.File.Exists(managedCertificate.CertificatePath); - Assert.IsTrue(fileExists); + //have cert file details + Assert.IsNotNull(managedCertificate.CertificatePath); - //check cert is correct - var certInfo = CertificateManager.LoadCertificate(managedCertificate.CertificatePath); - Assert.IsNotNull(certInfo); + var fileExists = System.IO.File.Exists(managedCertificate.CertificatePath); + Assert.IsTrue(fileExists); - var isRecentlyCreated = Math.Abs((DateTimeOffset.UtcNow - certInfo.NotBefore).TotalDays) < 2; - Assert.IsTrue(isRecentlyCreated); + //check cert is correct + certInfo = CertificateManager.LoadCertificate(managedCertificate.CertificatePath); + Assert.IsNotNull(certInfo); - var expiresInFuture = (certInfo.NotAfter - DateTimeOffset.UtcNow).TotalDays >= 89; - Assert.IsTrue(expiresInFuture); + var isRecentlyCreated = Math.Abs((DateTimeOffset.UtcNow - certInfo.NotBefore).TotalDays) < 2; + Assert.IsTrue(isRecentlyCreated); - // remove managed site - await certifyManager.DeleteManagedCertificate(managedCertificate.Id); + var expiresInFuture = (certInfo.NotAfter - DateTimeOffset.UtcNow).TotalDays >= 89; + Assert.IsTrue(expiresInFuture); + } + finally + { - // cleanup certificate - CertificateManager.RemoveCertificate(certInfo); + // remove managed site + await certifyManager.DeleteManagedCertificate(dummyManagedCertificate.Id); + + // cleanup certificate + if (certInfo != null) + { + CertificateManager.RemoveCertificate(certInfo); + } + } } } +#pragma warning restore CS0618 // Type or member is obsolete } diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/Certify.Core.Tests.Integration.csproj b/src/Certify.Tests/Certify.Core.Tests.Integration/Certify.Core.Tests.Integration.csproj index ef9a8a5ca..92edb8c64 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/Certify.Core.Tests.Integration.csproj +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/Certify.Core.Tests.Integration.csproj @@ -1,7 +1,7 @@ - + - net7.0;net462; - Debug;Release;Debug;Release + net9.0;net462; + Debug;Release; Certify.Core.Tests Certify.Core.Tests @@ -13,7 +13,7 @@ DEBUG;TRACE prompt 4 - x64 + AnyCPU pdbonly @@ -23,24 +23,7 @@ prompt 4 - - true - bin\x64\Debug\ - DEBUG;TRACE - full - x64 - prompt - MinimumRecommendedRules.ruleset - - - bin\x64\Release\ - TRACE - true - pdbonly - x64 - prompt - MinimumRecommendedRules.ruleset - + Debug AnyCPU @@ -60,7 +43,19 @@ - x64 + AnyCPU + + + 1701;1702;NU1701 + + + 1701;1702;NU1701 + + + 1701;1702;NU1701 + + + 1701;1702;NU1701 @@ -76,22 +71,22 @@ - + - - - + + + - + - + diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/CertifyManagerServerTypeTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/CertifyManagerServerTypeTests.cs new file mode 100644 index 000000000..8fcc9840e --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/CertifyManagerServerTypeTests.cs @@ -0,0 +1,281 @@ +using System; +using System.Threading.Tasks; +using Certify.Management; +using Certify.Management.Servers; +using Certify.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Certify.Core.Tests +{ + [TestClass] + public class CertifyManagerServerTypeTests : IntegrationTestBase, IDisposable + { + private readonly CertifyManager _certifyManager; + private readonly ServerProviderIIS _iisManager; + private readonly string _testSiteName = "Test1ServerTypes"; + private readonly string _testSiteDomain = "integration1.anothertest.com"; + private readonly string _testSiteIp = "192.168.68.20"; + private readonly int _testSiteHttpPort = 80; + private string _testSiteId = ""; + + public CertifyManagerServerTypeTests() + { + // Must set IncludeExternalPlugins to true in C:\ProgramData\certify\appsettings.json and run copy-plugins.bat from certify-internal + _certifyManager = new CertifyManager(); + _certifyManager.Init().Wait(); + + _iisManager = new ServerProviderIIS(); + SetupIIS().Wait(); + } + + public void Dispose() => TeardownIIS().Wait(); + + public async Task SetupIIS() + { + if (await _iisManager.SiteExists(_testSiteName)) + { + await _iisManager.DeleteSite(_testSiteName); + } + + var site = await _iisManager.CreateSite(_testSiteName, _testSiteDomain, _primaryWebRoot, "DefaultAppPool", ipAddress: _testSiteIp, port: _testSiteHttpPort); + Assert.IsTrue(await _iisManager.SiteExists(_testSiteName)); + _testSiteId = site.Id.ToString(); + } + + public async Task TeardownIIS() + { + await _iisManager.DeleteSite(_testSiteName); + Assert.IsFalse(await _iisManager.SiteExists(_testSiteName)); + _certifyManager.Dispose(); + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetPrimaryWebSites() for IIS")] + public async Task TestCertifyManagerGetPrimaryWebSitesIIS() + { + // Request websites from CertifyManager.GetPrimaryWebSites() for IIS + var primaryWebsites = await _certifyManager.GetPrimaryWebSites(StandardServerTypes.IIS, true); + + // Evaluate return from CertifyManager.GetPrimaryWebSites() for IIS + Assert.IsNotNull(primaryWebsites, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to not be null"); + Assert.IsTrue(primaryWebsites.Count > 0, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to not be empty"); + Assert.IsTrue(primaryWebsites.Exists(s => s.IsEnabled), "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to have at least one enabled site"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetPrimaryWebSites() for Apache")] + [Ignore] + public async Task TestCertifyManagerGetPrimaryWebSitesApache() + { + // TODO: Support for Apache via plugin must be added + // This test requires at least one website in Apache to be active + var primaryWebsites = await _certifyManager.GetPrimaryWebSites(StandardServerTypes.Apache, true); + + // Evaluate return from CertifyManager.GetPrimaryWebSites() for Apache + Assert.IsNotNull(primaryWebsites, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for Apache sites to not be null"); + Assert.IsTrue(primaryWebsites.Count > 0, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for Apache sites to not be empty"); + Assert.IsTrue(primaryWebsites.Exists(s => s.IsEnabled), "Expected website list returned by CertifyManager.GetPrimaryWebSites() for Apache sites to have at least one enabled site"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetPrimaryWebSites() for Nginx")] + public async Task TestCertifyManagerGetPrimaryWebSitesNginx() + { + // This test requires at least one website in Nginx conf to be defined + var primaryWebsites = await _certifyManager.GetPrimaryWebSites(StandardServerTypes.Nginx, true); + + // Evaluate return from CertifyManager.GetPrimaryWebSites() for Nginx + Assert.IsNotNull(primaryWebsites, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for Nginx sites to not be null"); + Assert.IsTrue(primaryWebsites.Count > 0, "Expected website list returned by CertifyManager.GetPrimaryWebSites() to not be empty"); + Assert.IsTrue(primaryWebsites.Exists(s => s.IsEnabled), "Expected website list returned by CertifyManager.GetPrimaryWebSites() for Nginx sites to have at least one enabled site"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetPrimaryWebSites() for IIS using an item id")] + public async Task TestCertifyManagerGetPrimaryWebSitesItemId() + { + // Request website info from CertifyManager.GetPrimaryWebSites() for IIS using Item ID + var itemIdWebsite = await _certifyManager.GetPrimaryWebSites(StandardServerTypes.IIS, true, _testSiteId); + + // Evaluate return from CertifyManager.GetPrimaryWebSites() for IIS using item id + Assert.IsNotNull(itemIdWebsite, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to not be null"); + Assert.AreEqual(1, itemIdWebsite.Count, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to not be empty"); + Assert.AreEqual(_testSiteId, itemIdWebsite[0].Id, "Expected the same Item Id for SiteInfo objects returned by CertifyManager.GetPrimaryWebSites() for IIS sites"); + Assert.AreEqual(_testSiteName, itemIdWebsite[0].Name, "Expected the same Name for SiteInfo objects returned by CertifyManager.GetPrimaryWebSites() for IIS sites"); + } + + [TestMethod, Description("Test for using CertifyManager.GetPrimaryWebSites() for IIS using a bad item id")] + public async Task TestCertifyManagerGetPrimaryWebSitesBadItemId() + { + // Request website from CertifyManager.GetPrimaryWebSites() using a non-existent Item ID + var itemIdWebsite = await _certifyManager.GetPrimaryWebSites(StandardServerTypes.IIS, true, "bad_id"); + + // Evaluate return from CertifyManager.GetPrimaryWebSites() for IIS using a non-existent Item ID + Assert.IsNotNull(itemIdWebsite, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to not be null"); + Assert.AreEqual(1, itemIdWebsite.Count, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to not be empty"); + Assert.IsNull(itemIdWebsite[0], "Expected website list object returned by CertifyManager.GetPrimaryWebSites() for IIS with a bad itemId to be null"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetPrimaryWebSites() for IIS including stopped sites")] + public async Task TestCertifyManagerGetPrimaryWebSitesIncludeStoppedSites() + { + // This test requires at least one website in IIS that is stopped + var primaryWebsites = await _certifyManager.GetPrimaryWebSites(StandardServerTypes.IIS, false); + + // Evaluate return from CertifyManager.GetPrimaryWebSites() for IIS + Assert.IsNotNull(primaryWebsites, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to not be null"); + Assert.IsTrue(primaryWebsites.Count > 0, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to not be empty"); + Assert.IsTrue(primaryWebsites.Exists(s => s.IsEnabled), "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to have at least one enabled site"); + Assert.IsTrue(primaryWebsites.Exists(s => s.IsEnabled == false), "Expected website list returned by CertifyManager.GetPrimaryWebSites() for IIS sites to have at least one disabled site"); + } + + [TestMethod, Description("Test for using CertifyManager.GetPrimaryWebSites() when server type is not found")] + public async Task TestCertifyManagerGetPrimaryWebSitesServerTypeNotFound() + { + // Request websites from CertifyManager.GetPrimaryWebSites() using StandardServerTypes.Other + var primaryWebsites = await _certifyManager.GetPrimaryWebSites(StandardServerTypes.Other, true); + + // Evaluate return from CertifyManager.GetPrimaryWebSites() for StandardServerTypes.Other + Assert.IsNotNull(primaryWebsites, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for StandardServerTypes.Other to not be null"); + Assert.AreEqual(0, primaryWebsites.Count, "Expected website list returned by CertifyManager.GetPrimaryWebSites() for StandardServerTypes.Other to be empty"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetDomainOptionsFromSite() for IIS")] + public async Task TestCertifyManagerGetDomainOptionsFromSite() + { + // Request website Domain Options using Item ID + var siteDomainOptions = await _certifyManager.GetDomainOptionsFromSite(StandardServerTypes.IIS, _testSiteId); + + // Evaluate return from CertifyManager.GetDomainOptionsFromSite() for IIS + Assert.IsNotNull(siteDomainOptions, "Expected domain options list returned by CertifyManager.GetDomainOptionsFromSite() for IIS to not be null"); + Assert.AreEqual(1, siteDomainOptions.Count, "Expected domain options list returned by CertifyManager.GetDomainOptionsFromSite() for IIS to not be empty"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetDomainOptionsFromSite() for IIS site with no defined domain")] + public async Task TestCertifyManagerGetDomainOptionsFromSiteNoDomain() + { + // Verify no domain site does not exist from previous test run + var noDomainSiteName = "NoDomainSite"; + if (await _iisManager.SiteExists(noDomainSiteName)) + { + await _iisManager.DeleteSite(noDomainSiteName); + } + + // Add no domain site + var noDomainSite = await _iisManager.CreateSite(noDomainSiteName, "", _primaryWebRoot, "DefaultAppPool", port: 81); + Assert.IsTrue(await _iisManager.SiteExists(_testSiteName), "Expected no domain site to be created"); + var noDomainSiteId = noDomainSite.Id.ToString(); + + // Request website Domain Options using Item ID + var siteDomainOptions = await _certifyManager.GetDomainOptionsFromSite(StandardServerTypes.IIS, noDomainSiteId); + + // Evaluate return from CertifyManager.GetDomainOptionsFromSite() for IIS + Assert.IsNotNull(siteDomainOptions, "Expected domain options list returned by CertifyManager.GetDomainOptionsFromSite() for IIS to not be null"); + Assert.AreEqual(0, siteDomainOptions.Count, "Expected domain options list returned by CertifyManager.GetDomainOptionsFromSite() for IIS to be empty"); + + // Remove no domain site + await _iisManager.DeleteSite(noDomainSiteName); + Assert.IsFalse(await _iisManager.SiteExists(noDomainSiteName), "Expected no domain site to be deleted"); + } + + [TestMethod, Description("Test for using CertifyManager.GetDomainOptionsFromSite() when server type is not found")] + public async Task TestCertifyManagerGetDomainOptionsFromSiteServerTypeNotFound() + { + // Request website Domain Options for a non-initialized server type (StandardServerTypes.Other) + var siteDomainOptions = await _certifyManager.GetDomainOptionsFromSite(StandardServerTypes.Other, "1"); + + // Evaluate return from CertifyManager.GetDomainOptionsFromSite() for StandardServerTypes.Other + Assert.IsNotNull(siteDomainOptions, "Expected domain options list returned by CertifyManager.GetDomainOptionsFromSite() for StandardServerTypes.Other to not be null"); + Assert.AreEqual(0, siteDomainOptions.Count, "Expected domain options list returned by CertifyManager.GetDomainOptionsFromSite() for StandardServerTypes.Other to be empty"); + } + + [TestMethod, Description("Test for using CertifyManager.GetDomainOptionsFromSite() for IIS using a bad item id")] + public async Task TestCertifyManagerGetDomainOptionsFromSiteBadItemId() + { + // Request website Domain Options using a non-existent Item ID for IIS + var siteDomainOptions = await _certifyManager.GetDomainOptionsFromSite(StandardServerTypes.IIS, "bad_id"); + + // Evaluate return from CertifyManager.GetDomainOptionsFromSite() using a non-existent Item ID + Assert.IsNotNull(siteDomainOptions, "Expected domain options list returned by CertifyManager.GetDomainOptionsFromSite() to not be null"); + Assert.AreEqual(0, siteDomainOptions.Count, "Expected domain options list returned by CertifyManager.GetDomainOptionsFromSite() for a non-existent Item ID to be empty"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.IsServerTypeAvailable()")] + public async Task TestCertifyManagerIsServerTypeAvailable() + { + // This test requires at least one website in Nginx conf to be defined + var isIisAvailable = await _certifyManager.IsServerTypeAvailable(StandardServerTypes.IIS); + var isNginxAvailable = await _certifyManager.IsServerTypeAvailable(StandardServerTypes.Nginx); + var isApacheAvailable = await _certifyManager.IsServerTypeAvailable(StandardServerTypes.Apache); + var isOtherAvailable = await _certifyManager.IsServerTypeAvailable(StandardServerTypes.Other); + + // Evaluate returns from CertifyManager.IsServerTypeAvailable() + Assert.IsTrue(isIisAvailable, "Expected return from CertifyManager.IsServerTypeAvailable() to be true when at least one IIS site is active"); + + Assert.IsTrue(isNginxAvailable, "Expected return from CertifyManager.IsServerTypeAvailable() to be true when at least one Nginx site is active"); + + Assert.IsFalse(isApacheAvailable, "Expected return from CertifyManager.IsServerTypeAvailable() to be false when Apache plugin does not exist"); + // TODO: Support for Apache via plugin must be added to enable the next assert + //Assert.IsTrue(isApacheAvailable, "Expected return from CertifyManager.IsServerTypeAvailable() to be true when at least one Apache site is active"); + + Assert.IsFalse(isOtherAvailable, "Expected return from CertifyManager.IsServerTypeAvailable() to be false for StandardServerTypes.Other"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetServerTypeVersion()")] + public async Task TestCertifyManagerGetServerTypeVersion() + { + // This test requires at least one website in Nginx conf to be defined + var iisServerVersion = await _certifyManager.GetServerTypeVersion(StandardServerTypes.IIS); + var nginxServerVersion = await _certifyManager.GetServerTypeVersion(StandardServerTypes.Nginx); + var apacheServerVersion = await _certifyManager.GetServerTypeVersion(StandardServerTypes.Apache); + var otherServerVersion = await _certifyManager.GetServerTypeVersion(StandardServerTypes.Other); + + var unknownVersion = new Version(0, 0); + + // Evaluate returns from CertifyManager.GetServerTypeVersion() + Assert.AreNotEqual(unknownVersion, iisServerVersion, "Expected return from CertifyManager.GetServerTypeVersion() to be known when at least one IIS site is active"); + Assert.IsTrue(iisServerVersion.Major > 0); + + Assert.AreNotEqual(unknownVersion, nginxServerVersion, "Expected return from CertifyManager.GetServerTypeVersion() to be known when at least one Nginx site is active"); + Assert.IsTrue(nginxServerVersion.Major > 0); + + Assert.AreEqual(unknownVersion, apacheServerVersion, "Expected return from CertifyManager.GetServerTypeVersion() to be unknown when Apache plugin does not exist"); + // TODO: Support for Apache via plugin must be added to enable the next assert + //Assert.AreNotEqual(unknownVersion, isApacheAvailable, "Expected return from CertifyManager.GetServerTypeVersion() to be known when at least one Apache site is active"); + + Assert.AreEqual(unknownVersion, otherServerVersion, "Expected return from CertifyManager.GetServerTypeVersion() to be unknown for StandardServerTypes.Other"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.RunServerDiagnostics() for IIS")] + public async Task TestCertifyManagerRunServerDiagnostics() + { + // Run diagnostics on the IIS site using Item ID + var siteDiagnostics = await _certifyManager.RunServerDiagnostics(StandardServerTypes.IIS, _testSiteId); + + // Evaluate return from CertifyManager.GetPrimaryWebSites() for IIS + Assert.IsNotNull(siteDiagnostics, "Expected diagnostics list returned by CertifyManager.RunServerDiagnostics() for IIS site to not be null"); + Assert.AreEqual(1, siteDiagnostics.Count, "Expected diagnostics list returned by CertifyManager.RunServerDiagnostics() for IIS site to not be empty"); + } + + [TestMethod, Description("Test for using CertifyManager.RunServerDiagnostics() when server type is not found")] + public async Task TestCertifyManagerRunServerDiagnosticsServerTypeNotFound() + { + // Run diagnostics for a non-initialized server type (StandardServerTypes.Other) + var siteDiagnostics = await _certifyManager.RunServerDiagnostics(StandardServerTypes.Other, _testSiteId); + + // Evaluate return from CertifyManager.GetPrimaryWebSites() for StandardServerTypes.Other + Assert.IsNotNull(siteDiagnostics, "Expected diagnostics list returned by CertifyManager.RunServerDiagnostics() for StandardServerTypes.Other to not be null"); + Assert.AreEqual(0, siteDiagnostics.Count, "Expected diagnostics list returned by CertifyManager.RunServerDiagnostics() for StandardServerTypes.Other to be empty"); + } + + [TestMethod, Description("Test for using CertifyManager.RunServerDiagnostics() using a bad item id")] + public async Task TestCertifyManagerRunServerDiagnosticsBadItemId() + { + // Run diagnostics on the IIS site using bad Item ID + var siteDiagnostics = await _certifyManager.RunServerDiagnostics(StandardServerTypes.IIS, "bad_id"); + + // Evaluate return from CertifyManager.GetPrimaryWebSites() for IIS with bad Item ID + Assert.IsNotNull(siteDiagnostics, "Expected diagnostics list returned by CertifyManager.RunServerDiagnostics() for IIS site to not be null"); + + // Note: There seems to be no difference at the moment as to whether the Item ID passed in is valid or not, + // as RunServerDiagnostics() for IIS never uses the passed siteId string (is this intentional?) + Assert.AreEqual(1, siteDiagnostics.Count, "Expected diagnostics list returned by CertifyManager.RunServerDiagnostics() for IIS site to be empty"); + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/CertifyManagerTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/CertifyManagerTests.cs new file mode 100644 index 000000000..14f661f6c --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/CertifyManagerTests.cs @@ -0,0 +1,211 @@ +using System; +using System.Threading.Tasks; +using Certify.Management; +using Certify.Models; +using Certify.Models.Config.Migration; +using Certify.Service; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Certify.Core.Tests +{ + [TestClass] + public class CertifyManagerTests : IntegrationTestBase + { + private readonly CertifyManager _certifyManager; + + public CertifyManagerTests() + { + _certifyManager = new CertifyManager(); + _certifyManager.Init().Wait(); + } + + [TestCleanup] + public void Cleanup() + { + _certifyManager.Dispose(); + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetACMEProvider()")] + public async Task TestCertifyManagerGetACMEProvider() + { + // Setup account registration info + var testCaId = StandardCertAuthorities.LETS_ENCRYPT; + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = testCaId, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add new ACME account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + + // Setup dummy ManagedCertificate + var testUrl = "test.com"; + var dummyManagedCert = new ManagedCertificate { CurrentOrderUri = testUrl, UseStagingMode = true }; + + // Get expected certificate authority staging URI + var expectedAcmeBaseUri = CertificateAuthority.CoreCertificateAuthorities.Find((c) => c.Id == testCaId).StagingAPIEndpoint; + + try + { + // Get results from CertifyManager.GetACMEProvider() + var acmeClientProvider = await _certifyManager.GetACMEProvider(dummyManagedCert, accountDetails); + + // Validate return from CertifyManager.GetACMEProvider() + Assert.IsNotNull(acmeClientProvider, "Expected response from CertifyManager.GetACMEProvider() to not be null"); + Assert.AreEqual(expectedAcmeBaseUri, acmeClientProvider.GetAcmeBaseURI(), "Unexpected CA Base URI in returned value from acmeClientProvider.GetAcmeBaseURI()"); + Assert.AreEqual("Anvil", acmeClientProvider.GetProviderName(), "Unexpected Provider name in returned value from acmeClientProvider.GetProviderName()"); + await Assert.ThrowsExceptionAsync(async () => await acmeClientProvider.GetAcmeAccountStatus(), "Expected acmeClientProvider.GetAcmeAccountStatus() to throw NotImplementedException"); + Assert.IsNotNull(await acmeClientProvider.GetAcmeDirectory(), "Expected acmeClientProvider.GetAcmeDirectory() to return a non-null value"); + } + finally + { + // Remove created ACME account + var removeAccountRes = await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + Assert.IsTrue(removeAccountRes.IsSuccess, $"Expected account removal to be successful for {contactRegEmail}"); + } + } + + [TestMethod, Description("Test for using CertifyManager.GetACMEProvider() with a null ca account")] + public async Task TestCertifyManagerGetACMEProviderNullCaAccount() + { + // Setup test data + var testUrl = "test.com"; + var dummyManagedCert = new ManagedCertificate { CurrentOrderUri = testUrl, UseStagingMode = true }; + + // Get results from CertifyManager.GetACMEProvider() + var acmeClientProvider = await _certifyManager.GetACMEProvider(dummyManagedCert, null); + + // Validate return from CertifyManager.GetACMEProvider() with null ca account + Assert.IsNull(acmeClientProvider, "Expected response from CertifyManager.GetACMEProvider() to be null"); + } + + [TestMethod, Description("Test for using CertifyManager.GetACMEProvider() with an invalid ca account")] + public async Task TestCertifyManagerGetACMEProviderBadCaAccount() + { + // Setup test data + var testUrl = "test.com"; + var dummyManagedCert = new ManagedCertificate { CurrentOrderUri = testUrl, UseStagingMode = true }; + var account = new AccountDetails + { + AccountKey = "", + AccountURI = "", + Title = "Dev", + Email = "test@certifytheweb.com", + CertificateAuthorityId = "badca.com", + StorageKey = "dev", + IsStagingAccount = true, + }; + + // Get results from CertifyManager.GetACMEProvider() + var acmeClientProvider = await _certifyManager.GetACMEProvider(dummyManagedCert, account); + + // Validate return from CertifyManager.GetACMEProvider() with invalid ca account + Assert.IsNull(acmeClientProvider, "Expected response from CertifyManager.GetACMEProvider() to be null"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.ReportProgress()")] + public async Task TestCertifyManagerReportProgress() + { + // Setup test data + var testUrl = "test.com"; + var dummyManagedCert = new ManagedCertificate { CurrentOrderUri = testUrl, UseStagingMode = true }; + + var progressState = new RequestProgressState(RequestState.Running, "Starting..", dummyManagedCert); + var progressIndicator = new Progress(progressState.ProgressReport); + _certifyManager.SetStatusReporting(new StatusHubReporting()); + + // Set event handler for when Progress changes + var progressChanged = false; + var progressNewState = RequestState.Running; + progressIndicator.ProgressChanged += (obj, e) => + { + progressChanged = true; + progressNewState = e.CurrentState; + }; + + // Execute CertifyManager.ReportProgress() with new Warning state + _certifyManager.ReportProgress(progressIndicator, new RequestProgressState(RequestState.Warning, "Warning message", dummyManagedCert), logThisEvent: true); + await Task.Delay(100); + + // Validate events from CertifyManager.ReportProgress() + Assert.IsTrue(progressChanged, "Expected progressChanged to be true after CertifyManager.ReportProgress() completed"); + Assert.AreEqual(RequestState.Warning, progressNewState, "Expected progressNewState to be changed to RequestState.Warning"); + + // Execute CertifyManager.ReportProgress() with new Error state + progressChanged = false; + _certifyManager.ReportProgress(progressIndicator, new RequestProgressState(RequestState.Error, "Error message", dummyManagedCert), logThisEvent: true); + await Task.Delay(100); + + // Validate events from CertifyManager.ReportProgress() + Assert.IsTrue(progressChanged, "Expected progressChanged to be true after CertifyManager.ReportProgress() completed"); + Assert.AreEqual(RequestState.Error, progressNewState, "Expected progressNewState to be changed to RequestState.Error"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.PerformRenewalTasks()")] + public async Task TestCertifyManagerPerformRenewalTasks() + { + // Get results from CertifyManager.PerformRenewalTasks() + var renewalPerformed = await _certifyManager.PerformRenewalTasks(); + + // Validate return from CertifyManager.PerformRenewalTasks() + Assert.IsTrue(renewalPerformed, "Expected response from CertifyManager.PerformRenewalTasks() to be true"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.PerformExport() and CertifyManager.PerformImport()")] + public async Task TestCertifyManagerPerformExportAndImport() + { + // Setup export test data + var exportReq = new ExportRequest + { + Filter = new ManagedCertificateFilter { }, + Settings = new ExportSettings { ExportAllStoredCredentials = true, EncryptionSecret = "secret" }, + IsPreviewMode = false, + }; + + // Get results from CertifyManager.PerformExport() + var performExportRes = await _certifyManager.PerformExport(exportReq); + + // Validate return from CertifyManager.PerformExport() + Assert.IsNotNull(performExportRes, "Expected response from CertifyManager.PerformExport() to not be null"); + Assert.AreEqual(1, performExportRes.FormatVersion, "Expected FormatVersion of response from CertifyManager.PerformExport() to equal 1 by default"); + Assert.AreEqual("Certify The Web - Exported App Settings", performExportRes.Description, "Unexpected default Description in response from CertifyManager.PerformExport()"); + Assert.AreEqual(0, performExportRes.Errors.Count, "Unexpected Errors in response from CertifyManager.PerformExport()"); + Assert.AreEqual(Certify.Management.Util.GetAppVersion(), performExportRes.SystemVersion?.ToVersion(), "Unexpected SystemVersion in response from CertifyManager.PerformExport()"); + Assert.AreEqual(Environment.MachineName, performExportRes.SourceName, "Unexpected SourceName in response from CertifyManager.PerformExport()"); + Assert.AreEqual(DateTime.Now.Year, performExportRes.ExportDate.Year, "Unexpected ExportDate.Year in response from CertifyManager.PerformExport()"); + Assert.AreEqual(DateTime.Now.Day, performExportRes.ExportDate.Day, "Unexpected ExportDate.Year in response from CertifyManager.PerformExport()"); + Assert.AreEqual(DateTime.Now.Month, performExportRes.ExportDate.Month, "Unexpected ExportDate.Year in response from CertifyManager.PerformExport()"); + + // Setup import test data + var importReq = new ImportRequest + { + Package = performExportRes, + Settings = new ImportSettings { EncryptionSecret = "secret" }, + IsPreviewMode = false, + }; + + // Get results from CertifyManager.PerformImport() + var performImportRes = await _certifyManager.PerformImport(importReq); + + // Validate return from CertifyManager.PerformImport() + Assert.IsNotNull(performImportRes, "Expected response from CertifyManager.PerformImport() to not be null"); + Assert.IsTrue(0 < performImportRes.Count, "Expected response from CertifyManager.PerformImport() to not be an empty list"); + foreach (var step in performImportRes) + { + Assert.AreEqual("Import", step.Category, $"Unexpected Category value in step '{step.Title}' from response of CertifyManager.PerformImport()"); + Assert.IsTrue(!string.IsNullOrEmpty(step.Title), $"Unexpected Title value in step '{step.Title}' from response of CertifyManager.PerformImport()"); + Assert.IsTrue(!string.IsNullOrEmpty(step.Key), $"Unexpected Key value in step '{step.Title}' from response of CertifyManager.PerformImport()"); + Assert.IsFalse(step.HasError, $"Unexpected HasError value in step '{step.Title}' from response of CertifyManager.PerformImport()"); + Assert.IsFalse(step.HasWarning, $"Unexpected HasWarning value in step '{step.Title}' from response of CertifyManager.PerformImport()"); + } + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.AWSRoute53.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.AWSRoute53.cs index 2513984b3..796867e5a 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.AWSRoute53.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.AWSRoute53.cs @@ -1,11 +1,11 @@ using System.Collections.Generic; using System.Diagnostics; using System.Threading.Tasks; -using Certify.Management; +using Certify.Datastore.SQLite; using Certify.Models.Providers; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Certify.Core.Tests +namespace Certify.Core.Tests.DNS { [TestClass] public class DnsAPITestAWSRoute53 : IntegrationTestBase @@ -40,7 +40,7 @@ public async Task TestCreateRecord() Assert.IsTrue(createResult.IsSuccess); stopwatch.Stop(); - System.Diagnostics.Debug.WriteLine($"Create DNS Record {createRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); + Debug.WriteLine($"Create DNS Record {createRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); return createRequest; } @@ -62,7 +62,7 @@ public async Task TestCreateRecords() // also create a duplicate var record2 = await TestCreateRecord(); - System.Diagnostics.Debug.WriteLine($"Cloudflare DNS should now have record {record1.RecordName} with values {record1.RecordValue} and {record2.RecordValue}"); + Debug.WriteLine($"Cloudflare DNS should now have record {record1.RecordName} with values {record1.RecordValue} and {record2.RecordValue}"); } [TestMethod, TestCategory("DNS")] @@ -80,7 +80,7 @@ public async Task TestDeleteRecord() var deleteResult = await _provider.DeleteRecord(deleteRequest); Assert.IsTrue(deleteResult.IsSuccess); - System.Diagnostics.Debug.WriteLine($"Delete DNS Record {deleteRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); + Debug.WriteLine($"Delete DNS Record {deleteRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); } [TestMethod, TestCategory("DNS")] diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.Azure.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.Azure.cs index bb2ed8933..733936dc1 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.Azure.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.Azure.cs @@ -1,11 +1,11 @@ using System.Collections.Generic; using System.Diagnostics; using System.Threading.Tasks; -using Certify.Management; +using Certify.Datastore.SQLite; using Certify.Models.Providers; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Certify.Core.Tests +namespace Certify.Core.Tests.DNS { [TestClass] [Ignore("Requires credential setup")] @@ -41,7 +41,7 @@ private async Task TestCreateRecord() Assert.IsTrue(createResult.IsSuccess); stopwatch.Stop(); - System.Diagnostics.Debug.WriteLine($"Create DNS Record {createRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); + Debug.WriteLine($"Create DNS Record {createRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); return createRequest; } @@ -63,7 +63,7 @@ public async Task TestCreateRecords() // also create a duplicate var record2 = await TestCreateRecord(); - System.Diagnostics.Debug.WriteLine($"Azure DNS should now have record {record1.RecordName} with values {record1.RecordValue} and {record2.RecordValue}"); + Debug.WriteLine($"Azure DNS should now have record {record1.RecordName} with values {record1.RecordValue} and {record2.RecordValue}"); } [TestMethod, TestCategory("DNS")] @@ -81,7 +81,7 @@ public async Task TestDeleteRecord() var deleteResult = await _provider.DeleteRecord(deleteRequest); Assert.IsTrue(deleteResult.IsSuccess); - System.Diagnostics.Debug.WriteLine($"Delete DNS Record {deleteRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); + Debug.WriteLine($"Delete DNS Record {deleteRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); } } } diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.Cloudflare.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.Cloudflare.cs index 3657e7f57..906999019 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.Cloudflare.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/DNS/DnsAPITest.Cloudflare.cs @@ -1,11 +1,11 @@ using System.Collections.Generic; using System.Diagnostics; using System.Threading.Tasks; -using Certify.Management; +using Certify.Datastore.SQLite; using Certify.Models.Providers; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Certify.Core.Tests +namespace Certify.Core.Tests.DNS { [TestClass] public class DnsAPITestCloudflare : IntegrationTestBase @@ -33,7 +33,7 @@ public async Task TestCreateRecord() Assert.IsTrue(createResult.IsSuccess); stopwatch.Stop(); - System.Diagnostics.Debug.WriteLine($"Create DNS Record {createRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); + Debug.WriteLine($"Create DNS Record {createRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); return createRequest; } @@ -62,7 +62,7 @@ public async Task TestCreateRecords() // also create a duplicate var record2 = await TestCreateRecord(); - System.Diagnostics.Debug.WriteLine($"Cloudflare DNS should now have record {record1.RecordName} with values {record1.RecordValue} and {record2.RecordValue}"); + Debug.WriteLine($"Cloudflare DNS should now have record {record1.RecordName} with values {record1.RecordValue} and {record2.RecordValue}"); } [TestMethod, TestCategory("DNS")] @@ -80,7 +80,7 @@ public async Task TestDeleteRecord() var deleteResult = await _provider.DeleteRecord(deleteRequest); Assert.IsTrue(deleteResult.IsSuccess); - System.Diagnostics.Debug.WriteLine($"Delete DNS Record {deleteRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); + Debug.WriteLine($"Delete DNS Record {deleteRequest.RecordName} took {stopwatch.Elapsed.TotalSeconds} seconds"); } } } diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/DataStores/AccessControlDataStoreTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/DataStores/AccessControlDataStoreTests.cs new file mode 100644 index 000000000..ac1261907 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/DataStores/AccessControlDataStoreTests.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Certify.Core.Management.Access; +using Certify.Datastore.SQLite; +using Certify.Models.Config.AccessControl; +using Certify.Providers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Certify.Core.Tests.DataStores +{ + [TestClass] + public class AccessControlDataStoreTests + { + private string _storeType = "sqlite"; + private const string TEST_PATH = "Tests"; + + public static IEnumerable TestDataStores + { + get + { + return new[] + { + new object[] { "sqlite" }, + //new object[] { "postgres" }, + //new object[] { "sqlserver" } + }; + } + } + + private IAccessControlStore GetStore(string storeType = null) + { + IAccessControlStore store = null; + + if (storeType == null) + { + storeType = _storeType; + } + + if (storeType == "sqlite") + { + store = new SQLiteAccessControlStore(storageSubfolder: TEST_PATH); + } + /* else if (storeType == "postgres") + { + return new PostgresCredentialStore(Environment.GetEnvironmentVariable("CERTIFY_TEST_POSTGRES")); + } + else if (storeType == "sqlserver") + { + return new SQLServerCredentialStore(Environment.GetEnvironmentVariable("CERTIFY_TEST_SQLSERVER")); + }*/ + else + { + throw new ArgumentOutOfRangeException(nameof(storeType), "Unsupported store type " + storeType); + } + + return store; + } + + [TestMethod] + [DynamicData(nameof(TestDataStores))] + public async Task TestStoreSecurityPrinciple(string storeType) + { + var store = GetStore(storeType ?? _storeType); + + var sp = new SecurityPrinciple + { + Email = "test@test.com", + PrincipleType = SecurityPrincipleType.User, + Username = "test", + Provider = StandardIdentityProviders.INTERNAL + }; + + try + { + await store.Add(nameof(SecurityPrinciple), sp); + + var list = await store.GetItems(nameof(SecurityPrinciple)); + + Assert.IsTrue(list.Any(l => l.Id == sp.Id), "Security Principle retrieved"); + } + finally + { + // cleanup + await store.Delete(nameof(SecurityPrinciple), sp.Id); + } + } + + [TestMethod] + [DynamicData(nameof(TestDataStores))] + public async Task TestStoreRole(string storeType) + { + var store = GetStore(storeType ?? _storeType); + + var role1 = new Role("test", "Test Role", "A test role"); + var role2 = new Role("test2", "Test Role 2", "A test role 2"); + + try + { + await store.Add(nameof(Role), role1); + await store.Add(nameof(Role), role2); + + var item = await store.Get(nameof(Role), role1.Id); + + Assert.IsTrue(item.Id == role1.Id, "Role retrieved"); + } + finally + { + // cleanup + await store.Delete(nameof(Role), role1.Id); + await store.Delete(nameof(Role), role2.Id); + } + } + + [TestMethod] + public void TestStorePasswordHashing() + { + var store = GetStore(_storeType); + var access = new AccessControl(null, store); + + var firstHash = access.HashPassword("secret"); + + Assert.IsNotNull(firstHash); + + Assert.IsTrue(access.IsPasswordValid("secret", firstHash)); + } + + [TestMethod] + [DynamicData(nameof(TestDataStores))] + public async Task TestStoreGeneralAccessControl(string storeType) + { + + var store = GetStore(storeType ?? _storeType); + + var access = new AccessControl(null, store); + + var adminSp = new SecurityPrinciple + { + Id = "admin_01", + Email = "admin@test.com", + Description = "Primary test admin", + PrincipleType = SecurityPrincipleType.User, + Username = "admin01", + Provider = StandardIdentityProviders.INTERNAL + }; + + var consumerSp = new SecurityPrinciple + { + Id = "dev_01", + Email = "dev_test01@test.com", + Description = "Consumer test", + PrincipleType = SecurityPrincipleType.User, + Username = "dev01", + Password = "oldpassword", + Provider = StandardIdentityProviders.INTERNAL + }; + + try + { + var list = await access.GetSecurityPrinciples(adminSp.Id); + + // add first admin security principle, bypass role check as there is no user to check yet + + await access.AddSecurityPrinciple(adminSp.Id, adminSp, bypassIntegrityCheck: true); + + await access.AddAssignedRole(new AssignedRole { Id = new Guid().ToString(), SecurityPrincipleId = adminSp.Id, RoleId = StandardRoles.Administrator.Id }); + + // add second security principle, bypass role check as this is just a data store test + var added = await access.AddSecurityPrinciple(adminSp.Id, consumerSp, bypassIntegrityCheck: true); + + Assert.IsTrue(added, "Should be able to add a security principle"); + + list = await access.GetSecurityPrinciples(adminSp.Id); + + Assert.IsTrue(list.Any(), "Should have security principles in store"); + + // get updated sp so that password is hashed for comparison check + consumerSp = await access.GetSecurityPrinciple(adminSp.Id, consumerSp.Id); + + Assert.IsTrue(access.IsPasswordValid("oldpassword", consumerSp.Password)); + } + finally + { + await access.DeleteSecurityPrinciple(adminSp.Id, consumerSp.Id); + await access.DeleteSecurityPrinciple(adminSp.Id, adminSp.Id, allowSelfDelete: true); + } + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/ManagedItemDataStoreTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/DataStores/ManagedItemDataStoreTests.cs similarity index 88% rename from src/Certify.Tests/Certify.Core.Tests.Integration/ManagedItemDataStoreTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Integration/DataStores/ManagedItemDataStoreTests.cs index 2bde078e2..39ed09e60 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/ManagedItemDataStoreTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/DataStores/ManagedItemDataStoreTests.cs @@ -11,7 +11,7 @@ using Certify.Providers; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Certify.Core.Tests +namespace Certify.Core.Tests.DataStores { [TestClass] public class ManagedItemDataStoreTests @@ -46,7 +46,7 @@ private IManagedItemStore GetManagedItemStore(string storeType = null) if (storeType == "sqlite") { - return new SQLiteManagedItemStore(TEST_PATH, highPerformanceMode: true); + return new SQLiteManagedItemStore(TEST_PATH); } else if (storeType == "postgres") { @@ -58,7 +58,7 @@ private IManagedItemStore GetManagedItemStore(string storeType = null) } else { - throw new ArgumentOutOfRangeException(nameof(storeType), "Unsupport store type " + storeType); + throw new ArgumentOutOfRangeException(nameof(storeType), "Unsupported store type " + storeType); } } @@ -101,9 +101,13 @@ public async Task TestLoadManagedCertificates(string storeType = null) try { var managedCertificate = await itemManager.Update(testCert); + var filter = new ManagedCertificateFilter { MaxResults = 10 }; + var managedCertificates = await itemManager.Find(filter); - var managedCertificates = await itemManager.Find(new ManagedCertificateFilter { MaxResults = 10 }); Assert.IsTrue(managedCertificates.Count > 0); + + var total = await itemManager.CountAll(filter); + Assert.IsTrue(total > 0); } finally { @@ -342,11 +346,12 @@ public async Task TestManagedCertificateFilters(string storeType = null) newTestItem.Name = "FilterMultiTest_" + i; newTestItem.Id = Guid.NewGuid().ToString(); newTestItem.RequestConfig.PrimaryDomain = i + "_" + testItem.RequestConfig.PrimaryDomain; - newTestItem.DateExpiry = DateTimeOffset.UtcNow.AddDays(new Random().Next(5, 90)); - newTestItem.DateStart = DateTimeOffset.UtcNow.AddDays(-new Random().Next(1, 30)); - newTestItem.DateLastOcspCheck = DateTimeOffset.UtcNow.AddMinutes(-new Random().Next(1, 60)); - newTestItem.DateLastRenewalInfoCheck = DateTimeOffset.UtcNow.AddMinutes(-new Random().Next(1, 30)); - newTestItem.DateRenewed = DateTimeOffset.UtcNow.AddDays(-new Random().Next(1, 30)); + newTestItem.DateExpiry = DateTimeOffset.UtcNow.AddDays(rnd.Next(5, 90)); + newTestItem.DateStart = newTestItem.DateExpiry.Value.AddDays(-rnd.Next(1, 30)); + newTestItem.DateLastOcspCheck = DateTimeOffset.UtcNow.AddMinutes(-rnd.Next(1, 60)); + newTestItem.DateLastRenewalInfoCheck = DateTimeOffset.UtcNow.AddMinutes(-rnd.Next(1, 30)); + newTestItem.DateRenewed = newTestItem.DateStart; + newTestItem.DateLastRenewalAttempt = newTestItem.DateRenewed; if (rnd.Next(0, 10) >= 8) { @@ -365,11 +370,12 @@ public async Task TestManagedCertificateFilters(string storeType = null) newTestItem.Name = "ExtraMultiTest_" + i; newTestItem.Id = Guid.NewGuid().ToString(); newTestItem.RequestConfig.PrimaryDomain = i + "_" + testItem.RequestConfig.PrimaryDomain; - newTestItem.DateExpiry = DateTimeOffset.UtcNow.AddDays(new Random().Next(5, 90)); - newTestItem.DateStart = DateTimeOffset.UtcNow.AddDays(-new Random().Next(1, 30)); - newTestItem.DateLastOcspCheck = DateTimeOffset.UtcNow.AddMinutes(-new Random().Next(1, 30)); - newTestItem.DateLastRenewalInfoCheck = DateTimeOffset.UtcNow.AddMinutes(-new Random().Next(1, 30)); - newTestItem.DateRenewed = DateTimeOffset.UtcNow.AddDays(-new Random().Next(1, 30)); + newTestItem.DateExpiry = DateTimeOffset.UtcNow.AddDays(rnd.Next(5, 90)); + newTestItem.DateStart = DateTimeOffset.UtcNow.AddDays(-rnd.Next(1, 30)); + newTestItem.DateLastOcspCheck = DateTimeOffset.UtcNow.AddMinutes(-rnd.Next(1, 30)); + newTestItem.DateLastRenewalInfoCheck = DateTimeOffset.UtcNow.AddMinutes(-rnd.Next(1, 30)); + newTestItem.DateRenewed = DateTimeOffset.UtcNow.AddDays(-rnd.Next(1, 30)); + newTestItem.DateLastRenewalAttempt = newTestItem.DateRenewed; inMemoryList.Add(newTestItem); } @@ -423,6 +429,7 @@ public async Task TestManagedCertificateFilters(string storeType = null) new ManagedCertificateFilter { Keyword = "FilterMultiTest_", PageIndex=0, PageSize =5, FilterDescription="Paging test 0" }, new ManagedCertificateFilter { Keyword = "FilterMultiTest_", PageIndex=1, PageSize =5, FilterDescription="Paging test 1" }, new ManagedCertificateFilter { Keyword = "FilterMultiTest_", PageIndex=2, PageSize =5, FilterDescription="Paging test 3" }, + new ManagedCertificateFilter { Keyword = "FilterMultiTest_", PageIndex=2, PageSize =5, FilterDescription="Paging test 4 with sorting by renewal date", OrderBy= ManagedCertificateFilter.SortMode.RENEWAL_ASC }, new ManagedCertificateFilter { Keyword = "FilterMultiTest_", ChallengeType ="http-01", FilterDescription="Challenge type filter"}, new ManagedCertificateFilter { Keyword = "FilterMultiTest_", ChallengeProvider ="A.Test.Provider", FilterDescription="Challenge provider filter"}, new ManagedCertificateFilter { Keyword = "FilterMultiTest_", StoredCredentialKey ="ABCD123", FilterDescription="Stored Credential filter"} @@ -443,9 +450,21 @@ public async Task TestManagedCertificateFilters(string storeType = null) && (filter.ChallengeType == null || i.RequestConfig.Challenges.Any(c => c.ChallengeType == filter.ChallengeType)) && (filter.ChallengeProvider == null || i.RequestConfig.Challenges.Any(c => c.ChallengeProvider == filter.ChallengeProvider)) && (filter.StoredCredentialKey == null || i.RequestConfig.Challenges.Any(c => c.ChallengeCredentialKey == filter.StoredCredentialKey)) - ) - .OrderBy(t => t.Name) - .AsQueryable(); + ).AsQueryable(); + + if (filter.OrderBy == ManagedCertificateFilter.SortMode.NAME_ASC) + { + expectedResult = expectedResult + .OrderBy(t => t.Name) + .AsQueryable(); + } + + if (filter.OrderBy == ManagedCertificateFilter.SortMode.RENEWAL_ASC) + { + expectedResult = expectedResult + .OrderBy(t => t.DateLastRenewalAttempt) + .AsQueryable(); + } if (filter.PageIndex != null && filter.PageSize != null) { @@ -467,8 +486,17 @@ public async Task TestManagedCertificateFilters(string storeType = null) Assert.AreEqual(expectedResult.Count(), testResult.Count, filter.FilterDescription); - Assert.IsTrue(expectedResult.First().Id == testResult.First().Id, $"{filter.FilterDescription} Test and expected should return same first items"); - Assert.IsTrue(expectedResult.Last().Id == testResult.Last().Id, $"{filter.FilterDescription} Test and expected should return same last items"); + if (filter.OrderBy == ManagedCertificateFilter.SortMode.NAME_ASC) + { + Assert.IsTrue(expectedResult.First().Id == testResult.First().Id, $"{filter.FilterDescription} Test and expected should return same first items"); + Assert.IsTrue(expectedResult.Last().Id == testResult.Last().Id, $"{filter.FilterDescription} Test and expected should return same last items"); + } + + if (filter.OrderBy == ManagedCertificateFilter.SortMode.RENEWAL_ASC) + { + Assert.IsTrue(expectedResult.First().Id == testResult.First().Id, $"{filter.FilterDescription} Test and expected should return same first items"); + Assert.IsTrue(expectedResult.Last().Id == testResult.Last().Id, $"{filter.FilterDescription} Test and expected should return same last items"); + } } } finally diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/StoredCredentialsDataStoreTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/DataStores/StoredCredentialsDataStoreTests.cs similarity index 97% rename from src/Certify.Tests/Certify.Core.Tests.Integration/StoredCredentialsDataStoreTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Integration/DataStores/StoredCredentialsDataStoreTests.cs index 4915a7e3d..a59b53f7a 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/StoredCredentialsDataStoreTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/DataStores/StoredCredentialsDataStoreTests.cs @@ -3,18 +3,19 @@ using System.Linq; using System.Threading.Tasks; using Certify.Datastore.Postgres; +using Certify.Datastore.SQLite; using Certify.Datastore.SQLServer; using Certify.Management; using Certify.Models.Config; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Certify.Core.Tests +namespace Certify.Core.Tests.DataStores { [TestClass] public class StoredCredentialsDataStoreTests { private string _storeType = "postgres"; - private const string TEST_PATH = "Tests\\credentials"; + private const string TEST_PATH = "Tests"; public static IEnumerable TestDataStores { @@ -50,7 +51,7 @@ private ICredentialsManager GetCredentialManager(string storeType = null) } else { - throw new ArgumentOutOfRangeException(nameof(storeType), "Unsupport store type " + storeType); + throw new ArgumentOutOfRangeException(nameof(storeType), "Unsupported store type " + storeType); } } diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/DeploymentPreviewTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/DeploymentPreviewTests.cs index 038c76688..00750073a 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/DeploymentPreviewTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/DeploymentPreviewTests.cs @@ -9,7 +9,6 @@ using Certify.Management.Servers; using Certify.Models; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Serilog; namespace Certify.Core.Tests { @@ -27,11 +26,7 @@ public class DeploymentPreviewTests : IntegrationTestBase, IDisposable public DeploymentPreviewTests() { - var log = new LoggerConfiguration() - .WriteTo.Debug() - .CreateLogger(); - _log = new Loggy(log); certifyManager = new CertifyManager(); certifyManager.Init().Wait(); iisManager = new ServerProviderIIS(); @@ -74,6 +69,7 @@ public async Task TeardownIIS() { await iisManager.DeleteSite(testSiteName); Assert.IsFalse(await iisManager.SiteExists(testSiteName)); + certifyManager.Dispose(); } [TestMethod] diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/DeploymentTaskTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/DeploymentTaskTests.cs index 3af000cd7..e9850d15a 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/DeploymentTaskTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/DeploymentTaskTests.cs @@ -8,7 +8,6 @@ using Certify.Models; using Certify.Models.Config; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Serilog; namespace Certify.Core.Tests { @@ -23,12 +22,6 @@ public class DeploymentTaskTests : IntegrationTestBase public DeploymentTaskTests() { - var log = new LoggerConfiguration() - .WriteTo.Debug() - .CreateLogger(); - - _log = new Loggy(log); - certifyManager = new CertifyManager(); certifyManager.Init().Wait(); @@ -36,6 +29,12 @@ public DeploymentTaskTests() PrimaryTestDomain = ConfigSettings["Cloudflare_TestDomain"]; } + [TestCleanup] + public void Cleanup() + { + certifyManager?.Dispose(); + } + private DeploymentTaskConfig GetMockTaskConfig( string name, string msg = "Hello World", diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/IntegrationTestBase.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/IntegrationTestBase.cs index 2008ace16..3234d9ae9 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/IntegrationTestBase.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/IntegrationTestBase.cs @@ -4,8 +4,8 @@ using System.IO; using Certify.Models; using Certify.Models.Providers; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; -using Serilog; namespace Certify.Core.Tests { @@ -18,9 +18,9 @@ public class IntegrationTestBase public IntegrationTestBase() { - if (Environment.GetEnvironmentVariable("CERTIFYSSLDOMAIN") != null) + if (Environment.GetEnvironmentVariable("CERTIFY_TESTDOMAIN") != null) { - PrimaryTestDomain = Environment.GetEnvironmentVariable("CERTIFYSSLDOMAIN"); + PrimaryTestDomain = Environment.GetEnvironmentVariable("CERTIFY_TESTDOMAIN"); } /* ConfigSettings.Add("AWS_ZoneId", "example"); @@ -31,12 +31,7 @@ public IntegrationTestBase() ConfigSettings = JsonConvert.DeserializeObject>(System.IO.File.ReadAllText("C:\\temp\\Certify\\TestConfigSettings.json")); - var logImp = new LoggerConfiguration() - .WriteTo.Debug() - .CreateLogger(); - - _log = new Loggy(logImp); - + _log = new Loggy(LoggerFactory.Create(builder => builder.AddDebug()).CreateLogger()); } public ManagedCertificate GetMockManagedCertificate(string siteName, string testDomain, string siteId = null, string testPath = null) diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/MigrationManagerTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/MigrationManagerTests.cs index 47e115dbc..e96117505 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/MigrationManagerTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/MigrationManagerTests.cs @@ -1,10 +1,10 @@ using System.Collections.Generic; using System.Threading.Tasks; -using Certify.Config.Migration; using Certify.Core.Management; using Certify.Datastore.SQLite; using Certify.Management; using Certify.Models; +using Certify.Models.Config.Migration; using Certify.Providers; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -18,14 +18,14 @@ private IManagedItemStore GetManagedItemStore() { var itemManager = new SQLiteManagedItemStore(); - itemManager.Init("", null); + itemManager.Init(string.Empty, null); return itemManager; } private ICredentialsManager GetCredentialsStore() { var itemManager = new SQLiteCredentialStore(); - itemManager.Init("", useWindowsNativeFeatures: true, null); + itemManager.Init(string.Empty, null); return itemManager; } diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/RdapTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/RdapTests.cs index 743806cf8..62292dba2 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/RdapTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/RdapTests.cs @@ -8,12 +8,6 @@ namespace Certify.Core.Tests [TestClass] public class RdapTests { - - public RdapTests() - { - - } - [TestMethod, Description("Test Rdap Query")] [DataTestMethod] [DataRow("example.com", "OK", null)] diff --git a/src/Certify.Tests/Certify.Core.Tests.Integration/ServerManagers/IISManagerTests.cs b/src/Certify.Tests/Certify.Core.Tests.Integration/ServerManagers/IISManagerTests.cs index 0e7679732..6dc42066b 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Integration/ServerManagers/IISManagerTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Integration/ServerManagers/IISManagerTests.cs @@ -69,6 +69,13 @@ public async Task TestIISVersionCheck() Assert.IsTrue(version.Major >= 7); } + [TestMethod] + public async Task TestIISIsAvailable() + { + var isAvailable = await iisManager.IsAvailable(); + Assert.IsTrue(isAvailable); + } + [TestMethod] public async Task TestIISSiteRunning() { diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/.dockerignore b/src/Certify.Tests/Certify.Core.Tests.Unit/.dockerignore new file mode 100644 index 000000000..3aae53927 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/.dockerignore @@ -0,0 +1,32 @@ +# Include any files or directories that you don't want to be copied to your +# container here (e.g., local build artifacts, temporary files, etc.). +# +# For more help, visit the .dockerignore file reference guide at +# https://docs.docker.com/engine/reference/builder/#dockerignore-file + +**/.DS_Store +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/AccessControlTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/AccessControlTests.cs deleted file mode 100644 index 39bcdeec8..000000000 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/AccessControlTests.cs +++ /dev/null @@ -1,185 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Threading.Tasks; -using Certify.Core.Management.Access; -using Certify.Models; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using Serilog; - -namespace Certify.Core.Tests.Unit -{ - - public class MemoryObjectStore : IObjectStore - { - private ConcurrentDictionary _store = new ConcurrentDictionary(); - - public Task Load(string id) - { - if (_store.TryGetValue(id, out var value)) - { - return Task.FromResult((T)value); - } - else - { - var empty = (T)Activator.CreateInstance(typeof(T)); - - return Task.FromResult(empty); - } - } - - public Task Save(string id, object item) - { - _ = _store.AddOrUpdate(id, item, (key, oldVal) => item); - return Task.FromResult(true); - } - } - - [TestClass] - public class AccessControlTests - { - - private List GetTestSecurityPrinciples() - { - - return new List { - new SecurityPrinciple { - Id = "admin_01", - Username = "admin", - Description = "Administrator account", - Email="info@test.com", Password="ABCDEFG", - PrincipleType= SecurityPrincipleType.User, - SystemRoleIds=new List{ StandardRoles.Administrator.Id - } -}, - new SecurityPrinciple - { - Id = "domain_owner_01", - Username = "demo_owner", - Description = "Example domain owner", - Email = "domains@test.com", - Password = "ABCDEFG", - PrincipleType = SecurityPrincipleType.User, - SystemRoleIds = new List { StandardRoles.DomainOwner.Id } - }, - new SecurityPrinciple - { - Id = "devops_user_01", - Username = "devops_01", - Description = "Example devops user", - Email = "devops01@test.com", - Password = "ABCDEFG", - PrincipleType = SecurityPrincipleType.User, - SystemRoleIds = new List { StandardRoles.CertificateConsumer.Id, StandardRoles.DomainRequestor.Id } - }, - new SecurityPrinciple - { - Id = "devops_app_01", - Username = "devapp_01", - Description = "Example devops app domain consumer", - Email = "dev_app01@test.com", - Password = "ABCDEFG", - PrincipleType = SecurityPrincipleType.User, - SystemRoleIds = new List { StandardRoles.CertificateConsumer.Id } - } - }; - } - - public List GetTestResourceProfiles() - { - return new List { - new ResourceProfile { - ResourceType = ResourceTypes.System, - AssignedRoles = new List{ - new ResourceAssignedRole{ RoleId=StandardRoles.Administrator.Id, PrincipleId = "admin_01" }, - new ResourceAssignedRole{ RoleId=StandardRoles.CertificateConsumer.Id, PrincipleId = "devops_user_01" }, - new ResourceAssignedRole{ RoleId=StandardRoles.DomainRequestor.Id, PrincipleId = "devops_user_01" } - } - }, - new ResourceProfile { - ResourceType = ResourceTypes.Domain, - Identifier = "example.com", - AssignedRoles= new List{ - new ResourceAssignedRole{ RoleId=StandardRoles.CertificateConsumer.Id, PrincipleId = "devops_user_01" }, - new ResourceAssignedRole{ RoleId=StandardRoles.DomainRequestor.Id, PrincipleId = "devops_user_01" } - } - }, - new ResourceProfile { - ResourceType = ResourceTypes.Domain, - Identifier = "www.example.com", - AssignedRoles= new List{ - new ResourceAssignedRole{ RoleId=StandardRoles.CertificateConsumer.Id, PrincipleId = "devops_user_01" }, - new ResourceAssignedRole{ RoleId=StandardRoles.DomainRequestor.Id, PrincipleId = "devops_user_01" } - } - }, - new ResourceProfile { - ResourceType = ResourceTypes.Domain, - Identifier = "*.microsoft.com", - AssignedRoles= new List{ - new ResourceAssignedRole{ RoleId=StandardRoles.CertificateConsumer.Id, PrincipleId = "devops_user_01" } - } - } - }; - } - - [TestMethod] - public async Task TestAccessControlChecks() - { - var log = new LoggerConfiguration() - .WriteTo.Debug() - .CreateLogger(); - - var loggy = new Loggy(log); - - var access = new AccessControl(loggy, new MemoryObjectStore()); - - var contextUserId = "[test]"; - - // add test security principles - var allPrinciples = GetTestSecurityPrinciples(); - foreach (var p in allPrinciples) - { - _ = await access.AddSecurityPrinciple(p, contextUserId, bypassIntegrityCheck: true); - } - - // assign resource roles per principle - var allResourceProfiles = GetTestResourceProfiles(); - foreach (var r in allResourceProfiles) - { - _ = await access.AddResourceProfile(r, contextUserId, bypassIntegrityCheck: true); - } - - // assert - - var hasAccess = await access.IsPrincipleInRole("admin_01", StandardRoles.Administrator.Id, contextUserId); - Assert.IsTrue(hasAccess, "User should be in role"); - - hasAccess = await access.IsPrincipleInRole("admin_02", StandardRoles.Administrator.Id, contextUserId); - Assert.IsFalse(hasAccess, "User should not be in role"); - - // check user can consume a cert for a given domain - var isAuthorised = await access.IsAuthorised("devops_user_01", StandardRoles.CertificateConsumer.Id, ResourceTypes.Domain, "www.example.com", contextUserId); - Assert.IsTrue(isAuthorised, "User should be a cert consumer for this domain"); - - // check user can't consume a cert for a subdomain they haven't been granted - isAuthorised = await access.IsAuthorised("devops_user_01", StandardRoles.CertificateConsumer.Id, ResourceTypes.Domain, "secure.example.com", contextUserId); - Assert.IsFalse(isAuthorised, "User should not be a cert consumer for this domain"); - - // check user can consume any subdomain via a granted wildcard - isAuthorised = await access.IsAuthorised("devops_user_01", StandardRoles.CertificateConsumer.Id, ResourceTypes.Domain, "random.microsoft.com", contextUserId); - Assert.IsTrue(isAuthorised, "User should be a cert consumer for this subdomain via wildcard"); - - // check user can't consume a random wildcard - isAuthorised = await access.IsAuthorised("devops_user_01", StandardRoles.CertificateConsumer.Id, ResourceTypes.Domain, "* lkjhasdf98862364", contextUserId); - Assert.IsFalse(isAuthorised, "User should not be a cert consumer for random wildcard"); - - // check user can't consume a random wildcard - isAuthorised = await access.IsAuthorised("devops_user_01", StandardRoles.CertificateConsumer.Id, ResourceTypes.Domain, " lkjhasdf98862364.*.microsoft.com", contextUserId); - Assert.IsFalse(isAuthorised, "User should not be a cert consumer for random wildcard"); - - // random user should not be authorised - isAuthorised = await access.IsAuthorised("randomuser", StandardRoles.CertificateConsumer.Id, ResourceTypes.Domain, "random.microsoft.com", contextUserId); - Assert.IsFalse(isAuthorised, "Unknown user should not be a cert consumer for this subdomain via wildcard"); - } - } -} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Assets/badcert.pfx b/src/Certify.Tests/Certify.Core.Tests.Unit/Assets/badcert.pfx new file mode 100644 index 000000000..e69de29bb diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Assets/dummycert.pfx b/src/Certify.Tests/Certify.Core.Tests.Unit/Assets/dummycert.pfx new file mode 100644 index 000000000..2148c5a29 Binary files /dev/null and b/src/Certify.Tests/Certify.Core.Tests.Unit/Assets/dummycert.pfx differ diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Certify.Core.Tests.Unit.csproj b/src/Certify.Tests/Certify.Core.Tests.Unit/Certify.Core.Tests.Unit.csproj index c0439e6d4..41e3db49b 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/Certify.Core.Tests.Unit.csproj +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Certify.Core.Tests.Unit.csproj @@ -1,152 +1,130 @@ - - net7.0;net462; - Debug;Release - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - x64 - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - x64 - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - true - bin\x64\Debug\ - DEBUG;TRACE - full - x64 - prompt - MinimumRecommendedRules.ruleset - - - true - bin\x64\Debug\ - DEBUG;TRACE - full - x64 - prompt - MinimumRecommendedRules.ruleset - - - bin\x64\Release\ - TRACE - true - pdbonly - x64 - prompt - MinimumRecommendedRules.ruleset - - - Debug - AnyCPU - {C0534CD8-10E9-438D-B9A1-A8C09A6BB964} - Library - Properties - Certify.Core.Tests.Unit - Certify.Core.Tests.Unit - v4.6.2 - 512 - {3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - 15.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - $(ProgramFiles)\Common Files\microsoft shared\VSTT\$(VisualStudioVersion)\UITestExtensionPackages - False - UnitTest - - - true - true - PackageReference - PackageReference - - - - x64 - - - 1701;1702;NU1701 - - - 1701;1702;NU1701 - - - 1701;1702;NU1701 - - - 1701;1702;NU1701 - - - - - - - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + net462;net9.0; + Debug;Release + AnyCPU + latest + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + AnyCPU + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + AnyCPU + + + + Debug + AnyCPU + {C0534CD8-10E9-438D-B9A1-A8C09A6BB964} + Library + Properties + Certify.Core.Tests.Unit + Certify.Core.Tests.Unit + v4.6.2 + 512 + {3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + 15.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + $(ProgramFiles)\Common Files\microsoft shared\VSTT\$(VisualStudioVersion)\UITestExtensionPackages + False + UnitTest + + + true + true + PackageReference + PackageReference + + + + portable + + + + AnyCPU + + + 1701;1702;NU1701 + + + 1701;1702;NU1701 + + + 1701;1702;NU1701 + + + 1701;1702;NU1701 + + + $(MSBuildThisFileDirectory)\unit-test.runsettings + + + $(MSBuildThisFileDirectory)\unit-test-linux.runsettings + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Docker.md b/src/Certify.Tests/Certify.Core.Tests.Unit/Docker.md new file mode 100644 index 000000000..19cda4c7f --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Docker.md @@ -0,0 +1,169 @@ +# Certify Core Unit Test Docker Images and Containers + +## Building Certify Core Unit Test Docker Images + +To build the Docker Images for the Certify Core Unit Tests, first navigate to `certify\src\Certify.Tests\Certify.Core.Tests.Unit` in a console window or Visual Studio's Developer Powershell Panel + +### Linux Images + +To build the Linux images, use the following Docker build commands (make sure Docker Desktop is switched to Linux containers): + +``` +// For .NET Core 9.0 +docker build ..\..\..\..\ -t certify-core-tests-unit-9_0-linux -f .\certify-core-tests-unit-9_0-linux.dockerfile +``` + +### Windows Images + +To build the Windows images, use the following Docker build commands (make sure Docker Desktop is switched to Windows containers): + +``` +// For .NET 4.6.2 +docker build ..\..\..\..\ -t certify-core-tests-unit-4_6_2-win -f .\certify-core-tests-unit-4_6_2-win.dockerfile -m 8GB + +// For .NET Core 9.0 +docker build ..\..\..\..\ -t certify-core-tests-unit-9_0-win -f .\certify-core-tests-unit-9_0-win.dockerfile -m 8GB + +// For the step-ca-win image +docker build . -t step-ca-win -f .\step-ca-win.dockerfile +``` + + +Since the context built for the Docker Daemon is quite large for the Certify images in Windows (depending on the size of your Certify workspace), you may need to run this in a Powershell terminal outside of Visual Studio with the IDE and other memory-heavy apps closed down (especailly if you have low RAM). + + +## Running Certify Core Unit Test Containers with Docker Compose + +### Linux Test Runs + +To run the Linux Tests in Docker, use the following Docker Compose command: + +``` +docker compose -f linux_compose.yaml up -d +``` + +To stop the Linux Tests in Docker, use the following Docker Compose command: + +``` +docker compose -f linux_compose.yaml down -v +``` + +### Windows Test Runs + +To run the Windows Tests in Docker, use the following Docker Compose command: + +``` +// For .NET 4.6.2 +docker compose --profile 4_6_2 -f windows_compose.yaml up -d + +// For .NET Core 9.0 +docker compose --profile 9_0 -f windows_compose.yaml up -d +``` + +To stop the Windows Tests in Docker, use the following Docker Compose command: + +``` +// For .NET 4.6.2 +docker compose --profile 4_6_2 -f windows_compose.yaml down -v + +// For .NET Core 8.0 +docker compose --profile 9_0 -f windows_compose.yaml down -v +``` + +### Debugging Tests Running in Containers with Docker Compose in Visual Studio + +Within each test Docker Compose file are commented out lines for debugging subsections of the Certify Core Unit Test code base. + +To run an individual class of tests, uncomment the following section of the corresponding Docker Compose file, with the name of the test class you wish to run following `ClassName=`: + +``` + entrypoint: "dotnet test Certify.Core.Tests.Unit.dll -f net9.0 --filter 'ClassName=Certify.Core.Tests.Unit.CertifyManagerAccountTests'" +``` + +To run an individual test, uncomment the following section of the corresponding Docker Compose file, with +the name of the test you wish to run following `Name=`: + +``` + entrypoint: "dotnet test Certify.Core.Tests.Unit.dll -f net9.0 --filter 'Name=TestCertifyManagerGetAccountDetailsDefinedCertificateAuthorityId'" +``` + +To run tests using the Visual Studio debugger, first ensure that you have the `Containers` window visible (`View -> Other Windows -> Containers`) + +Then, uncomment the following section of the corresponding Docker Compose file you wish to run: +``` + environment: + VSTEST_HOST_DEBUG: 1 +``` + +After starting the Docker Compose file with the `up` command, the container for the Unit Tests will show in the logs a message like this, showing the debug process to attach to (this may take a second while it waits for the health check of the Step-CA container): + +``` +Host debugging is enabled. Please attach debugger to testhost process to continue. +Process Id: 2044, Name: testhost +Waiting for debugger attach... +Process Id: 2044, Name: testhost +``` + +To attach to the process, right-click on the Unit Test container in the Visual Studio Container window, and select `Attach To Process`. Visual Studio may download the Debug tool to your container if missing. + +Visual Studio will then bring up a new window showing the running Processes on the selected container. Double-click the Process with the matching ID number from the logging. + +![Screenshot of the Visual Studio Attach to Process Window](../../../docs/images/VS_Container_Debug_Attach_To_Process_Window.png) + +For Linux containers, you may additionally have to select the proper code type for debugging in the following window (Always choose `Managed (.NET Core for Unix)`): + +![Screenshot of the Visual Studio Select Code Type Window](../../../docs/images/VS_Container_Debug_Select_Code_Type_Window.png) + +The Visual Studio's debugger will then take a moment to attach. Once ready, you will need to click the `Continue` button to start test code execution. + +**Be sure to uncomment any debugging lines from the compose files before committing changes to the `certify` repo.** + +## Running Certify Core Unit Tests with a Base Docker Image (No Building) + +Since building a custom image can take time while doing local development, you can also use a the base images referenced in the Dockerfiles for this project to run your code on your machine in a container. + +To do this, first navigate to `certify\src\Certify.Tests\Certify.Core.Tests.Unit` in a console window or Visual Studio's Developer Powershell Panel. + +### Running Certify Core Unit Tests with a Linux Base Image + +**Note: CertifyManagerAccountTests tests will not work properly unless a Docker container for step-ca has been started with the hostname `step-ca`** + +To run all of the Certify Core Unit Tests in a Linux container, use the following command: + +``` +docker run --name core-tests-unit-9_0-linux --rm -it -v ${pwd}\bin\Debug\net9.0:/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 dotnet test Certify.Core.Tests.Unit.dll -f net9.0 +``` + +To run a specific class of Certify Core Unit Tests in a Linux container, use the following command, substituting the Class Name of the tests after `--filter "ClassName=`: + +``` +docker run --name core-tests-unit-9_0-linux --rm -it -v ${pwd}\bin\Debug\net9.0:/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 dotnet test Certify.Core.Tests.Unit.dll -f net9.0 --filter "ClassName=Certify.Core.Tests.Unit.DnsQueryTests" +``` + +To run a single Certify Core Unit Test in a Linux container, use the following command, substituting the Test Name of the tests after `--filter "Name=`: + +``` +docker run --name core-tests-unit-9_0-linux --rm -it -v ${pwd}\bin\Debug\net9.0:/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 dotnet test Certify.Core.Tests.Unit.dll -f net9.0 --filter "Name=MixedIPBindingChecksNoPreview" +``` + +To run Certify Core Unit Tests with Debugging in a Linux container, use the following command, add `-e VSTEST_HOST_DEBUG=1` as a `docker run` parameter like so: + +``` +docker run --name core-tests-unit-9_0-linux --rm -it -e VSTEST_HOST_DEBUG=1 -v ${pwd}\bin\Debug\net9.0:/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 dotnet test Certify.Core.Tests.Unit.dll -f net9.0" +``` + +### Running Certify Core Unit Tests with a Windows Base Image + +**Note: CertifyManagerAccountTests tests will not work properly unless a Docker container for step-ca-win has been started with the hostname `step-ca`** + +To run all of the Certify Core Unit Tests in a Windows container, use the following command: + +``` +// For .NET 4.6.2 +docker run --name core-tests-unit-4_6_2-win --rm -it -v ${pwd}\bin\Debug\net462:C:\app -w C:\app mcr.microsoft.com/dotnet/sdk:9.0-preview-windowsservercore-ltsc2022 dotnet test Certify.Core.Tests.Unit.dll -f net462 + +// For .NET Core 8.0 +docker run --name core-tests-unit-9_0-win --rm -it -v ${pwd}\bin\Debug\net9.0:C:\app -w C:\app mcr.microsoft.com/dotnet/sdk:9.0-preview-windowsservercore-ltsc2022 dotnet test Certify.Core.Tests.Unit.dll -f net9.0 +``` + +See the above Linux examples to see how to run tests selectively or with debugging enabled. diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/MiscTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/MiscTests.cs deleted file mode 100644 index a74dbe87b..000000000 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/MiscTests.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.Threading.Tasks; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace Certify.Core.Tests.Unit -{ - [TestClass] - public class MiscTests - { - - public MiscTests() - { - - } - - [TestMethod, Description("Test null/blank coalesce of string")] - public void TestNullOrBlankCoalesce() - { - string testValue = null; - - var result = testValue.WithDefault("ok"); - Assert.AreEqual(result, "ok"); - - testValue = "test"; - result = testValue.WithDefault("ok"); - Assert.AreEqual(result, "test"); - - var ca = new Models.CertificateAuthority(); - ca.Description = null; - result = ca.Description.WithDefault("default"); - Assert.AreEqual(result, "default"); - - ca = null; - result = ca?.Description.WithDefault("default"); - Assert.AreEqual(result, null); - } - - [TestMethod, Description("Test ntp check")] - public async Task TestNtp() - { - var check = await Certify.Management.Util.CheckTimeServer(); - - var timeDiff = check - DateTimeOffset.UtcNow; - - if (Math.Abs(timeDiff.Value.TotalSeconds) > 50) - { - Assert.Fail("NTP Time Difference Failed"); - } - } -#if NET7_0_OR_GREATER - [TestMethod, Description("Test ARI CertID encoding example")] - public void TestARICertIDEncoding() - { - // https://letsencrypt.org/2024/04/25/guide-to-integrating-ari-into-existing-acme-clients - var certAKIbytes = Convert.FromHexString("69:88:5B:6B:87:46:40:41:E1:B3:7B:84:7B:A0:AE:2C:DE:01:C8:D4".Replace(":", "")); - var certSerialBytes = Convert.FromHexString("00:87:65:43:21".Replace(":", "")); - - var certId = Certify.Management.Util.ToUrlSafeBase64String(certAKIbytes) - + "." - + Certify.Management.Util.ToUrlSafeBase64String(certSerialBytes); - - Assert.AreEqual("aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE", certId); - } -#endif - } -} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/AccessControlTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/AccessControlTests.cs new file mode 100644 index 000000000..1bc4a1339 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/AccessControlTests.cs @@ -0,0 +1,757 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Certify.Core.Management.Access; +using Certify.Models; +using Certify.Models.Config.AccessControl; +using Certify.Providers; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; + +namespace Certify.Core.Tests.Unit +{ + public class MemoryObjectStore : IAccessControlStore + { + private ConcurrentDictionary _store = new ConcurrentDictionary(); + + public Task Add(string itemType, AccessStoreItem item) + { + item.ItemType = itemType; + + // clone the item to avoid reference issue mutating the same object, as we are using an in-memory store + var clonedItem = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(item)) as AccessStoreItem; + return Task.FromResult(_store.TryAdd(clonedItem.Id, clonedItem)); + } + + public Task Delete(string itemType, string id) + { + return Task.FromResult((_store.TryRemove(id, out _))); + } + + public Task> GetItems(string itemType) + { + var items = _store.Values + .Where((s => s.ItemType == itemType)) + .Select(s => (T)Convert.ChangeType(s, typeof(T))); + + return Task.FromResult((items.ToList())); + } + + public Task Get(string itemType, string id) + { + _store.TryGetValue(id, out var value); + return Task.FromResult((T)Convert.ChangeType(value, typeof(T))); + } + + public Task Add(string itemType, T item) + { + var o = item as AccessStoreItem; + o.ItemType = itemType; + + var clonedItem = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(o)) as AccessStoreItem; + return Task.FromResult(_store.TryAdd(clonedItem.Id, clonedItem)); + } + + public Task Update(string itemType, T item) + { + var o = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(item)) as AccessStoreItem; + + _store.TryGetValue(o.Id, out var value); + var c = Task.FromResult((T)Convert.ChangeType(value, typeof(T))).Result as AccessStoreItem; + var r = Task.FromResult(_store.TryUpdate(o.Id, o, c)); + if (r.Result == false) + { + throw new Exception("Could not store item type"); + } + + return r; + } + } + + public class TestAssignedRoles + { + public static AssignedRole TestAdmin { get; } = new AssignedRole + { + // test administrator + RoleId = StandardRoles.Administrator.Id, + SecurityPrincipleId = "[test]" + }; + public static AssignedRole Admin { get; } = new AssignedRole + { + // administrator + RoleId = StandardRoles.Administrator.Id, + SecurityPrincipleId = "admin_01" + }; + public static AssignedRole DevopsUserDomainConsumer { get; } = new AssignedRole + { + // devops user in consumer role for a specific domain + RoleId = StandardRoles.CertificateConsumer.Id, + SecurityPrincipleId = "devops_user_01", + IncludedResources = new List{ + new Resource{ ResourceType=ResourceTypes.Domain, Identifier="www.example.com" }, + } + }; + public static AssignedRole DevopsUserWildcardDomainConsumer { get; } = new AssignedRole + { + // devops user in consumer role for a wildcard domain + RoleId = StandardRoles.CertificateConsumer.Id, + SecurityPrincipleId = "devops_user_01", + IncludedResources = new List{ + new Resource{ ResourceType=ResourceTypes.Domain, Identifier="*.microsoft.com" }, + } + }; + } + + public class TestSecurityPrinciples + { + public static SecurityPrinciple TestAdmin => new SecurityPrinciple + { + Id = "[test]", + Username = "test administrator", + Description = "Example test administrator used as context user during test", + Email = "test_admin@test.com", + Password = "ABCDEFG", + PrincipleType = SecurityPrincipleType.User + }; + public static SecurityPrinciple Admin => new SecurityPrinciple + { + Id = "admin_01", + Username = "admin", + Description = "Administrator account", + Email = "info@test.com", + Password = "ABCDEFG", + PrincipleType = SecurityPrincipleType.User, + }; + public static SecurityPrinciple DomainOwner => new SecurityPrinciple + { + Id = "domain_owner_01", + Username = "demo_owner", + Description = "Example domain owner", + Email = "domains@test.com", + Password = "ABCDEFG", + PrincipleType = SecurityPrincipleType.User, + }; + public static SecurityPrinciple DevopsUser => new SecurityPrinciple + { + Id = "devops_user_01", + Username = "devops_01", + Description = "Example devops user", + Email = "devops01@test.com", + Password = "ABCDEFG", + PrincipleType = SecurityPrincipleType.User, + }; + public static SecurityPrinciple DevopsAppDomainConsumer => new SecurityPrinciple + { + Id = "devops_app_01", + Username = "devapp_01", + Description = "Example devops app domain consumer", + Email = "dev_app01@test.com", + Password = "ABCDEFG", + PrincipleType = SecurityPrincipleType.User, + }; + } + + [TestClass] + public class AccessControlTests + { + private Loggy loggy; + private AccessControl access; + private const string contextUserId = "[test]"; + + [TestInitialize] + public void TestInitialize() + { + this.loggy = new Loggy(LoggerFactory.Create(builder => builder.AddDebug()).CreateLogger()); + + this.access = new AccessControl(loggy, new MemoryObjectStore()); + } + + [TestMethod] + public async Task TestAddGetSecurityPrinciples() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Get stored security principles + var storedSecurityPrinciples = await access.GetSecurityPrinciples(contextUserId); + + // Validate SecurityPrinciple list returned by AccessControl.GetSecurityPrinciples() + Assert.IsNotNull(storedSecurityPrinciples, "Expected list returned by AccessControl.GetSecurityPrinciples() to not be null"); + Assert.AreEqual(2, storedSecurityPrinciples.Count, "Expected list returned by AccessControl.GetSecurityPrinciples() to have 2 SecurityPrinciple objects"); + foreach (var passedPrinciple in adminSecurityPrinciples) + { + Assert.IsNotNull(storedSecurityPrinciples.Find(x => x.Id == passedPrinciple.Id), $"Expected a SecurityPrinciple returned by GetSecurityPrinciples() to match Id '{passedPrinciple.Id}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + } + } + + [TestMethod] + public async Task TestGetSecurityPrinciplesNoRoles() + { + // Add test security principles + var securityPrincipleAdded = await access.AddSecurityPrinciple(contextUserId, TestSecurityPrinciples.TestAdmin); + + // Get stored security principles + Assert.IsFalse(securityPrincipleAdded, $"Expected AddSecurityPrinciple() to be unsuccessful without roles defined for {contextUserId}"); + } + + [TestMethod] + public async Task TestAddGetSecurityPrinciple() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + foreach (var securityPrinciple in adminSecurityPrinciples) + { + // Get stored security principle + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, securityPrinciple.Id); + + // Validate SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() + Assert.IsNotNull(storedSecurityPrinciple, "Expected object returned by AccessControl.GetSecurityPrinciple() to not be null"); + Assert.AreEqual(storedSecurityPrinciple.Id, securityPrinciple.Id, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Id '{securityPrinciple.Id}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + } + } + + [TestMethod] + public async Task TestAddGetAssignedRoles() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Setup security principle actions + var actions = Policies.GetStandardResourceActions().FindAll(a => a.ResourceType == ResourceTypes.System); + actions.ForEach(async a => await access.AddResourceAction(a)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.AccessAdmin); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.Administrator.Id); + await access.AddRole(role); + + // Assign security principles to roles and add roles and policy assignments to store + var assignedRoles = new List { TestAssignedRoles.Admin, TestAssignedRoles.TestAdmin }; + assignedRoles.ForEach(async r => await access.AddAssignedRole(r)); + + // Validate AssignedRole list returned by AccessControl.GetAssignedRoles() + foreach (var assignedRole in assignedRoles) + { + var adminAssignedRoles = await access.GetAssignedRoles(contextUserId, assignedRole.SecurityPrincipleId); + Assert.IsNotNull(adminAssignedRoles, "Expected list returned by AccessControl.GetAssignedRoles() to not be null"); + Assert.AreEqual(1, adminAssignedRoles.Count, "Expected list returned by AccessControl.GetAssignedRoles() to have 1 AssignedRole object"); + Assert.AreEqual(assignedRole.SecurityPrincipleId, adminAssignedRoles[0].SecurityPrincipleId, "Expected AssignedRole returned by GetAssignedRoles() to match SecurityPrincipleId of AssignedRole passed into AddAssignedRole()"); + } + } + + [TestMethod] + public async Task TestGetAssignedRolesNoRoles() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Validate AssignedRole list returned by AccessControl.GetAssignedRoles() + var adminAssignedRoles = await access.GetAssignedRoles(contextUserId, adminSecurityPrinciples[0].Id); + Assert.IsNotNull(adminAssignedRoles, "Expected list returned by AccessControl.GetAssignedRoles() to not be null"); + Assert.AreEqual(0, adminAssignedRoles.Count, "Expected list returned by AccessControl.GetAssignedRoles() to have no AssignedRole objects"); + } + + [TestMethod] + public async Task TestAddResourcePolicyNoRoles() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Setup security principle actions + var actions = Policies.GetStandardResourceActions().FindAll(a => a.ResourceType == ResourceTypes.System); + actions.ForEach(async a => await access.AddResourceAction(a)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.AccessAdmin); + var addedResourcePolicy = await access.AddResourcePolicy(contextUserId, policy); + + // Validate that AddResourcePolicy() failed when no roles are defined + Assert.IsFalse(addedResourcePolicy, $"Unable to add a resource policy using {contextUserId} when roles are undefined"); + } + + [TestMethod] + public async Task TestUpdateSecurityPrinciple() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Setup security principle actions + var actions = Policies.GetStandardResourceActions().FindAll(a => a.ResourceType == ResourceTypes.System); + actions.ForEach(async a => await access.AddResourceAction(a)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.AccessAdmin); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.Administrator.Id); + await access.AddRole(role); + + // Assign security principles to roles and add roles and policy assignments to store + var assignedRoles = new List { TestAssignedRoles.Admin, TestAssignedRoles.TestAdmin }; + assignedRoles.ForEach(async r => await access.AddAssignedRole(r)); + + // Validate email of SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() before update + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + Assert.AreEqual(storedSecurityPrinciple.Email, adminSecurityPrinciples[0].Email, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Email '{adminSecurityPrinciples[0].Email}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + + // Update security principle in AccessControl with a new principle object of the same Id, but different email + var updateSecurityPrinciple = new SecurityPrinciple + { + Id = TestSecurityPrinciples.Admin.Id, + Username = TestSecurityPrinciples.Admin.Username, + Description = TestSecurityPrinciples.Admin.Description, + Email = "new_test_email@test.com" + }; + + var securityPrincipleUpdated = await access.UpdateSecurityPrinciple(contextUserId, updateSecurityPrinciple); + Assert.IsTrue(securityPrincipleUpdated, $"Expected security principle update for {updateSecurityPrinciple.Id} to succeed"); + + // Validate email of SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() after update + storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, updateSecurityPrinciple.Id); + Assert.AreNotEqual(storedSecurityPrinciple.Email, adminSecurityPrinciples[0].Email, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to not match previous Email '{adminSecurityPrinciples[0].Email}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + Assert.AreEqual(storedSecurityPrinciple.Email, updateSecurityPrinciple.Email, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match updated Email '{updateSecurityPrinciple.Email}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + } + + [TestMethod] + public async Task TestUpdateSecurityPrincipleNoRoles() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Validate email of SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() before update + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + Assert.AreEqual(storedSecurityPrinciple.Email, adminSecurityPrinciples[0].Email, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Email '{adminSecurityPrinciples[0].Email}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + + // Update security principle in AccessControl with a new principle object of the same Id, but different email, with roles undefined + var newSecurityPrinciple = TestSecurityPrinciples.Admin; + newSecurityPrinciple.Email = "new_test_email@test.com"; + + var securityPrincipleUpdated = await access.UpdateSecurityPrinciple(contextUserId, newSecurityPrinciple); + Assert.IsFalse(securityPrincipleUpdated, $"Expected security principle update for {newSecurityPrinciple.Id} to be unsuccessful without roles defined"); + } + + [TestMethod] + public async Task TestUpdateSecurityPrincipleBadUpdate() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Setup security principle actions + var actions = Policies.GetStandardResourceActions().FindAll(a => a.ResourceType == ResourceTypes.System); + actions.ForEach(async a => await access.AddResourceAction(a)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.AccessAdmin); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.Administrator.Id); + await access.AddRole(role); + + // Assign security principles to roles and add roles and policy assignments to store + var assignedRoles = new List { TestAssignedRoles.Admin, TestAssignedRoles.TestAdmin }; + assignedRoles.ForEach(async r => await access.AddAssignedRole(r)); + + // Validate email of SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() before update + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + Assert.AreEqual(storedSecurityPrinciple.Email, adminSecurityPrinciples[0].Email, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Email '{adminSecurityPrinciples[0].Email}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + + // Update security principle in AccessControl with a new principle object with a bad Id name and different email + var newSecurityPrinciple = TestSecurityPrinciples.Admin; + newSecurityPrinciple.Email = "new_test_email@test.com"; + newSecurityPrinciple.Id = "missing_username"; + var securityPrincipleUpdated = await access.UpdateSecurityPrinciple(contextUserId, newSecurityPrinciple); + + Assert.IsFalse(securityPrincipleUpdated, $"Expected security principle update for {newSecurityPrinciple.Id} to be unsuccessful with bad update data (Id does not already exist in store)"); + } + + [TestMethod] + public async Task TestUpdateSecurityPrinciplePassword() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + var firstPassword = adminSecurityPrinciples[0].Password; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Setup security principle actions + var actions = Policies.GetStandardResourceActions().FindAll(a => a.ResourceType == ResourceTypes.System); + actions.ForEach(async a => await access.AddResourceAction(a)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.AccessAdmin); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.Administrator.Id); + await access.AddRole(role); + + // Assign security principles to roles and add roles and policy assignments to store + var assignedRoles = new List { TestAssignedRoles.Admin, TestAssignedRoles.TestAdmin }; + assignedRoles.ForEach(async r => await access.AddAssignedRole(r)); + + // Validate password of SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() before update + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + var firstPasswordHashed = access.HashPassword(firstPassword, storedSecurityPrinciple.Password.Split('.')[1]); + Assert.AreEqual(storedSecurityPrinciple.Password, firstPasswordHashed, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Password '{firstPasswordHashed}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + + // Update security principle in AccessControl with a new password + var newPassword = "GFEDCBA"; + var securityPrincipleUpdated = await access.UpdateSecurityPrinciplePassword(contextUserId, new Models.API.SecurityPrinciplePasswordUpdate(adminSecurityPrinciples[0].Id, firstPassword, newPassword)); + Assert.IsTrue(securityPrincipleUpdated, $"Expected security principle password update for {adminSecurityPrinciples[0].Id} to succeed"); + + // Validate password of SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() after update + storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + var newPasswordHashed = access.HashPassword(newPassword, storedSecurityPrinciple.Password.Split('.')[1]); + + Assert.AreNotEqual(storedSecurityPrinciple.Password, firstPasswordHashed, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to not match previous Password '{firstPasswordHashed}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + Assert.AreEqual(storedSecurityPrinciple.Password, newPasswordHashed, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match updated Password '{newPasswordHashed}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + } + + [TestMethod] + public async Task TestUpdateSecurityPrinciplePasswordNoRoles() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + var firstPassword = adminSecurityPrinciples[0].Password; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Update security principle in AccessControl with a new password + var newPassword = "GFEDCBA"; + var securityPrincipleUpdated = await access.UpdateSecurityPrinciplePassword(contextUserId, new Models.API.SecurityPrinciplePasswordUpdate(adminSecurityPrinciples[0].Id, firstPassword, newPassword)); + Assert.IsFalse(securityPrincipleUpdated, $"Expected security principle password update for {adminSecurityPrinciples[0].Id} to fail without roles"); + + // Validate password of SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() after failed update + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + var firstPasswordHashed = access.HashPassword(firstPassword, storedSecurityPrinciple.Password.Split('.')[1]); + + Assert.AreEqual(storedSecurityPrinciple.Password, firstPasswordHashed, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Password '{firstPasswordHashed}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + } + + [TestMethod] + public async Task TestUpdateSecurityPrinciplePasswordBadPassword() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + var firstPassword = adminSecurityPrinciples[0].Password; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Setup security principle actions + var actions = Policies.GetStandardResourceActions().FindAll(a => a.ResourceType == ResourceTypes.System); + actions.ForEach(async a => await access.AddResourceAction(a)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.AccessAdmin); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.Administrator.Id); + await access.AddRole(role); + + // Assign security principles to roles and add roles and policy assignments to store + var assignedRoles = new List { TestAssignedRoles.Admin, TestAssignedRoles.TestAdmin }; + assignedRoles.ForEach(async r => await access.AddAssignedRole(r)); + + // Update security principle in AccessControl with a new password, but wrong original password + var newPassword = "GFEDCBA"; + var securityPrincipleUpdated = await access.UpdateSecurityPrinciplePassword(contextUserId, new Models.API.SecurityPrinciplePasswordUpdate(adminSecurityPrinciples[0].Id, firstPassword.ToLower(), newPassword)); + Assert.IsFalse(securityPrincipleUpdated, $"Expected security principle password update for {adminSecurityPrinciples[0].Id} to fail with wrong password"); + + // Validate password of SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() after failed update + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + var firstPasswordHashed = access.HashPassword(firstPassword, storedSecurityPrinciple.Password.Split('.')[1]); + Assert.AreEqual(storedSecurityPrinciple.Password, firstPasswordHashed, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Password '{firstPasswordHashed}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + } + + [TestMethod] + public async Task TestDeleteSecurityPrinciple() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Setup security principle actions + var actions = Policies.GetStandardResourceActions().FindAll(a => a.ResourceType == ResourceTypes.System); + actions.ForEach(async a => await access.AddResourceAction(a)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.AccessAdmin); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.Administrator.Id); + await access.AddRole(role); + + // Assign security principles to roles and add roles and policy assignments to store + var assignedRoles = new List { TestAssignedRoles.Admin, TestAssignedRoles.TestAdmin }; + assignedRoles.ForEach(async r => await access.AddAssignedRole(r)); + + // Validate SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() before delete is not null + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + Assert.IsNotNull(storedSecurityPrinciple, "Expected object returned by AccessControl.GetSecurityPrinciple() to not be null"); + Assert.AreEqual(storedSecurityPrinciple.Id, adminSecurityPrinciples[0].Id, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Id '{adminSecurityPrinciples[0].Id}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + + // Delete first security principle in adminSecurityPrinciples list from AccessControl store + var securityPrincipleDeleted = await access.DeleteSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + Assert.IsTrue(securityPrincipleDeleted, $"Expected security principle deletion for {adminSecurityPrinciples[0].Id} to succeed"); + + // Validate SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() after delete is null + storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + Assert.IsNull(storedSecurityPrinciple, $"Expected SecurityPrinciple for '{adminSecurityPrinciples[0].Id}' to be null from GetSecurityPrinciple()"); + } + + [TestMethod] + public async Task TestDeleteSecurityPrincipleNoRoles() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Validate SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() before delete is not null + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + Assert.IsNotNull(storedSecurityPrinciple, "Expected object returned by AccessControl.GetSecurityPrinciple() to not be null"); + Assert.AreEqual(storedSecurityPrinciple.Id, adminSecurityPrinciples[0].Id, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Id '{adminSecurityPrinciples[0].Id}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + + // Try to delete first security principle in adminSecurityPrinciples list from AccessControl store + var securityPrincipleDeleted = await access.DeleteSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + Assert.IsFalse(securityPrincipleDeleted, $"Expected security principle deletion for {adminSecurityPrinciples[0].Id} to fail without roles defined"); + + // Validate SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() after delete is not null + storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[0].Id); + Assert.IsNotNull(storedSecurityPrinciple, $"Expected SecurityPrinciple for '{adminSecurityPrinciples[0].Id}' to not be null from GetSecurityPrinciple()"); + } + + [TestMethod] + public async Task TestDeleteSecurityPrincipleSelfDeletion() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Setup security principle actions + var actions = Policies.GetStandardResourceActions().FindAll(a => a.ResourceType == ResourceTypes.System); + actions.ForEach(async a => await access.AddResourceAction(a)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.AccessAdmin); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.Administrator.Id); + await access.AddRole(role); + + // Assign security principles to roles and add roles and policy assignments to store + var assignedRoles = new List { TestAssignedRoles.Admin, TestAssignedRoles.TestAdmin }; + assignedRoles.ForEach(async r => await access.AddAssignedRole(r)); + + // Validate SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() before delete is not null + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[1].Id); + Assert.IsNotNull(storedSecurityPrinciple, "Expected object returned by AccessControl.GetSecurityPrinciple() to not be null"); + Assert.AreEqual(storedSecurityPrinciple.Id, adminSecurityPrinciples[1].Id, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Id '{adminSecurityPrinciples[1].Id}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + + // Try to delete second security principle in adminSecurityPrinciples list from AccessControl store + var securityPrincipleDeleted = await access.DeleteSecurityPrinciple(contextUserId, contextUserId); + Assert.IsFalse(securityPrincipleDeleted, $"Expected security principle self deletion for {contextUserId} to fail"); + + // Validate SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() after delete is not null + storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[1].Id); + Assert.IsNotNull(storedSecurityPrinciple, $"Expected SecurityPrinciple for '{adminSecurityPrinciples[1].Id}' to not be null from GetSecurityPrinciple()"); + } + + [TestMethod] + public async Task TestDeleteSecurityPrincipleBadId() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Setup security principle actions + var actions = Policies.GetStandardResourceActions().FindAll(a => a.ResourceType == ResourceTypes.System); + actions.ForEach(async a => await access.AddResourceAction(a)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.AccessAdmin); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.Administrator.Id); + await access.AddRole(role); + + // Assign security principles to roles and add roles and policy assignments to store + var assignedRoles = new List { TestAssignedRoles.Admin, TestAssignedRoles.TestAdmin }; + assignedRoles.ForEach(async r => await access.AddAssignedRole(r)); + + // Validate SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() before delete is not null + var storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[1].Id); + Assert.IsNotNull(storedSecurityPrinciple, "Expected object returned by AccessControl.GetSecurityPrinciple() to not be null"); + Assert.AreEqual(storedSecurityPrinciple.Id, adminSecurityPrinciples[1].Id, $"Expected SecurityPrinciple returned by GetSecurityPrinciple() to match Id '{adminSecurityPrinciples[1].Id}' of SecurityPrinciple passed into AddSecurityPrinciple()"); + + // Try to delete second security principle in adminSecurityPrinciples list from AccessControl store + var securityPrincipleDeleted = await access.DeleteSecurityPrinciple(contextUserId, contextUserId.ToUpper()); + Assert.IsFalse(securityPrincipleDeleted, $"Expected security principle deletion for {contextUserId.ToUpper()} to fail"); + + // Validate SecurityPrinciple object returned by AccessControl.GetSecurityPrinciple() after delete is not null + storedSecurityPrinciple = await access.GetSecurityPrinciple(contextUserId, adminSecurityPrinciples[1].Id); + Assert.IsNotNull(storedSecurityPrinciple, $"Expected SecurityPrinciple for '{adminSecurityPrinciples[1].Id}' to not be null from GetSecurityPrinciple()"); + } + + [TestMethod] + public async Task TestIsPrincipleInRole() + { + // Add test security principles + var adminSecurityPrinciples = new List { TestSecurityPrinciples.Admin, TestSecurityPrinciples.TestAdmin }; + adminSecurityPrinciples.ForEach(async p => await access.AddSecurityPrinciple(contextUserId, p, bypassIntegrityCheck: true)); + + // Setup security principle actions + var actions = Policies.GetStandardResourceActions().FindAll(a => a.ResourceType == ResourceTypes.System); + actions.ForEach(async a => await access.AddResourceAction(a)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.AccessAdmin); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.Administrator.Id); + await access.AddRole(role); + + // Assign security principles to roles and add roles and policy assignments to store + var assignedRoles = new List { TestAssignedRoles.Admin, TestAssignedRoles.TestAdmin }; + assignedRoles.ForEach(async r => await access.AddAssignedRole(r)); + + // Validate specified admin user is a principle role + bool hasAccess; + foreach (var assignedRole in assignedRoles) + { + hasAccess = await access.IsPrincipleInRole(contextUserId, assignedRole.SecurityPrincipleId, StandardRoles.Administrator.Id); + Assert.IsTrue(hasAccess, $"User '{assignedRole.SecurityPrincipleId}' should be in role"); + } + + // Validate fake admin user is not a principle role + hasAccess = await access.IsPrincipleInRole(contextUserId, "admin_02", StandardRoles.Administrator.Id); + Assert.IsFalse(hasAccess, "User should not be in role"); + } + + [TestMethod] + public async Task TestDomainAuth() + { + // Add test devops user security principle + _ = await access.AddSecurityPrinciple(contextUserId, TestSecurityPrinciples.DevopsUser, bypassIntegrityCheck: true); + + // Setup security principle actions + await access.AddResourceAction(Policies.GetStandardResourceActions().Find(r => r.Id == "certificate_download")); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.CertificateConsumer); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.CertificateConsumer.Id); + await access.AddRole(role); + + // Assign security principles to roles and add roles and policy assignments to store + await access.AddAssignedRole(TestAssignedRoles.DevopsUserDomainConsumer); // devops user in consumer role for a specific domain + + // Validate user can consume a cert for a given domain + var isAuthorised = await access.IsAuthorised(contextUserId, "devops_user_01", StandardRoles.CertificateConsumer.Id, ResourceTypes.Domain, "certificate_download", "www.example.com"); + Assert.IsTrue(isAuthorised, "User should be a cert consumer for this domain"); + + // Validate user can't consume a cert for a subdomain they haven't been granted + isAuthorised = await access.IsAuthorised(contextUserId, "devops_user_01", StandardRoles.CertificateConsumer.Id, ResourceTypes.Domain, "certificate_download", "secure.example.com"); + Assert.IsFalse(isAuthorised, "User should not be a cert consumer for this domain"); + } + + [TestMethod] + public async Task TestWildcardDomainAuth() + { + // Add test devops user security principle + _ = await access.AddSecurityPrinciple(contextUserId, TestSecurityPrinciples.DevopsUser, bypassIntegrityCheck: true); + + // Setup security principle actions + await access.AddResourceAction(Policies.GetStandardResourceActions().Find(r => r.Id == StandardResourceActions.CertificateDownload)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.CertificateConsumer); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.CertificateConsumer.Id); + await access.AddRole(role); + + // Assign security principles to roles and add roles and policy assignments to store + await access.AddAssignedRole(TestAssignedRoles.DevopsUserWildcardDomainConsumer); // devops user in consumer role for a wildcard domain + + // Validate user can consume any subdomain via a granted wildcard + var isAuthorised = await access.IsAuthorised(contextUserId, "devops_user_01", StandardRoles.CertificateConsumer.Id, ResourceTypes.Domain, "certificate_download", "random.microsoft.com"); + Assert.IsTrue(isAuthorised, "User should be a cert consumer for this subdomain via wildcard"); + + // Validate user can't consume a random wildcard + isAuthorised = await access.IsAuthorised(contextUserId, "devops_user_01", StandardRoles.CertificateConsumer.Id, ResourceTypes.Domain, "certificate_download", "* lkjhasdf98862364"); + Assert.IsFalse(isAuthorised, "User should not be a cert consumer for random wildcard"); + + // Validate user can't consume a random wildcard + isAuthorised = await access.IsAuthorised(contextUserId, "devops_user_01", StandardRoles.CertificateConsumer.Id, ResourceTypes.Domain, "certificate_download", "lkjhasdf98862364.*.microsoft.com"); + Assert.IsFalse(isAuthorised, "User should not be a cert consumer for random wildcard"); + } + + [TestMethod] + public async Task TestRandomUserAuth() + { + // Add test devops user security principle + _ = await access.AddSecurityPrinciple(contextUserId, TestSecurityPrinciples.DevopsUser, bypassIntegrityCheck: true); + + // Setup security principle actions + await access.AddResourceAction(Policies.GetStandardResourceActions().Find(r => r.Id == StandardResourceActions.CertificateDownload)); + + // Setup policy with actions and add policy to store + var policy = Policies.GetStandardPolicies().Find(p => p.Id == StandardPolicies.CertificateConsumer); + _ = await access.AddResourcePolicy(contextUserId, policy, bypassIntegrityCheck: true); + + // Setup and add roles and policy assignments to store + var role = Policies.GetStandardRoles().Find(r => r.Id == StandardRoles.CertificateConsumer.Id); + await access.AddRole(role); + + // Assign security principles to roles and add roles and policy assignments to store + await access.AddAssignedRole(TestAssignedRoles.DevopsUserWildcardDomainConsumer); // devops user in consumer role for a wildcard domain + + // Validate that random user should not be authorised + var isAuthorised = await access.IsAuthorised(contextUserId, "randomuser", StandardRoles.CertificateConsumer.Id, ResourceTypes.Domain, "certificate_download", "random.microsoft.com"); + Assert.IsFalse(isAuthorised, "Unknown user should not be a cert consumer for this subdomain via wildcard"); + } + + [TestMethod] + public async Task TestSecurityPrinciplePwdValid() + { + // Add test devops user security principle + _ = await access.AddSecurityPrinciple(contextUserId, TestSecurityPrinciples.DevopsUser, bypassIntegrityCheck: true); + var check = await access.CheckSecurityPrinciplePassword(contextUserId, new Models.API.SecurityPrinciplePasswordCheck(TestSecurityPrinciples.DevopsUser.Id, TestSecurityPrinciples.DevopsUser.Password)); + + Assert.IsTrue(check.IsSuccess, "Password should be valid"); + } + + [TestMethod] + public async Task TestSecurityPrinciplePwdInvalid() + { + // Add test devops user security principle + _ = await access.AddSecurityPrinciple(contextUserId, TestSecurityPrinciples.DevopsUser, bypassIntegrityCheck: true); + var check = await access.CheckSecurityPrinciplePassword(contextUserId, new Models.API.SecurityPrinciplePasswordCheck(TestSecurityPrinciples.DevopsUser.Id, "INVALID_PWD")); + + Assert.IsFalse(check.IsSuccess, "Password should not be valid"); + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/AccountKeyTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/AccountKeyTests.cs similarity index 100% rename from src/Certify.Tests/Certify.Core.Tests.Unit/AccountKeyTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Unit/Tests/AccountKeyTests.cs diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/BindingMatchTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/BindingMatchTests.cs similarity index 99% rename from src/Certify.Tests/Certify.Core.Tests.Unit/BindingMatchTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Unit/Tests/BindingMatchTests.cs index eb650ded1..21a3c4207 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/BindingMatchTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/BindingMatchTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; @@ -1240,7 +1240,7 @@ public async Task TestIPSpecific_ExistingHttpsBinding() SubjectAlternativeNames = new string[] { "ipspecific.test.com", "ipspecific2.test.com", "nonipspecific.test.com", "nonipspecific2.test.com", "nonipspecific3.test.com" }, PerformAutomatedCertBinding = true, DeploymentSiteOption = DeploymentOption.SingleSite, - + DeploymentBindingBlankHostname = true, BindingIPAddress = "127.0.0.1", BindingPort = "443", @@ -1261,7 +1261,7 @@ public async Task TestIPSpecific_ExistingHttpsBinding() var results = await deployment.StoreAndDeploy(mockTarget, testManagedCert, "test.pfx", pfxPwd: "", true, Certify.Management.CertificateManager.WEBHOSTING_STORE_NAME); - Assert.AreEqual(6, results.Count(r=>r.ObjectResult is BindingInfo)); + Assert.AreEqual(6, results.Count(r => r.ObjectResult is BindingInfo)); // existing IP specific https binding should be preserved var bindingInfo = results.Last(r => (r.ObjectResult as BindingInfo)?.Host == "ipspecific.test.com")?.ObjectResult as BindingInfo; diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/CAFailoverTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CAFailoverTests.cs similarity index 90% rename from src/Certify.Tests/Certify.Core.Tests.Unit/CAFailoverTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CAFailoverTests.cs index 25a0cf707..d6bbbabfd 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/CAFailoverTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CAFailoverTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Certify.Management; @@ -12,6 +12,14 @@ public class CAFailoverTests { private const string DEFAULTCA = "letscertify"; + // TODO: This requires a valid test CA auth token to run + //private Dictionary ConfigSettings = new Dictionary(); + + //public CAFailoverTests() + //{ + // ConfigSettings = JsonConvert.DeserializeObject>(System.IO.File.ReadAllText("C:\\temp\\Certify\\TestConfigSettings.json")); + //} + private List GetTestCAs() { var caList = new List { @@ -331,4 +339,33 @@ public void TestBasicFailoverOccursOptionalLifetimeDays() Assert.IsTrue(selectedAccount.IsFailoverSelection, "Account should be marked as a failover choice"); } } + + // TODO: This test requires a valid test CA auth token to run + //[TestMethod, Description("Failover to an alternate CA when an item has repeatedly failed, with TnAuthList CA")] + //public void TestBasicFailoverOccursTnAuthList() + //{ + // // setup + // var accounts = GetTestAccounts(); + // var caList = GetTestCAs(); + + // var managedCertificate = GetBasicManagedCertificate(RequestState.Error, 3, lastCA: DEFAULTCA, + // new CertRequestConfig { + // //SubjectAlternativeNames = new List { "test.com", "anothertest.com", "www.test.com" }.ToArray(), + // AuthorityTokens = new ObservableCollection { + // new TkAuthToken{ + // Token = ConfigSettings["TestAuthToken"], + // Crl =ConfigSettings["TestAuthTokenCRL"] + // } + // } + // }); + + // // perform check + // var defaultCAAccount = accounts.FirstOrDefault(a => a.CertificateAuthorityId == DEFAULTCA && a.IsStagingAccount == managedCertificate.UseStagingMode); + + // var selectedAccount = RenewalManager.SelectCAWithFailover(caList, accounts, managedCertificate, defaultCAAccount); + + // // assert result + // Assert.IsTrue(selectedAccount.CertificateAuthorityId == "letsreluctantlyfallback", "Fallback CA should be selected"); + // Assert.IsTrue(selectedAccount.IsFailoverSelection, "Account should be marked as a failover choice"); + //} } diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertificateEditorServiceTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertificateEditorServiceTests.cs new file mode 100644 index 000000000..1c797d81a --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertificateEditorServiceTests.cs @@ -0,0 +1,261 @@ +using Certify.Models; +using Certify.Models.Shared.Validation; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Certify.Core.Tests.Unit +{ + [TestClass] + public class CertificateEditorServiceTests + { + + [TestMethod, Description("Test primary domain required")] + public void TestPrimaryDomainRequired() + { + + var item = new ManagedCertificate + { + DomainOptions = new System.Collections.ObjectModel.ObservableCollection + { + new DomainOption { Domain = "test.com", IsPrimaryDomain=false, IsSelected=true }, + new DomainOption { Domain = "www.test.com", IsPrimaryDomain=false, IsSelected=true } + }, + RequestConfig = new CertRequestConfig + { + Challenges = new System.Collections.ObjectModel.ObservableCollection + { + new CertRequestChallengeConfig + { + ChallengeType = SupportedChallengeTypes.CHALLENGE_TYPE_HTTP + } + }, + SubjectAlternativeNames = new[] { "test.com", "www.test.com" } + } + }; + + // skip auto config to that primary domain is not auto selected + var validationResult = CertificateEditorService.Validate(item, null, null, false); + + Assert.IsNotNull(validationResult); + Assert.IsFalse(validationResult.IsValid); + Assert.AreEqual(ValidationErrorCodes.PRIMARY_IDENTIFIER_REQUIRED.ToString(), validationResult.ErrorCode); + } + + [TestMethod, Description("Test primary domain too many")] + public void TestPrimaryDomainTooMany() + { + + var item = new ManagedCertificate + { + DomainOptions = new System.Collections.ObjectModel.ObservableCollection + { + new DomainOption { Domain = "test.com", IsPrimaryDomain=true, IsSelected=true }, + new DomainOption { Domain = "www.test.com", IsPrimaryDomain=true, IsSelected=true } + }, + RequestConfig = new CertRequestConfig + { + Challenges = new System.Collections.ObjectModel.ObservableCollection + { + new CertRequestChallengeConfig + { + ChallengeType = SupportedChallengeTypes.CHALLENGE_TYPE_HTTP + } + } + } + }; + + var validationResult = CertificateEditorService.Validate(item, null, null, true); + + Assert.IsNotNull(validationResult); + Assert.IsFalse(validationResult.IsValid); + Assert.AreEqual(ValidationErrorCodes.PRIMARY_IDENTIFIER_TOOMANY.ToString(), validationResult.ErrorCode); + } + + [TestMethod, Description("Test mixed wildcard label validation")] + public void TestMixedWildcardLabels() + { + + var item = new ManagedCertificate + { + DomainOptions = new System.Collections.ObjectModel.ObservableCollection + { + new DomainOption { Domain = "test.com", IsPrimaryDomain=true, IsSelected=true }, + new DomainOption { Domain = "www.test.com", IsPrimaryDomain=false,IsSelected=true }, + new DomainOption { Domain = "*.test.com", IsPrimaryDomain=false,IsSelected=true } + }, + RequestConfig = new CertRequestConfig + { + Challenges = new System.Collections.ObjectModel.ObservableCollection + { + new CertRequestChallengeConfig + { + ChallengeType = SupportedChallengeTypes.CHALLENGE_TYPE_DNS + } + } + } + }; + + var validationResult = CertificateEditorService.Validate(item, null, null, true); + + Assert.IsNotNull(validationResult); + Assert.IsFalse(validationResult.IsValid); + Assert.AreEqual(ValidationErrorCodes.MIXED_WILDCARD_WITH_LABELS.ToString(), validationResult.ErrorCode); + } + + [TestMethod, Description("Test mixed wildcard subdomain-like name allowed")] + public void TestMixedWildcardSubdomainLabels() + { + // in this example *.test.com and *.vs-test.com should be allowed as they are distinct + var item = new ManagedCertificate + { + DomainOptions = new System.Collections.ObjectModel.ObservableCollection + { + new DomainOption { Domain = "test.com", IsPrimaryDomain=true, IsSelected=true }, + new DomainOption { Domain = "vs-test.com", IsPrimaryDomain=false, IsSelected=true }, + new DomainOption { Domain = "*.test.com", IsPrimaryDomain=false,IsSelected=true }, + new DomainOption { Domain = "*.vs-test.com", IsPrimaryDomain=false,IsSelected=true } + }, + RequestConfig = new CertRequestConfig + { + Challenges = new System.Collections.ObjectModel.ObservableCollection + { + new CertRequestChallengeConfig + { + ChallengeType = SupportedChallengeTypes.CHALLENGE_TYPE_DNS, + ChallengeProvider = "DNS01.API.Route53" + } + } + } + }; + + var validationResult = CertificateEditorService.Validate(item, null, null, true); + + Assert.IsNotNull(validationResult); + Assert.IsTrue(validationResult.IsValid); + + } + + [TestMethod, Description("Test mixed wildcard subdomain-like with invalid subdomain label")] + public void TestMixedWildcardSubdomainWithInvalidLabels() + { + // in this example *.test.com and *.vs-test.com should be allowed as they are distinct + var item = new ManagedCertificate + { + DomainOptions = new System.Collections.ObjectModel.ObservableCollection + { + new DomainOption { Domain = "test.com", IsPrimaryDomain=true, IsSelected=true }, + new DomainOption { Domain = "vs-test.com", IsPrimaryDomain=false, IsSelected=true }, + new DomainOption { Domain = "*.test.com", IsPrimaryDomain=false,IsSelected=true }, + new DomainOption { Domain = "*.vs-test.com", IsPrimaryDomain=false,IsSelected=true }, + new DomainOption { Domain = "www.vs-test.com", IsPrimaryDomain=false,IsSelected=true } + }, + RequestConfig = new CertRequestConfig + { + Challenges = new System.Collections.ObjectModel.ObservableCollection + { + new CertRequestChallengeConfig + { + ChallengeType = SupportedChallengeTypes.CHALLENGE_TYPE_DNS, + ChallengeProvider = "DNS01.API.Route53" + } + } + } + }; + + var validationResult = CertificateEditorService.Validate(item, null, null, true); + + Assert.IsNotNull(validationResult); + Assert.IsFalse(validationResult.IsValid); + Assert.AreEqual(ValidationErrorCodes.MIXED_WILDCARD_WITH_LABELS.ToString(), validationResult.ErrorCode); + + } + + [TestMethod, Description("Test mixed wildcard invalid challenge type")] + public void TestMixedWildcardInvalidChallenge() + { + + var item = new ManagedCertificate + { + DomainOptions = new System.Collections.ObjectModel.ObservableCollection + { + new DomainOption { Domain = "test.com", IsPrimaryDomain=true, IsSelected=true }, + new DomainOption { Domain = "www.test.com", IsPrimaryDomain=false,IsSelected=true }, + new DomainOption { Domain = "*.test.com", IsPrimaryDomain=false,IsSelected=true } + }, + RequestConfig = new CertRequestConfig + { + Challenges = new System.Collections.ObjectModel.ObservableCollection + { + new CertRequestChallengeConfig + { + ChallengeType = SupportedChallengeTypes.CHALLENGE_TYPE_HTTP + } + } + } + }; + + var validationResult = CertificateEditorService.Validate(item, null, null, true); + + Assert.IsNotNull(validationResult); + Assert.IsFalse(validationResult.IsValid); + Assert.AreEqual(ValidationErrorCodes.CHALLENGE_TYPE_INVALID.ToString(), validationResult.ErrorCode); + } + + [TestMethod, Description("Test max CN length")] + public void TestMaxCNLength() + { + var item = new ManagedCertificate + { + DomainOptions = new System.Collections.ObjectModel.ObservableCollection + { + new DomainOption { Domain = "TherearemanyvariationsofpassagesofLoremIpsumavailablebutthemajorityhavesufferedalterationinsomeformbyinjectedhumourorrandomisedwordswhichdontlookevenslightlybelievable.com", IsPrimaryDomain=true, IsSelected=true }, + new DomainOption { Domain = "www.test.com", IsPrimaryDomain=false,IsSelected=true } + }, + RequestConfig = new CertRequestConfig + { + Challenges = new System.Collections.ObjectModel.ObservableCollection + { + new CertRequestChallengeConfig + { + ChallengeType = SupportedChallengeTypes.CHALLENGE_TYPE_HTTP + } + } + } + }; + + var validationResult = CertificateEditorService.Validate(item, null, null, true); + + Assert.IsNotNull(validationResult); + Assert.IsFalse(validationResult.IsValid); + Assert.AreEqual(ValidationErrorCodes.CN_LIMIT.ToString(), validationResult.ErrorCode); + } + + [TestMethod, Description("Test with invalid local hostname")] + public void TestInvalidHostname() + { + var item = new ManagedCertificate + { + DomainOptions = new System.Collections.ObjectModel.ObservableCollection + { + new DomainOption { Domain = "intranet.local", IsPrimaryDomain=true, IsSelected=true }, + new DomainOption { Domain = "exchange01", IsPrimaryDomain=false,IsSelected=true } + }, + RequestConfig = new CertRequestConfig + { + Challenges = new System.Collections.ObjectModel.ObservableCollection + { + new CertRequestChallengeConfig + { + ChallengeType = SupportedChallengeTypes.CHALLENGE_TYPE_HTTP + } + } + } + }; + + var validationResult = CertificateEditorService.Validate(item, null, null, true); + + Assert.IsNotNull(validationResult); + Assert.IsFalse(validationResult.IsValid); + Assert.AreEqual(ValidationErrorCodes.INVALID_HOSTNAME.ToString(), validationResult.ErrorCode); + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertificateOperationTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertificateOperationTests.cs new file mode 100644 index 000000000..231022096 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertificateOperationTests.cs @@ -0,0 +1,153 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using Certify.Management; +using Certify.Models; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Certify.Core.Tests.Unit +{ + [TestClass] + public class CertificateOperationTests + { + [TestMethod, Description("Test self signed cert")] + public void TestSelfSignedCertCreate() + { + + var cert = CertificateManager.GenerateSelfSignedCertificate("test.com", new DateTime(1934, 01, 01), new DateTime(1934, 03, 01), suffix: "[Certify](test)"); + Assert.IsNotNull(cert); + } + + [TestMethod, Description("Test self signed cert storage")] + public void TestSelfSignedCertCreateAndStore() + { + + var cert = CertificateManager.GenerateSelfSignedCertificate("test.com", new DateTime(1934, 01, 01), new DateTime(1934, 03, 01), suffix: "[Certify](test)"); + Assert.IsNotNull(cert); + + CertificateManager.StoreCertificate(cert, CertificateManager.DEFAULT_STORE_NAME); + + var storedCert = CertificateManager.GetCertificateByThumbprint(cert.Thumbprint, CertificateManager.DEFAULT_STORE_NAME); + Assert.IsNotNull(storedCert); + + CertificateManager.RemoveCertificate(storedCert, CertificateManager.DEFAULT_STORE_NAME); + } + + [TestMethod, Description("Test localhost cert")] + public void TestSelfSignedLocalhostCertCreateAndStore() + { + + var cert = CertificateManager.GenerateSelfSignedCertificate("localhost", DateTime.UtcNow, DateTime.UtcNow.AddDays(30), suffix: "[Certify](test)"); + Assert.IsNotNull(cert); + + CertificateManager.StoreCertificate(cert, CertificateManager.DEFAULT_STORE_NAME); + + var storedCert = CertificateManager.GetCertificateByThumbprint(cert.Thumbprint, CertificateManager.DEFAULT_STORE_NAME); + Assert.IsNotNull(storedCert); + + CertificateManager.RemoveCertificate(storedCert, CertificateManager.DEFAULT_STORE_NAME); + } + + [TestMethod, Description("Test get cert RSA private key file path")] + public void TestGetRSAPrivateKeyPath() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Debug.WriteLine("Test only valid on Windows, skipping"); + return; + } + + var cert = CertificateManager.GenerateSelfSignedCertificate("localhost", DateTime.UtcNow, DateTime.UtcNow.AddDays(30), suffix: "[Certify](test)", keyType: StandardKeyTypes.RSA256); + + CertificateManager.StoreCertificate(cert, CertificateManager.DEFAULT_STORE_NAME); + + var storedCert = CertificateManager.GetCertificateByThumbprint(cert.Thumbprint, CertificateManager.DEFAULT_STORE_NAME); + Assert.IsNotNull(storedCert); + + try + { + var path = CertificateManager.GetCertificatePrivateKeyPath(storedCert); + Assert.IsNotNull(path); + } + finally + { + CertificateManager.RemoveCertificate(storedCert, CertificateManager.DEFAULT_STORE_NAME); + } + } + + [TestMethod, Description("Test get cert ECDSA private key file path")] + public void TestGetECDSAPrivateKeyPath() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Debug.WriteLine("Test only valid on Windows, skipping"); + return; + } + + var cert = CertificateManager.GenerateSelfSignedCertificate("localhost", DateTime.UtcNow, DateTime.UtcNow.AddDays(30), suffix: "[Certify](test)", keyType: StandardKeyTypes.ECDSA256); + + CertificateManager.StoreCertificate(cert, CertificateManager.DEFAULT_STORE_NAME); + + var storedCert = CertificateManager.GetCertificateByThumbprint(cert.Thumbprint, CertificateManager.DEFAULT_STORE_NAME); + Assert.IsNotNull(storedCert); + + try + { + var path = CertificateManager.GetCertificatePrivateKeyPath(storedCert); + Assert.IsNotNull(path); + } + finally + { + CertificateManager.RemoveCertificate(storedCert, CertificateManager.DEFAULT_STORE_NAME); + } + } + + [TestMethod, Description("Test private key set ACL")] + [DataTestMethod] + [DataRow("NT AUTHORITY\\LOCAL SERVICE", StandardKeyTypes.RSA256, "read", true, "RSA Key Type, Read")] + [DataRow("NT AUTHORITY\\LOCAL SERVICE", StandardKeyTypes.RSA256, "fullcontrol", true, "RSA Key Type, Full Control")] + [DataRow("NT AUTHORITY\\LOCAL SERVICE", StandardKeyTypes.ECDSA256, "read", true, "ECDSA Key Type, Read")] + [DataRow("NT AUTHORITY\\LOCAL SERVICE", StandardKeyTypes.ECDSA256, "fullcontrol", true, "ECDSA Key Type, Full Control")] + [DataRow("NT AUTHORITY\\MadeUpUser", StandardKeyTypes.ECDSA256, "fullcontrol", false, "ECDSA Key Type, Full Control, Invalid User")] + public void TestSetACLOnPrivateKey(string account, string keyType, string fileSystemRights, bool isUserValid, string testDescription) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Debug.WriteLine("Test only valid on Windows, skipping"); + return; + } + + var log = new Loggy(LoggerFactory.Create(builder => builder.AddDebug()).CreateLogger()); + + var cert = CertificateManager.GenerateSelfSignedCertificate("localhost", DateTime.UtcNow, DateTime.UtcNow.AddDays(30), suffix: "[Certify](test)", keyType: keyType); + + CertificateManager.StoreCertificate(cert, CertificateManager.DEFAULT_STORE_NAME); + + var storedCert = CertificateManager.GetCertificateByThumbprint(cert.Thumbprint, CertificateManager.DEFAULT_STORE_NAME); + Assert.IsNotNull(storedCert); + + try + { + + var success = CertificateManager.GrantUserAccessToCertificatePrivateKey(storedCert, account, fileSystemRights: fileSystemRights, log); + + if (isUserValid) + { + Assert.IsTrue(success, "Updating the ACL for the private key should succeed"); + + var hasAccess = CertificateManager.HasUserAccessToCertificatePrivateKey(storedCert, account, fileSystemRights: fileSystemRights, log); + Assert.IsTrue(hasAccess, "User should have the required access on the private key"); + } + else + { + Assert.IsFalse(success, "Updating the ACL for the private key should fail due to invalid user specified"); + } + } + finally + { + CertificateManager.RemoveCertificate(storedCert, CertificateManager.DEFAULT_STORE_NAME); + } + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertifyManagerAccountTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertifyManagerAccountTests.cs new file mode 100644 index 000000000..4af5bac94 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertifyManagerAccountTests.cs @@ -0,0 +1,1173 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Certify.ACME.Anvil; +using Certify.Management; +using Certify.Models; +using Certify.Providers.ACME.Anvil; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Volumes; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; + +namespace Certify.Core.Tests.Unit +{ + [TestClass] + public class CertifyManagerAccountTests + { + private static readonly bool _isContainer = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true"; + private static readonly bool _isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + private static readonly string _winRunnerTempDir = "C:\\Temp\\.step"; + private static string _caDomain; + private static int _caPort; + private static IContainer _caContainer; + private static IVolume _stepVolume; + private static Loggy _log; + private CertifyManager _certifyManager; + private CertificateAuthority _customCa; + private AccountDetails _customCaAccount; + + [ClassInitialize] + public static async Task ClassInit(TestContext context) + { + + _log = new Loggy(LoggerFactory.Create(builder => builder.AddDebug()).CreateLogger()); + + _caDomain = _isContainer ? "step-ca" : "localhost"; + _caPort = 9000; + + await BootstrapStepCa(); + await CheckCustomCaIsRunning(); + } + + [TestInitialize] + public async Task TestInit() + { + _certifyManager = new CertifyManager(); + _certifyManager.Init().Wait(); + + await AddCustomCa(); + await AddNewCustomCaAccount(); + await CheckForExistingLeAccount(); + } + + [TestCleanup] + public async Task Cleanup() + { + if (_customCaAccount != null) + { + await _certifyManager.RemoveAccount(_customCaAccount.StorageKey, true); + } + + if (_customCa != null) + { + await _certifyManager.RemoveCertificateAuthority(_customCa.Id); + } + + _certifyManager?.Dispose(); + } + + [ClassCleanup(ClassCleanupBehavior.EndOfClass)] + public static async Task ClassCleanup() + { + if (!_isContainer) + { + await _caContainer.DisposeAsync(); + if (_stepVolume != null) + { + await _stepVolume.DeleteAsync(); + await _stepVolume.DisposeAsync(); + } + else + { + Directory.Delete(_winRunnerTempDir, true); + } + } + + var stepConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".step", "config"); + if (Directory.Exists(stepConfigPath)) + { + Directory.Delete(stepConfigPath, true); + } + + var stepCertsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".step", "certs"); + if (Directory.Exists(stepCertsPath)) + { + Directory.Delete(stepCertsPath, true); + } + } + + private static async Task BootstrapStepCa() + { + string stepCaFingerprint; + + // If running in a container + if (_isContainer) + { + // Step container volume path containing step-ca config based on OS + var configPath = _isWindows ? "C:\\step_share\\config\\defaults.json" : "/mnt/step_share/config/defaults.json"; + + // Wait till step-ca config file is written + while (!File.Exists(configPath)) { } + + // Read step-ca fingerprint from config file + var stepCaConfigJson = JsonReader.ReadFile(configPath); + stepCaFingerprint = stepCaConfigJson.fingerprint; + } + else + { + var dockerInfo = RunCommand("docker", "info --format \"{{ .OSType }}\"", "Get Docker Info"); + var runningWindowsDockerEngine = dockerInfo.output.Contains("windows"); + + // Start new step-ca container + await StartStepCaContainer(runningWindowsDockerEngine); + + // Read step-ca fingerprint from config file + if (_isWindows && runningWindowsDockerEngine) + { + // Read step-ca fingerprint from config file + var stepCaConfigJson = JsonReader.ReadFile($"{_winRunnerTempDir}\\config\\defaults.json"); + stepCaFingerprint = stepCaConfigJson.fingerprint; + } + else + { + var stepCaConfigBytes = await _caContainer.ReadFileAsync("/home/step/config/defaults.json"); + var stepCaConfigJson = JsonReader.ReadBytes(stepCaConfigBytes); + stepCaFingerprint = stepCaConfigJson.fingerprint; + } + } + + // Run bootstrap command + var args = $"ca bootstrap -f --ca-url https://{_caDomain}:{_caPort} --fingerprint {stepCaFingerprint}"; + RunCommand("step", args, "Bootstrap Step CA Script", 1000 * 30); + } + + private static async Task StartStepCaContainer(bool runningWindowsDockerEngine) + { + try + { + if (_isWindows && runningWindowsDockerEngine) + { + if (!Directory.Exists(_winRunnerTempDir)) + { + Directory.CreateDirectory(_winRunnerTempDir); + } + + // Create new step-ca container + _caContainer = new ContainerBuilder() + .WithName("step-ca") + // Set the image for the container to "webprofusion/step-ca-win:latest". + .WithImage("webprofusion/step-ca-win:latest") + .WithBindMount(_winRunnerTempDir, "C:\\Users\\ContainerUser\\.step") + // Bind port 9000 of the container to port 9000 on the host. + .WithPortBinding(_caPort) + .WithEnvironment("DOCKER_STEPCA_INIT_NAME", "Smallstep") + .WithEnvironment("DOCKER_STEPCA_INIT_DNS_NAMES", _caDomain) + .WithEnvironment("DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT", "true") + .WithEnvironment("DOCKER_STEPCA_INIT_ACME", "true") + // Wait until the HTTPS endpoint of the container is available. + .WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged($"Serving HTTPS on :{_caPort} ...")) + // Build the container configuration. + .Build(); + } + else + { + // Create new volume for step-ca container + _stepVolume = new VolumeBuilder().WithName("step").Build(); + await _stepVolume.CreateAsync(); + + // Create new step-ca container + _caContainer = new ContainerBuilder() + .WithName("step-ca") + // Set the image for the container to "smallstep/step-ca:latest". + .WithImage("smallstep/step-ca:latest") + .WithVolumeMount(_stepVolume, "/home/step") + // Bind port 9000 of the container to port 9000 on the host. + .WithPortBinding(_caPort) + .WithEnvironment("DOCKER_STEPCA_INIT_NAME", "Smallstep") + .WithEnvironment("DOCKER_STEPCA_INIT_DNS_NAMES", _caDomain) + .WithEnvironment("DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT", "true") + .WithEnvironment("DOCKER_STEPCA_INIT_ACME", "true") + // Wait until the HTTPS endpoint of the container is available. + .WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged($"Serving HTTPS on :{_caPort} ...")) + // Build the container configuration. + .Build(); + } + + // Start step-ca container + await _caContainer.StartAsync(); + } + catch (Exception) + { + throw; + } + } + + private static class JsonReader + { + public static T ReadFile(string filePath) + { + using (var streamReader = new StreamReader(File.Open(filePath, FileMode.Open))) + { + using (var jsonTextReader = new JsonTextReader(streamReader)) + { + var serializer = new JsonSerializer(); + return serializer.Deserialize(jsonTextReader); + } + } + } + + public static T ReadBytes(byte[] bytes) + { + using (var stringReader = new StringReader(Encoding.UTF8.GetString(bytes))) + { + using (var jsonTextReader = new JsonTextReader(stringReader)) + { + var serializer = new JsonSerializer(); + return serializer.Deserialize(jsonTextReader); + } + } + } + } + + private class StepCaConfig + { + [JsonProperty(PropertyName = "ca-url")] + public string ca_url = string.Empty; + [JsonProperty(PropertyName = "ca-config")] + public string ca_config = string.Empty; + public string fingerprint = string.Empty; + public string root = string.Empty; + } + + private static CommandOutput RunCommand(string program, string args, string description = null, int timeoutMS = Timeout.Infinite) + { + if (description == null) { description = string.Concat(program, " ", args); } + + var output = ""; + var errorOutput = ""; + + var startInfo = new ProcessStartInfo() + { + FileName = program, + Arguments = args, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + var process = new Process() { StartInfo = startInfo }; + + process.OutputDataReceived += (obj, a) => + { + if (!string.IsNullOrWhiteSpace(a.Data)) + { + _log.Information(a.Data); + output += a.Data; + } + }; + + process.ErrorDataReceived += (obj, a) => + { + if (!string.IsNullOrWhiteSpace(a.Data)) + { + _log.Error($"Error: {a.Data}"); + errorOutput += a.Data; + } + }; + + try + { + process.Start(); + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + process.WaitForExit(timeoutMS); + } + catch (Exception exp) + { + _log.Error($"Error Running ${description}: " + exp.ToString()); + throw; + } + + _log.Information($"{description} is Finished"); + + return new CommandOutput { errorOutput = errorOutput, output = output, exitCode = process.ExitCode }; + } + + private struct CommandOutput + { + public string errorOutput { get; set; } + public string output { get; set; } + public int exitCode { get; set; } + } + + private static async Task CheckCustomCaIsRunning() + { + var httpHandler = new HttpClientHandler(); + + httpHandler.ServerCertificateCustomValidationCallback = (message, certificate, chain, sslPolicyErrors) => true; + + var loggingHandler = new LoggingHandler(httpHandler, _log, maxRequestsPerSecond: 2); + var stepCaHttp = new HttpClient(loggingHandler); + var healthRes = await stepCaHttp.GetAsync($"https://{_caDomain}:{_caPort}/health"); + var healthResStr = await healthRes.Content.ReadAsStringAsync(); + Assert.AreEqual("{\"status\":\"ok\"}\n", (healthResStr)); + } + + private async Task AddCustomCa() + { + _customCa = new CertificateAuthority + { + Id = "step-ca", + Title = "Custom Step CA", + IsCustom = true, + IsEnabled = true, + APIType = CertAuthorityAPIType.ACME_V2.ToString(), + ProductionAPIEndpoint = $"https://{_caDomain}:{_caPort}/acme/acme/directory", + StagingAPIEndpoint = $"https://{_caDomain}:{_caPort}/acme/acme/directory", + RequiresEmailAddress = true, + AllowUntrustedTls = true, + SANLimit = 100, + StandardExpiryDays = 90, + SupportedFeatures = new List + { + CertAuthoritySupportedRequests.DOMAIN_SINGLE.ToString(), + CertAuthoritySupportedRequests.DOMAIN_MULTIPLE_SAN.ToString(), + CertAuthoritySupportedRequests.DOMAIN_WILDCARD.ToString() + }, + SupportedKeyTypes = new List + { + StandardKeyTypes.ECDSA256, + } + }; + var updateCaRes = await _certifyManager.UpdateCertificateAuthority(_customCa); + Assert.IsTrue(updateCaRes.IsSuccess, $"Expected Custom CA creation for CA with ID {_customCa.Id} to be successful"); + } + + private async Task AddNewCustomCaAccount() + { + if (_customCa?.Id != null) + { + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com", + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegistration.EmailAddress}"); + _customCaAccount = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegistration.EmailAddress); + } + } + + private async Task CheckForExistingLeAccount() + { + if ((await _certifyManager.GetAccountRegistrations()).Find(a => a.CertificateAuthorityId == "letsencrypt.org") == null) + { + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = "letsencrypt.org", + EmailAddress = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com", + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegistration.EmailAddress}"); + } + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetAccountDetails()")] + public async Task TestCertifyManagerGetAccountDetails() + { + var dummyManagedCert = (new ManagedCertificate { UseStagingMode = true }); + var caAccount = await _certifyManager.GetAccountDetails(dummyManagedCert); + Assert.IsNotNull(caAccount, "Expected result of CertifyManager.GetAccountDetails() to not be null"); + } + + [TestMethod, Description("Test for using CertifyManager.GetAccountDetails() when passed in managed certificate is null")] + public async Task TestCertifyManagerGetAccountDetailsNullItem() + { + var caAccount = await _certifyManager.GetAccountDetails(null); + Assert.IsNotNull(caAccount, "Expected result of CertifyManager.GetAccountDetails() to not be null"); + } + + [TestMethod, Description("Test for using CertifyManager.GetAccountDetails() when allowCache is false")] + public async Task TestCertifyManagerGetAccountDetailsAllowCacheFalse() + { + var dummyManagedCert = (new ManagedCertificate { UseStagingMode = true }); + var caAccount = await _certifyManager.GetAccountDetails(dummyManagedCert, false); + Assert.IsNotNull(caAccount, "Expected result of CertifyManager.GetAccountDetails() to not be null"); + } + + [TestMethod, Description("Test for using CertifyManager.GetAccountDetails() when CertificateAuthorityId is defined in passed ManagedCertificate")] + public async Task TestCertifyManagerGetAccountDetailsDefinedCertificateAuthorityId() + { + var dummyManagedCert = (new ManagedCertificate { UseStagingMode = true, CertificateAuthorityId = _customCa.Id }); + var caAccount = await _certifyManager.GetAccountDetails(dummyManagedCert); + Assert.IsNotNull(caAccount, "Expected result of CertifyManager.GetAccountDetails() to not be null"); + Assert.AreEqual(_customCa.Id, caAccount.CertificateAuthorityId, $"Unexpected certificate authority id '{caAccount.CertificateAuthorityId}'"); + } + + [TestMethod, Description("Test for using CertifyManager.GetAccountDetails() when OverrideAccountDetails is defined in CertifyManager")] + public async Task TestCertifyManagerGetAccountDetailsDefinedOverrideAccountDetails() + { + + var account = new AccountDetails + { + AccountKey = "", + AccountURI = "", + Title = "Dev", + Email = "test@certifytheweb.com", + CertificateAuthorityId = _customCa.Id, + StorageKey = "dev", + IsStagingAccount = true, + }; + _certifyManager.OverrideAccountDetails = account; + + var dummyManagedCert = (new ManagedCertificate { UseStagingMode = true }); + var caAccount = await _certifyManager.GetAccountDetails(dummyManagedCert); + Assert.IsNotNull(caAccount, "Expected result of CertifyManager.GetAccountDetails() to not be null"); + Assert.AreEqual("test@certifytheweb.com", caAccount.Email); + + _certifyManager.OverrideAccountDetails = null; + } + + [TestMethod, Description("Test for using CertifyManager.GetAccountDetails() when there is no matching account")] + public async Task TestCertifyManagerGetAccountDetailsNoMatches() + { + var dummyManagedCert = (new ManagedCertificate { UseStagingMode = true, CertificateAuthorityId = "sectigo-ev" }); + var caAccount = await _certifyManager.GetAccountDetails(dummyManagedCert); + Assert.IsNull(caAccount, "Expected result of CertifyManager.GetAccountDetails() to be null"); + } + + [TestMethod, Description("Test for using CertifyManager.GetAccountDetails() when it is a resume order")] + public async Task TestCertifyManagerGetAccountDetailsIsResumeOrder() + { + var dummyManagedCert = (new ManagedCertificate { UseStagingMode = true, CertificateAuthorityId = "letsencrypt.org", LastAttemptedCA = "zerossl.com" }); + var caAccount = await _certifyManager.GetAccountDetails(dummyManagedCert, true, false, true); + Assert.IsNotNull(caAccount, "Expected result of CertifyManager.GetAccountDetails() to not be null"); + } + + [TestMethod, Description("Test for using CertifyManager.GetAccountDetails() when allowFailover is true")] + public async Task TestCertifyManagerGetAccountDetailsAllowFailover() + { + var dummyManagedCert = (new ManagedCertificate { UseStagingMode = true }); + var caAccount = await _certifyManager.GetAccountDetails(dummyManagedCert, true, true); + Assert.IsNotNull(caAccount, "Expected result of CertifyManager.GetAccountDetails() to not be null"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.AddAccount()")] + public async Task TestCertifyManagerAddAccount() + { + AccountDetails accountDetails = null; + try + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + } + finally + { + // Cleanup added account + if (accountDetails != null) + { + await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + } + } + } + + [TestMethod, Description("Happy path test for using CertifyManager.RemoveAccount()")] + public async Task TestCertifyManagerRemoveAccount() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + + // Remove account + var removeAccountRes = await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + Assert.IsTrue(removeAccountRes.IsSuccess, $"Expected account removal to be successful for {contactRegEmail}"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNull(accountDetails, $"Did not expect an account for {contactRegEmail} to be returned by CertifyManager.GetAccountRegistrations()"); + } + + [TestMethod, Description("Test for CertifyManager.AddAccount() when AgreedToTermsAndConditions is false")] + public async Task TestCertifyManagerAddAccountDidNotAgree() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = false, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Attempt to add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsFalse(addAccountRes.IsSuccess, $"Expected account creation to be unsuccessful for {contactRegEmail}"); + Assert.AreEqual(addAccountRes.Message, "You must agree to the terms and conditions of the Certificate Authority to register with them.", "Unexpected error message"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNull(accountDetails, $"Did not expect an account for {contactRegEmail} to be returned by CertifyManager.GetAccountRegistrations()"); + } + + [TestMethod, Description("Test for CertifyManager.AddAccount() when CertificateAuthorityId is a bad value")] + public async Task TestCertifyManagerAddAccountBadCaId() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = "bad_ca.org", + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Attempt to add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsFalse(addAccountRes.IsSuccess, $"Expected account creation to be unsuccessful for {contactRegEmail}"); + Assert.AreEqual(addAccountRes.Message, "Invalid Certificate Authority specified.", "Unexpected error message"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNull(accountDetails, $"Did not expect an account for {contactRegEmail} to be returned by CertifyManager.GetAccountRegistrations()"); + } + + [TestMethod, Description("Test for CertifyManager.AddAccount() when ImportedAccountKey is a blank value")] + public async Task TestCertifyManagerAddAccountMissingAccountKey() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = _customCaAccount.AccountURI, + IsStaging = true + }; + + // Attempt to add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsFalse(addAccountRes.IsSuccess, $"Expected account creation to be unsuccessful for {contactRegEmail}"); + Assert.AreEqual(addAccountRes.Message, "To import account details both the existing account URI and account key in PEM format are required. ", "Unexpected error message"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNull(accountDetails, $"Did not expect an account for {contactRegEmail} to be returned by CertifyManager.GetAccountRegistrations()"); + } + + [TestMethod, Description("Test for CertifyManager.AddAccount() when ImportedAccountURI is a blank value")] + public async Task TestCertifyManagerAddAccountMissingAccountUri() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = _customCaAccount.AccountKey, + ImportedAccountURI = "", + IsStaging = true + }; + + // Attempt to add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsFalse(addAccountRes.IsSuccess, $"Expected account creation to be unsuccessful for {contactRegEmail}"); + Assert.AreEqual(addAccountRes.Message, "To import account details both the existing account URI and account key in PEM format are required. ", "Unexpected error message"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNull(accountDetails, $"Did not expect an account for {contactRegEmail} to be returned by CertifyManager.GetAccountRegistrations()"); + } + + [TestMethod, Description("Test for CertifyManager.AddAccount() when ImportedAccountKey is a bad value")] + public async Task TestCertifyManagerAddAccountBadAccountKey() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "tHiSiSnOtApEm", + ImportedAccountURI = _customCaAccount.AccountURI, + IsStaging = true + }; + + // Attempt to add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsFalse(addAccountRes.IsSuccess, $"Expected account creation to be unsuccessful for {contactRegEmail}"); + Assert.AreEqual(addAccountRes.Message, "The provided account key was invalid or not supported for import. A PEM (text) format RSA or ECDA private key is required.", "Unexpected error message"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNull(accountDetails, $"Did not expect an account for {contactRegEmail} to be returned by CertifyManager.GetAccountRegistrations()"); + } + + [TestMethod, Description("Test for CertifyManager.AddAccount() when ImportedAccountKey and ImportedAccountURI are valid")] + public async Task TestCertifyManagerAddAccountImport() + { + // Remove account + var removeAccountRes = await _certifyManager.RemoveAccount(_customCaAccount.StorageKey); + Assert.IsTrue(removeAccountRes.IsSuccess, $"Expected account removal to be successful for {_customCaAccount.Email}"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == _customCaAccount.Email); + Assert.IsNull(accountDetails, $"Did not expect an account for {_customCaAccount.Email} to be returned by CertifyManager.GetAccountRegistrations()"); + + // Setup account registration info + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = _customCaAccount.Email, + ImportedAccountKey = _customCaAccount.AccountKey, + ImportedAccountURI = _customCaAccount.AccountURI, + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {_customCaAccount.Email}"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == _customCaAccount.Email); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {_customCaAccount.Email}"); + } + + [TestMethod, Description("Test for using CertifyManager.RemoveAccount() with a bad storage key")] + public async Task TestCertifyManagerRemoveAccountBadKey() + { + // Attempt to remove account with bad storage key + var badStorageKey = "8da1a662-18ed-4787-a0b1-dc36db5a866b"; + var removeAccountRes = await _certifyManager.RemoveAccount(badStorageKey, true); + Assert.IsFalse(removeAccountRes.IsSuccess, $"Expected account removal to be unsuccessful for storage key {badStorageKey}"); + Assert.AreEqual(removeAccountRes.Message, "Account not found.", "Unexpected error message"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.GetAccountAndACMEProvider()")] + public async Task TestCertifyManagerGetAccountAndAcmeProvider() + { + AccountDetails accountDetails = null; + try + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + + var (account, certAuthority, acmeProvider) = await _certifyManager.GetAccountAndACMEProvider(accountDetails.StorageKey); + Assert.IsNotNull(account, $"Expected account returned by GetAccountAndACMEProvider() to not be null for storage key {accountDetails.StorageKey}"); + Assert.IsNotNull(certAuthority, $"Expected certAuthority returned by GetAccountAndACMEProvider() to not be null for storage key {accountDetails.StorageKey}"); + Assert.IsNotNull(acmeProvider, $"Expected acmeProvider returned by GetAccountAndACMEProvider() to not be null for storage key {accountDetails.StorageKey}"); + } + finally + { + // Cleanup added account + if (accountDetails != null) + { + await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + } + } + } + + [TestMethod, Description("Test for using CertifyManager.GetAccountAndACMEProvider() with a bad storage key")] + public async Task TestCertifyManagerGetAccountAndAcmeProviderBadKey() + { + // Attempt to retrieve account with bad storage key + var badStorageKey = "8da1a662-18ed-4787-a0b1-dc36db5a866b"; + var (account, certAuthority, acmeProvider) = await _certifyManager.GetAccountAndACMEProvider(badStorageKey); + Assert.IsNull(account, $"Expected account returned by GetAccountAndACMEProvider() to be null for storage key {badStorageKey}"); + Assert.IsNull(certAuthority, $"Expected certAuthority returned by GetAccountAndACMEProvider() to be null for storage key {badStorageKey}"); + Assert.IsNull(acmeProvider, $"Expected acmeProvider returned by GetAccountAndACMEProvider() to be null for storage key {badStorageKey}"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.UpdateAccountContact()")] + public async Task TestCertifyManagerUpdateAccountContact() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + + // Update account + var newContactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var newContactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = newContactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + var updateAccountRes = await _certifyManager.UpdateAccountContact(accountDetails.StorageKey, newContactRegistration); + Assert.IsTrue(updateAccountRes.IsSuccess, $"Expected account creation to be successful for {newContactRegEmail}"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == newContactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {newContactRegEmail}"); + + // Cleanup account + await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + } + + [TestMethod, Description("Test for using CertifyManager.UpdateAccountContact() when AgreedToTermsAndConditions is false")] + public async Task TestCertifyManagerUpdateAccountContactNoAgreement() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + + // Update account + var newContactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var newContactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = false, + CertificateAuthorityId = _customCa.Id, + EmailAddress = newContactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + var updateAccountRes = await _certifyManager.UpdateAccountContact(accountDetails.StorageKey, newContactRegistration); + Assert.IsFalse(updateAccountRes.IsSuccess, $"Expected account creation to not be successful for {newContactRegEmail}"); + Assert.AreEqual(updateAccountRes.Message, "You must agree to the terms and conditions of the Certificate Authority to register with them.", "Unexpected error message"); + var newAccountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == newContactRegEmail); + Assert.IsNull(newAccountDetails, $"Expected none of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {newContactRegEmail}"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + + // Cleanup account + await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + } + + [TestMethod, Description("Test for using CertifyManager.UpdateAccountContact() when passed storage key doesn't exist")] + public async Task TestCertifyManagerUpdateAccountContactBadKey() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + + // Update account + var newContactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var newContactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = newContactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + var badStorageKey = Guid.NewGuid().ToString(); + var updateAccountRes = await _certifyManager.UpdateAccountContact(badStorageKey, newContactRegistration); + Assert.IsFalse(updateAccountRes.IsSuccess, $"Expected account creation to not be successful for {newContactRegEmail}"); + Assert.AreEqual(updateAccountRes.Message, "Account not found.", "Unexpected error message"); + var newAccountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == newContactRegEmail); + Assert.IsNull(newAccountDetails, $"Expected none of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {newContactRegEmail}"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + + // Cleanup account + await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + } + + [TestMethod, Description("Happy path test for using CertifyManager.ChangeAccountKey()")] + public async Task TestCertifyManagerChangeAccountKey() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = "letsencrypt.org", + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + var firstAccountKey = accountDetails.AccountKey; + + // Update account key + var newKeyPem = KeyFactory.NewKey(KeyAlgorithm.ES256).ToPem(); + var changeAccountKeyRes = await _certifyManager.ChangeAccountKey(accountDetails.StorageKey, newKeyPem); + Assert.IsTrue(changeAccountKeyRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + Assert.AreEqual(changeAccountKeyRes.Message, "Completed account key rollover", "Unexpected message for CertifyManager.GetAccountRegistrations() success"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + Assert.AreNotEqual(firstAccountKey, accountDetails.AccountKey, $"Expected account key for {contactRegEmail} to have changed after successful CertifyManager.ChangeAccountKey()"); + + // Cleanup account + await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + } + + [TestMethod, Description("Happy path test for using CertifyManager.ChangeAccountKey() with no passed in new account key")] + public async Task TestCertifyManagerChangeAccountKeyNull() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = "letsencrypt.org", + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + var firstAccountKey = accountDetails.AccountKey; + + // Update account key + var changeAccountKeyRes = await _certifyManager.ChangeAccountKey(accountDetails.StorageKey); + Assert.IsTrue(changeAccountKeyRes.IsSuccess, $"Expected account creation to be successful for {contactRegEmail}"); + Assert.AreEqual(changeAccountKeyRes.Message, "Completed account key rollover", "Unexpected message for CertifyManager.GetAccountRegistrations() success"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + Assert.AreNotEqual(firstAccountKey, accountDetails.AccountKey, $"Expected account key for {contactRegEmail} to have changed after successful CertifyManager.ChangeAccountKey()"); + + // Cleanup account + await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + } + + [TestMethod, Description("Test for using CertifyManager.ChangeAccountKey() when passed an invalid storage key")] + public async Task TestCertifyManagerChangeAccountKeyBadStorageKey() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account key update to be successful for {contactRegEmail}"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + var firstAccountKey = accountDetails.AccountKey; + + // Attempt to update account key + var newKeyPem = KeyFactory.NewKey(KeyAlgorithm.ES256).ToPem(); + var badStorageKey = Guid.NewGuid().ToString(); + var changeAccountKeyRes = await _certifyManager.ChangeAccountKey(badStorageKey, newKeyPem); + Assert.IsFalse(changeAccountKeyRes.IsSuccess, $"Expected account key update to be unsuccessful for {contactRegEmail}"); + Assert.AreEqual(changeAccountKeyRes.Message, "Failed to match account to known ACME provider", "Unexpected error message for CertifyManager.GetAccountRegistrations() failure"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + Assert.AreEqual(firstAccountKey, accountDetails.AccountKey, $"Expected account key for {contactRegEmail} not to have changed after unsuccessful CertifyManager.ChangeAccountKey()"); + + // Cleanup account + await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + } + + [TestMethod, Description("Test for using CertifyManager.ChangeAccountKey() when passed an invalid new account key")] + public async Task TestCertifyManagerChangeAccountKeyBadAccountKey() + { + // Setup account registration info + var contactRegEmail = "admin." + Guid.NewGuid().ToString().Substring(0, 6) + "@test.com"; + var contactRegistration = new ContactRegistration + { + AgreedToTermsAndConditions = true, + CertificateAuthorityId = _customCa.Id, + EmailAddress = contactRegEmail, + ImportedAccountKey = "", + ImportedAccountURI = "", + IsStaging = true + }; + + // Add account + var addAccountRes = await _certifyManager.AddAccount(contactRegistration); + Assert.IsTrue(addAccountRes.IsSuccess, $"Expected account key update to be successful for {contactRegEmail}"); + var accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + var firstAccountKey = accountDetails.AccountKey; + + // Attempt to update account key + var badKeyPem = KeyFactory.NewKey(KeyAlgorithm.ES256).ToPem().Substring(20); + var changeAccountKeyRes = await _certifyManager.ChangeAccountKey(accountDetails.StorageKey, badKeyPem); + Assert.IsFalse(changeAccountKeyRes.IsSuccess, $"Expected account key update to be unsuccessful for {contactRegEmail}"); + Assert.AreEqual(changeAccountKeyRes.Message, "Failed to use provide key for account rollover", "Unexpected error message for CertifyManager.GetAccountRegistrations() failure"); + accountDetails = (await _certifyManager.GetAccountRegistrations()).Find(a => a.Email == contactRegEmail); + Assert.IsNotNull(accountDetails, $"Expected one of the accounts returned by CertifyManager.GetAccountRegistrations() to be for {contactRegEmail}"); + Assert.AreEqual(firstAccountKey, accountDetails.AccountKey, $"Expected account key for {contactRegEmail} not to have changed after unsuccessful CertifyManager.ChangeAccountKey()"); + + // Cleanup account + await _certifyManager.RemoveAccount(accountDetails.StorageKey, true); + } + + [TestMethod, Description("Happy path test for using CertifyManager.UpdateCertificateAuthority() to add a new custom CA")] + public async Task TestCertifyManagerUpdateCertificateAuthorityAdd() + { + CertificateAuthority newCustomCa = null; + try + { + newCustomCa = new CertificateAuthority + { + Id = Guid.NewGuid().ToString(), + Title = "Test Custom CA", + IsCustom = true, + IsEnabled = true, + SupportedFeatures = new List + { + CertAuthoritySupportedRequests.DOMAIN_SINGLE.ToString(), + } + }; + var updateCaRes = await _certifyManager.UpdateCertificateAuthority(newCustomCa); + Assert.IsTrue(updateCaRes.IsSuccess, $"Expected Custom CA creation for CA with ID {newCustomCa.Id} to be successful"); + Assert.AreEqual(updateCaRes.Message, "OK", "Unexpected result message for CertifyManager.UpdateCertificateAuthority() success"); + var certificateAuthorities = await _certifyManager.GetCertificateAuthorities(); + var newCaDetails = certificateAuthorities.Find(c => c.Id == newCustomCa.Id); + Assert.IsNotNull(newCaDetails, $"Expected one of the CAs returned by CertifyManager.GetCertificateAuthorities() to have an ID of {newCustomCa.Id}"); + } + finally + { + if (newCustomCa != null) + { + await _certifyManager.RemoveCertificateAuthority(newCustomCa.Id); + } + } + } + + [TestMethod, Description("Happy path test for using CertifyManager.UpdateCertificateAuthority() to update an existing custom CA")] + public async Task TestCertifyManagerUpdateCertificateAuthorityUpdate() + { + CertificateAuthority newCustomCa = null; + try + { + newCustomCa = new CertificateAuthority + { + Id = Guid.NewGuid().ToString(), + Title = "Test Custom CA", + IsCustom = true, + IsEnabled = true, + AllowInternalHostnames = false, + SupportedFeatures = new List + { + CertAuthoritySupportedRequests.DOMAIN_SINGLE.ToString(), + } + }; + + // Add new CA + var addCaRes = await _certifyManager.UpdateCertificateAuthority(newCustomCa); + Assert.IsTrue(addCaRes.IsSuccess, $"Expected Custom CA creation for CA with ID {newCustomCa.Id} to be successful"); + Assert.AreEqual(addCaRes.Message, "OK", "Unexpected result message for CertifyManager.UpdateCertificateAuthority() success"); + var certificateAuthorities = await _certifyManager.GetCertificateAuthorities(); + var newCaDetails = certificateAuthorities.Find(c => c.Id == newCustomCa.Id); + Assert.IsNotNull(newCaDetails, $"Expected one of the CAs returned by CertifyManager.GetCertificateAuthorities() to have an ID of {newCustomCa.Id}"); + Assert.IsFalse(newCaDetails.AllowInternalHostnames); + + var updatedCustomCa = new CertificateAuthority + { + Id = newCustomCa.Id, + Title = "Test Custom CA", + IsCustom = true, + IsEnabled = true, + AllowInternalHostnames = true, + SupportedFeatures = new List + { + CertAuthoritySupportedRequests.DOMAIN_SINGLE.ToString(), + } + }; + + // Update existing CA + var updateCaRes = await _certifyManager.UpdateCertificateAuthority(updatedCustomCa); + Assert.IsTrue(updateCaRes.IsSuccess, $"Expected Custom CA update for CA with ID {updatedCustomCa.Id} to be successful"); + Assert.AreEqual(updateCaRes.Message, "OK", "Unexpected result message for CertifyManager.UpdateCertificateAuthority() success"); + certificateAuthorities = await _certifyManager.GetCertificateAuthorities(); + newCaDetails = certificateAuthorities.Find(c => c.Id == updatedCustomCa.Id); + Assert.IsNotNull(newCaDetails, $"Expected one of the CAs returned by CertifyManager.GetCertificateAuthorities() to have an ID of {updatedCustomCa.Id}"); + Assert.IsTrue(newCaDetails.AllowInternalHostnames); + } + finally + { + if (newCustomCa != null) + { + await _certifyManager.RemoveCertificateAuthority(newCustomCa.Id); + } + } + } + + [TestMethod, Description("Test for using CertifyManager.UpdateCertificateAuthority() on a default CA")] + public async Task TestCertifyManagerUpdateCertificateAuthorityDefaultCa() + { + var certificateAuthorities = await _certifyManager.GetCertificateAuthorities(); + var defaultCa = certificateAuthorities.First(); + var newCustomCa = new CertificateAuthority + { + Id = defaultCa.Id, + Title = "Test Custom CA", + IsCustom = true, + IsEnabled = true, + AllowInternalHostnames = false, + SupportedFeatures = new List + { + CertAuthoritySupportedRequests.DOMAIN_SINGLE.ToString(), + } + }; + + // Attempt to update default CA + var updateCaRes = await _certifyManager.UpdateCertificateAuthority(newCustomCa); + Assert.IsFalse(updateCaRes.IsSuccess, $"Expected CA update for default CA with ID {defaultCa.Id} to be unsuccessful"); + Assert.AreEqual(updateCaRes.Message, "Default Certificate Authorities cannot be modified.", "Unexpected result message for CertifyManager.UpdateCertificateAuthority() failure"); + } + + [TestMethod, Description("Happy path test for using CertifyManager.RemoveCertificateAuthority()")] + public async Task TestCertifyManagerRemoveCertificateAuthority() + { + var newCustomCa = new CertificateAuthority + { + Id = Guid.NewGuid().ToString(), + Title = "Test Custom CA", + IsCustom = true, + IsEnabled = true, + SupportedFeatures = new List + { + CertAuthoritySupportedRequests.DOMAIN_SINGLE.ToString(), + } + }; + + // Add custom CA + var updateCaRes = await _certifyManager.UpdateCertificateAuthority(newCustomCa); + Assert.IsTrue(updateCaRes.IsSuccess, $"Expected Custom CA creation for CA with ID {newCustomCa.Id} to be successful"); + Assert.AreEqual(updateCaRes.Message, "OK", "Unexpected result message for CertifyManager.UpdateCertificateAuthority() success"); + var certificateAuthorities = await _certifyManager.GetCertificateAuthorities(); + var newCaDetails = certificateAuthorities.Find(c => c.Id == newCustomCa.Id); + Assert.IsNotNull(newCaDetails, $"Expected one of the CAs returned by CertifyManager.GetCertificateAuthorities() to have an ID of {newCustomCa.Id}"); + + // Delete custom CA + var deleteCaRes = await _certifyManager.RemoveCertificateAuthority(newCustomCa.Id); + Assert.IsTrue(deleteCaRes.IsSuccess, $"Expected Custom CA deletion for CA with ID {newCustomCa.Id} to be successful"); + Assert.AreEqual(deleteCaRes.Message, "OK", "Unexpected result message for CertifyManager.RemoveCertificateAuthority() success"); + certificateAuthorities = await _certifyManager.GetCertificateAuthorities(); + newCaDetails = certificateAuthorities.Find(c => c.Id == newCustomCa.Id); + Assert.IsNull(newCaDetails, $"Expected none of the CAs returned by CertifyManager.GetCertificateAuthorities() to have an ID of {newCustomCa.Id}"); + } + + [TestMethod, Description("Test for using CertifyManager.RemoveCertificateAuthority() when passed a bad custom CA ID")] + public async Task TestCertifyManagerRemoveCertificateAuthorityBadId() + { + var badId = Guid.NewGuid().ToString(); + + // Delete custom CA + var deleteCaRes = await _certifyManager.RemoveCertificateAuthority(badId); + Assert.IsFalse(deleteCaRes.IsSuccess, $"Expected Custom CA deletion for CA with ID {badId} to be unsuccessful"); + Assert.AreEqual(deleteCaRes.Message, $"The certificate authority {badId} was not found in the list of custom CAs and could not be removed.", "Unexpected result message for CertifyManager.RemoveCertificateAuthority() failure"); + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertifyServiceTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertifyServiceTests.cs new file mode 100644 index 000000000..d830affa0 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/CertifyServiceTests.cs @@ -0,0 +1,430 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Certify.Models; +using Certify.Models.Config; +using Certify.Shared; +using Medallion.Shell; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; + +namespace Certify.Core.Tests.Unit +{ +#if NET462 + [TestClass] + public class CertifyServiceTests + { + private HttpClient _httpClient; + private string serviceUri; + + public CertifyServiceTests() + { + var serviceConfig = SharedUtils.ServiceConfigManager.GetAppServiceConfig(); + serviceUri = $"{(serviceConfig.UseHTTPS ? "https" : "http")}://{serviceConfig.Host}:{serviceConfig.Port}"; + var httpHandler = new HttpClientHandler { UseDefaultCredentials = true }; + _httpClient = new HttpClient(httpHandler); + _httpClient.DefaultRequestHeaders.Add("User-Agent", "Certify/App"); + _httpClient.BaseAddress = new Uri(serviceUri + "/api/"); + } + + private async Task StartCertifyService(string args = "") + { + Command certifyService; + if (args == "") + { + certifyService = Command.Run(".\\Certify.Service.exe"); + await Task.Delay(2000); + } + else + { + certifyService = Command.Run(".\\Certify.Service.exe", args); + } + + return certifyService; + } + + private async Task StopCertifyService(Command certifyService) + { + await certifyService.TrySignalAsync(CommandSignal.ControlC); + + var cmdResult = await certifyService.Task; + + Assert.AreEqual(cmdResult.ExitCode, 0, "Unexpected exit code"); + + return cmdResult; + } + + [TestMethod, Description("Validate that Certify.Service.exe does not start with args from CLI")] + public async Task TestProgramMainFailsWithArgsCli() + { + var certifyService = await StartCertifyService("args"); + + var cmdResult = await certifyService.Task; + + Assert.IsTrue(cmdResult.StandardOutput.Contains("Topshelf.HostFactory Error: 0 : An exception occurred creating the host, Topshelf.HostConfigurationException: The service was not properly configured:")); + Assert.IsTrue(cmdResult.StandardOutput.Contains("Topshelf.HostFactory Error: 0 : The service terminated abnormally, Topshelf.HostConfigurationException: The service was not properly configured:")); + + Assert.AreEqual(cmdResult.ExitCode, 1067, "Unexpected exit code"); + } + + [TestMethod, Description("Validate that Certify.Service.exe starts from CLI with no args")] + public async Task TestProgramMainStartsCli() + { + var certifyService = await StartCertifyService(); + + var cmdResult = await StopCertifyService(certifyService); + + Assert.IsTrue(cmdResult.StandardOutput.Contains("[Success] Name Certify.Service")); + Assert.IsTrue(cmdResult.StandardOutput.Contains("[Success] DisplayName Certify Certificate Manager Service (Instance: Debug)")); + Assert.IsTrue(cmdResult.StandardOutput.Contains("[Success] Description Certify Certificate Manager Service")); + Assert.IsTrue(cmdResult.StandardOutput.Contains("[Success] InstanceName Debug")); + Assert.IsTrue(cmdResult.StandardOutput.Contains("[Success] ServiceName Certify.Service$Debug")); + Assert.IsTrue(cmdResult.StandardOutput.Contains("The Certify.Service$Debug service is now running, press Control+C to exit.")); + Assert.IsTrue(cmdResult.StandardOutput.Contains("Control+C detected, attempting to stop service.")); + Assert.IsTrue(cmdResult.StandardOutput.Contains("The Certify.Service$Debug service has stopped.")); + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route GET /api/system/appversion")] + public async Task TestCertifyServiceAppVersionRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var versionRawRes = await _httpClient.GetAsync("system/appversion"); + var versionResStr = await versionRawRes.Content.ReadAsStringAsync(); + var versionRes = JsonConvert.DeserializeObject(versionResStr); + + Assert.AreEqual(HttpStatusCode.OK, versionRawRes.StatusCode, $"Unexpected status code from GET {versionRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + StringAssert.Matches(versionRes, new Regex(@"^(\d+\.)?(\d+\.)?(\d+\.)?(\*|\d+)$"), $"Unexpected response from GET {versionRawRes.RequestMessage.RequestUri.AbsoluteUri} : {versionResStr}"); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid respose on route GET /api/system/updatecheck")] + public async Task TestCertifyServiceUpdateCheckRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var updatesRawRes = await _httpClient.GetAsync("system/updatecheck"); + var updateRawResStr = await updatesRawRes.Content.ReadAsStringAsync(); + var updateRes = JsonConvert.DeserializeObject(updateRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, updatesRawRes.StatusCode, $"Unexpected status code from GET {updatesRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsFalse(updateRes.MustUpdate); + Assert.IsFalse(updateRes.IsNewerVersion); + Assert.AreEqual("", updateRes.UpdateFilePath); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route GET /api/system/diagnostics")] + public async Task TestCertifyServiceDiagnosticsRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var diagnosticsRawRes = await _httpClient.GetAsync("system/diagnostics"); + var diagnosticsRawResStr = await diagnosticsRawRes.Content.ReadAsStringAsync(); + var diagnosticsRes = JsonConvert.DeserializeObject>(diagnosticsRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, diagnosticsRawRes.StatusCode, $"Unexpected status code from GET {diagnosticsRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.AreEqual(4, diagnosticsRes.Count); + + Assert.AreEqual("Created test temp file OK.", diagnosticsRes[0].Message); + Assert.IsTrue(diagnosticsRes[0].IsSuccess); + Assert.IsFalse(diagnosticsRes[0].IsWarning); + Assert.AreEqual(null, diagnosticsRes[0].Result); + + Assert.AreEqual($"Drive {Environment.GetEnvironmentVariable("SystemDrive")} has more than 512MB of disk space free.", diagnosticsRes[1].Message); + Assert.IsTrue(diagnosticsRes[1].IsSuccess); + Assert.IsFalse(diagnosticsRes[1].IsWarning); + Assert.AreEqual(null, diagnosticsRes[1].Result); + + Assert.AreEqual("System time is correct.", diagnosticsRes[2].Message); + Assert.IsTrue(diagnosticsRes[2].IsSuccess); + Assert.IsFalse(diagnosticsRes[2].IsWarning); + Assert.AreEqual(null, diagnosticsRes[2].Result); + + Assert.AreEqual("PowerShell 5.0 or higher is available.", diagnosticsRes[3].Message); + Assert.IsTrue(diagnosticsRes[3].IsSuccess); + Assert.IsFalse(diagnosticsRes[3].IsWarning); + Assert.AreEqual(null, diagnosticsRes[3].Result); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route GET /api/system/datastores/providers")] + public async Task TestCertifyServiceDatastoreProvidersRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var datastoreProvidersRawRes = await _httpClient.GetAsync("system/datastores/providers"); + var datastoreProvidersRawResStr = await datastoreProvidersRawRes.Content.ReadAsStringAsync(); + var datastoreProvidersRes = JsonConvert.DeserializeObject>(datastoreProvidersRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreProvidersRawRes.StatusCode, $"Unexpected status code from GET {datastoreProvidersRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route GET /api/system/datastores/")] + public async Task TestCertifyServiceDatastoresRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var datastoreRawRes = await _httpClient.GetAsync("system/datastores/"); + var datastoreRawResStr = await datastoreRawRes.Content.ReadAsStringAsync(); + var datastoreRes = JsonConvert.DeserializeObject>(datastoreRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreRawRes.StatusCode, $"Unexpected status code from GET {datastoreRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreRes.Count >= 1); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route POST /api/system/datastores/test")] + public async Task TestCertifyServiceDatastoresTestRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var datastoreRawRes = await _httpClient.GetAsync("system/datastores/"); + var datastoreRawResStr = await datastoreRawRes.Content.ReadAsStringAsync(); + var datastoreRes = JsonConvert.DeserializeObject>(datastoreRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreRawRes.StatusCode, $"Unexpected status code from GET {datastoreRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreRes.Count >= 1); + + var datastoreTestRawRes = await _httpClient.PostAsJsonAsync("system/datastores/test", datastoreRes[0]); + var datastoreTestRawResStr = await datastoreTestRawRes.Content.ReadAsStringAsync(); + var datastoreTestRes = JsonConvert.DeserializeObject>(datastoreTestRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreTestRawRes.StatusCode, $"Unexpected status code from POST {datastoreTestRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreTestRes.Count >= 1); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route POST /api/system/datastores/update")] + public async Task TestCertifyServiceDatastoresUpdateRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var datastoreRawRes = await _httpClient.GetAsync("system/datastores/"); + var datastoreRawResStr = await datastoreRawRes.Content.ReadAsStringAsync(); + var datastoreRes = JsonConvert.DeserializeObject>(datastoreRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreRawRes.StatusCode, $"Unexpected status code from GET {datastoreRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreRes.Count >= 1); + + var datastoreUpdateRawRes = await _httpClient.PostAsJsonAsync("system/datastores/update", datastoreRes[0]); + var datastoreUpdateRawResStr = await datastoreUpdateRawRes.Content.ReadAsStringAsync(); + var datastoreUpdateRes = JsonConvert.DeserializeObject>(datastoreUpdateRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreUpdateRawRes.StatusCode, $"Unexpected status code from POST {datastoreUpdateRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreUpdateRes.Count >= 1); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route POST /api/system/datastores/setdefault/{dataStoreId}")] + public async Task TestCertifyServiceDatastoresSetDefaultRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var datastoreRawRes = await _httpClient.GetAsync("system/datastores/"); + var datastoreRawResStr = await datastoreRawRes.Content.ReadAsStringAsync(); + var datastoreRes = JsonConvert.DeserializeObject>(datastoreRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreRawRes.StatusCode, $"Unexpected status code from GET {datastoreRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreRes.Count >= 1); + + var datastoreSetDefaultRawRes = await _httpClient.PostAsync($"system/datastores/setdefault/{datastoreRes[0].Id}", new StringContent("")); + var datastoreSetDefaultRawResStr = await datastoreSetDefaultRawRes.Content.ReadAsStringAsync(); + var datastoreSetDefaultRes = JsonConvert.DeserializeObject>(datastoreSetDefaultRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreSetDefaultRawRes.StatusCode, $"Unexpected status code from POST {datastoreSetDefaultRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreSetDefaultRes.Count >= 1); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route POST /api/system/datastores/delete")] + [Ignore] + public async Task TestCertifyServiceDatastoresDeleteRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var datastoreRawRes = await _httpClient.GetAsync("system/datastores/"); + var datastoreRawResStr = await datastoreRawRes.Content.ReadAsStringAsync(); + var datastoreRes = JsonConvert.DeserializeObject>(datastoreRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreRawRes.StatusCode, $"Unexpected status code from GET {datastoreRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreRes.Count >= 1); + + var datastoreDeleteRawRes = await _httpClient.PostAsync("system/datastores/delete", new StringContent(datastoreRes[0].Id)); + var datastoreDeleteRawResStr = await datastoreDeleteRawRes.Content.ReadAsStringAsync(); + var datastoreDeleteRes = JsonConvert.DeserializeObject>(datastoreDeleteRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreDeleteRawRes.StatusCode, $"Unexpected status code from POST {datastoreDeleteRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreDeleteRes.Count >= 1); + + var datastoreUpdateRawRes = await _httpClient.PostAsJsonAsync("system/datastores/update", datastoreRes[0]); + var datastoreUpdateRawResStr = await datastoreUpdateRawRes.Content.ReadAsStringAsync(); + var datastoreUpdateRes = JsonConvert.DeserializeObject>(datastoreUpdateRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreUpdateRawRes.StatusCode, $"Unexpected status code from POST {datastoreUpdateRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreUpdateRes.Count >= 1); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route POST /api/system/datastores/copy/{sourceId}/{destId}")] + [Ignore] + public async Task TestCertifyServiceDatastoresCopyRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var datastoreRawRes = await _httpClient.GetAsync("system/datastores/"); + var datastoreRawResStr = await datastoreRawRes.Content.ReadAsStringAsync(); + var datastoreRes = JsonConvert.DeserializeObject>(datastoreRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreRawRes.StatusCode, $"Unexpected status code from GET {datastoreRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreRes.Count >= 1); + + var newDataStoreId = "default-copy"; + var datastoreCopyRawRes = await _httpClient.PostAsync($"system/datastores/copy/{datastoreRes[0].Id}/{newDataStoreId}", new StringContent("")); + var datastoreCopyRawResStr = await datastoreCopyRawRes.Content.ReadAsStringAsync(); + var datastoreCopyRes = JsonConvert.DeserializeObject>(datastoreCopyRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreCopyRawRes.StatusCode, $"Unexpected status code from POST {datastoreCopyRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreCopyRes.Count >= 1); + + datastoreRawRes = await _httpClient.GetAsync("system/datastores/"); + datastoreRawResStr = await datastoreRawRes.Content.ReadAsStringAsync(); + datastoreRes = JsonConvert.DeserializeObject>(datastoreRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreRawRes.StatusCode, $"Unexpected status code from GET {datastoreRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreRes.Count >= 2); + + var datastoreDeleteRawRes = await _httpClient.PostAsJsonAsync("system/datastores/delete", newDataStoreId); + var datastoreDeleteRawResStr = await datastoreDeleteRawRes.Content.ReadAsStringAsync(); + var datastoreDeleteRes = JsonConvert.DeserializeObject>(datastoreDeleteRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, datastoreDeleteRawRes.StatusCode, $"Unexpected status code from POST {datastoreDeleteRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(datastoreDeleteRes.Count >= 1); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route GET /api/server/isavailable/{serverType}")] + public async Task TestCertifyServiceServerIsavailableRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var isAvailableRawRes = await _httpClient.GetAsync($"server/isavailable/{StandardServerTypes.IIS}"); + var isAvailableRawResStr = await isAvailableRawRes.Content.ReadAsStringAsync(); + var isAvailableRes = JsonConvert.DeserializeObject(isAvailableRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, isAvailableRawRes.StatusCode, $"Unexpected status code from GET {isAvailableRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + Assert.IsTrue(isAvailableRes); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route GET /api/server/sitelist/{serverType}")] + public async Task TestCertifyServiceServerSitelistRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var sitelistRawRes = await _httpClient.GetAsync($"server/sitelist/{StandardServerTypes.IIS}"); + var sitelistRawResStr = await sitelistRawRes.Content.ReadAsStringAsync(); + var sitelistRes = JsonConvert.DeserializeObject>(sitelistRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, sitelistRawRes.StatusCode, $"Unexpected status code from GET {sitelistRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + } + finally + { + await StopCertifyService(certifyService); + } + } + + [TestMethod, Description("Validate that Certify.Service.exe returns a valid response on route GET /api/server/version/{serverType}")] + public async Task TestCertifyServiceServerVersionRoute() + { + var certifyService = await StartCertifyService(); + + try + { + var versionRawRes = await _httpClient.GetAsync($"server/version/{StandardServerTypes.IIS}"); + var versionRawResStr = await versionRawRes.Content.ReadAsStringAsync(); + var versionRes = JsonConvert.DeserializeObject(versionRawResStr); + + Assert.AreEqual(HttpStatusCode.OK, versionRawRes.StatusCode, $"Unexpected status code from GET {versionRawRes.RequestMessage.RequestUri.AbsoluteUri}"); + StringAssert.Matches(versionRes, new Regex(@"^(\d+\.)?(\*|\d+)$"), $"Unexpected response from GET {versionRawRes.RequestMessage.RequestUri.AbsoluteUri} : {versionRawResStr}"); + } + finally + { + await StopCertifyService(certifyService); + } + } + } +#endif +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/ChallengeConfigMatchTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/ChallengeConfigMatchTests.cs similarity index 95% rename from src/Certify.Tests/Certify.Core.Tests.Unit/ChallengeConfigMatchTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Unit/Tests/ChallengeConfigMatchTests.cs index fd5b5553a..7ef2d122a 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/ChallengeConfigMatchTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/ChallengeConfigMatchTests.cs @@ -102,7 +102,7 @@ public void MultiChallengeConfigMatch() } [TestMethod, Description("Ensure correct challenge config selected based on domain")] - public void ChallengeDelgationRuleTests() + public void ChallengeDelegationRuleTests() { // wildcard rule tests [any subdomain source, any subdomain target] var testRule = "*.test.com:*.auth.test.co.uk"; @@ -140,5 +140,12 @@ public void ChallengeDelgationRuleTests() result = Management.Challenges.DnsChallengeHelper.ApplyChallengeDelegationRule("www.subdomain.example.com", "_acme-challenge.www.subdomain.example.com", testRule); Assert.AreEqual("_acme-challenge.www.subdomain.auth.example.co.uk", result); } + + [TestMethod, Description("Ensure correct challenge config selected when rule is blank")] + public void ChallengeDelegationRuleBlankRule() + { + var result = Management.Challenges.DnsChallengeHelper.ApplyChallengeDelegationRule("test.com", "_acme-challenge.test.com", null); + Assert.AreEqual("_acme-challenge.test.com", result); + } } } diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/ConnectionCheckTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/ConnectionCheckTests.cs similarity index 75% rename from src/Certify.Tests/Certify.Core.Tests.Unit/ConnectionCheckTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Unit/Tests/ConnectionCheckTests.cs index 7ab2fe3a7..5df117389 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/ConnectionCheckTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/ConnectionCheckTests.cs @@ -1,8 +1,6 @@ using System.Threading.Tasks; -using Certify.Models; using Certify.Shared.Core.Utils; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Serilog; namespace Certify.Core.Tests.Unit { @@ -14,12 +12,6 @@ public async Task TestPortConnection() { var net = new NetworkUtils(enableProxyValidationAPI: true); - var logImp = new LoggerConfiguration() - .WriteTo.Debug() - .CreateLogger(); - - var log = new Loggy(logImp); - var result = await net.CheckServiceConnection("webprofusion.com", 80); Assert.IsTrue(result.IsSuccess, "hostname should connect ok on port 80"); diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/DnsQueryTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/DnsQueryTests.cs similarity index 87% rename from src/Certify.Tests/Certify.Core.Tests.Unit/DnsQueryTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Unit/Tests/DnsQueryTests.cs index e5673dbde..646c0961d 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/DnsQueryTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/DnsQueryTests.cs @@ -2,9 +2,8 @@ using System.Threading.Tasks; using Certify.Models; using Certify.Shared.Core.Utils; - +using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Serilog; namespace Certify.Core.Tests.Unit { @@ -17,11 +16,7 @@ public async Task TestDNSTests() { var net = new NetworkUtils(enableProxyValidationAPI: true); - var logImp = new LoggerConfiguration() - .WriteTo.Debug() - .CreateLogger(); - - var log = new Loggy(logImp); + var log = new Loggy(LoggerFactory.Create(builder => builder.AddDebug()).CreateLogger()); // check invalid domain var result = await net.CheckDNS(log, "fdlsakdfoweinoijsjdfpsdkfspdf.com"); @@ -51,17 +46,13 @@ public async Task TestDNSTests() Assert.IsFalse(result.All(r => r.IsSuccess), "incorrectly configured DNSSEC record should fail dns check"); } -#if NET6_0_OR_GREATER +#if NET9_0_OR_GREATER [TestMethod, Description("Check for a DNS TXT record")] public async Task TestDNS_CheckTXT() { var net = new NetworkUtils(enableProxyValidationAPI: true); - var logImp = new LoggerConfiguration() - .WriteTo.Debug() - .CreateLogger(); - - var log = new Loggy(logImp); + var log = new Loggy(null); // check invalid domain var result = await net.GetDNSRecordTXT(log, "_acme-challenge-test.cointelligence.io"); diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/DomainZoneMatchTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/DomainZoneMatchTests.cs similarity index 89% rename from src/Certify.Tests/Certify.Core.Tests.Unit/DomainZoneMatchTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Unit/Tests/DomainZoneMatchTests.cs index 7a82f1011..0b724a6d2 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/DomainZoneMatchTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/DomainZoneMatchTests.cs @@ -18,8 +18,8 @@ public async Task DetermineRootDomainTests() new DnsZone{ Name="test.com", ZoneId="123-test.com"}, new DnsZone{ Name="subdomain.test.com", ZoneId="345-subdomain-test.com"}, new DnsZone{ Name="long-subdomain.test.com", ZoneId="345-subdomain-test.com"}, - new DnsZone{ Name="bar.co.uk", ZoneId="lengthtest-1"}, - new DnsZone{ Name="foobar.co.uk", ZoneId="lengthtest-2"} + new DnsZone{ Name="bar.co.uk", ZoneId="lengthtest-1"}, + new DnsZone{ Name="foobar.co.uk", ZoneId="lengthtest-2"} } ); @@ -32,6 +32,9 @@ public async Task DetermineRootDomainTests() domainRoot = await mockDnsProvider.Object.DetermineZoneDomainRoot("www.test.com", "123-test.com"); Assert.IsTrue(domainRoot.ZoneId == "123-test.com"); + domainRoot = await mockDnsProvider.Object.DetermineZoneDomainRoot("test.com", "bad.domain.com"); + Assert.IsTrue(domainRoot.ZoneId == "123-test.com"); + domainRoot = await mockDnsProvider.Object.DetermineZoneDomainRoot("www.test.com", null); Assert.IsTrue(domainRoot.ZoneId == "123-test.com"); diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/GetDnsProviderTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/GetDnsProviderTests.cs new file mode 100644 index 000000000..23e259469 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/GetDnsProviderTests.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Certify.Core.Management.Challenges; +using Certify.Datastore.SQLite; +using Certify.Management; +using Certify.Models.Config; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Certify.Core.Tests.Unit +{ + [TestClass] + public class GetDnsProviderTests + { + private SQLiteCredentialStore credentialsManager; + private DnsChallengeHelper dnsHelper; + + public GetDnsProviderTests() + { + var pluginManager = new PluginManager(); + pluginManager.LoadPlugins(new List { PluginManager.PLUGINS_DNS_PROVIDERS }); + var TEST_PATH = "Tests\\credentials"; + credentialsManager = new SQLiteCredentialStore(TEST_PATH); + dnsHelper = new DnsChallengeHelper(credentialsManager); + } + + [TestMethod, Description("Test Getting DNS Provider with empty CredentialsID")] + public async Task TestGetDnsProvidersEmptyCredentialsID() + { + var providerTypeId = "DNS01.Powershell"; + var credentialsId = ""; + var result = await dnsHelper.GetDnsProvider(providerTypeId, credentialsId, null, credentialsManager); + + // Assert + Assert.AreEqual("DNS Challenge API Provider not set or could not load.", result.Result.Message); + Assert.IsFalse(result.Result.IsSuccess); + Assert.IsFalse(result.Result.IsWarning); + } + + [TestMethod, Description("Test Getting DNS Provider with empty ProviderTypeId")] + public async Task TestGetDnsProvidersEmptyProviderTypeId() + { + var providerTypeId = ""; + var secrets = new Dictionary(); + secrets.Add("zoneid", "ABC123"); + secrets.Add("secretid", "thereisnosecret"); + var testCredential = new StoredCredential + { + ProviderType = "DNS01.Manual", + Title = "A test credential", + StorageKey = Guid.NewGuid().ToString(), + Secret = Newtonsoft.Json.JsonConvert.SerializeObject(secrets) + }; + var updateResult = await credentialsManager.Update(testCredential); + + var result = await dnsHelper.GetDnsProvider(providerTypeId, testCredential.StorageKey, null, credentialsManager); + + // Assert + Assert.AreEqual("DNS Challenge API Provider not set or could not load.", result.Result.Message); + Assert.IsFalse(result.Result.IsSuccess); + Assert.IsFalse(result.Result.IsWarning); + + // Cleanup credentials + await credentialsManager.Delete(null, testCredential.StorageKey); + } + + [TestMethod, Description("Test Getting DNS Provider with a bad CredentialId")] + public async Task TestGetDnsProvidersBadCredentialId() + { + var secrets = new Dictionary(); + secrets.Add("zoneid", "ABC123"); + secrets.Add("secretid", "thereisnosecret"); + var testCredential = new StoredCredential + { + ProviderType = "DNS01.Manual", + Title = "A test credential", + StorageKey = Guid.NewGuid().ToString(), + Secret = Newtonsoft.Json.JsonConvert.SerializeObject(secrets) + }; + + var updateResult = await credentialsManager.Update(testCredential); + + var result = await dnsHelper.GetDnsProvider(testCredential.ProviderType, testCredential.StorageKey.Substring(5), null, credentialsManager); + + // Assert + Assert.AreEqual("DNS Challenge API Credentials could not be decrypted or no longer exists. The original user must be used for decryption.", result.Result.Message); + Assert.IsFalse(result.Result.IsSuccess); + Assert.IsFalse(result.Result.IsWarning); + + // Cleanup credentials + await credentialsManager.Delete(null, testCredential.StorageKey); + } + + [TestMethod, Description("Test Getting DNS Provider")] + public async Task TestGetDnsProviders() + { + var secrets = new Dictionary(); + secrets.Add("zoneid", "ABC123"); + secrets.Add("secretid", "thereisnosecret"); + var testCredential = new StoredCredential + { + ProviderType = "DNS01.Manual", + Title = "A test credential", + StorageKey = Guid.NewGuid().ToString(), + Secret = Newtonsoft.Json.JsonConvert.SerializeObject(secrets) + }; + + var updateResult = await credentialsManager.Update(testCredential); + + var result = await dnsHelper.GetDnsProvider(testCredential.ProviderType, testCredential.StorageKey, null, credentialsManager); + + // Assert + Assert.AreEqual("Create Provider Instance", result.Result.Message); + Assert.IsTrue(result.Result.IsSuccess); + Assert.IsFalse(result.Result.IsWarning); + Assert.AreEqual(testCredential.ProviderType, result.Provider.ProviderId); + + // Cleanup credentials + await credentialsManager.Delete(null, testCredential.StorageKey); + } + + [TestMethod, Description("Test Getting Challenge API Providers")] + public async Task TestGetChallengeAPIProviders() + { + var challengeAPIProviders = await ChallengeProviders.GetChallengeAPIProviders(); + + // Assert + Assert.IsNotNull(challengeAPIProviders); + Assert.AreNotEqual(0, challengeAPIProviders.Count); + foreach (object item in challengeAPIProviders) + { + var itemType = item.GetType(); + Assert.IsTrue(itemType.GetProperty("ChallengeType") != null); + Assert.IsTrue(itemType.GetProperty("Config") != null); + Assert.IsTrue(itemType.GetProperty("Description") != null); + Assert.IsTrue(itemType.GetProperty("HandlerType") != null); + Assert.IsTrue(itemType.GetProperty("HasDynamicParameters") != null); + Assert.IsTrue(itemType.GetProperty("HelpUrl") != null); + Assert.IsTrue(itemType.GetProperty("Id") != null); + Assert.IsTrue(itemType.GetProperty("IsEnabled") != null); + Assert.IsTrue(itemType.GetProperty("IsExperimental") != null); + Assert.IsTrue(itemType.GetProperty("IsTestModeSupported") != null); + Assert.IsTrue(itemType.GetProperty("PropagationDelaySeconds") != null); + Assert.IsTrue(itemType.GetProperty("ProviderCategoryId") != null); + Assert.IsTrue(itemType.GetProperty("ProviderParameters") != null); + Assert.IsTrue(itemType.GetProperty("Title") != null); + } + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/LoggyTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/LoggyTests.cs new file mode 100644 index 000000000..03f4eed99 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/LoggyTests.cs @@ -0,0 +1,188 @@ +using System; +using System.IO; +using Certify.Models; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Serilog; + +namespace Certify.Core.Tests.Unit +{ + [TestClass] + public class LoggyTests + { + private string testsDataPath; + private string logFilePath; + + [TestInitialize] + public void TestInitialize() + { + testsDataPath = Path.Combine(EnvironmentUtil.CreateAppDataPath(), "Tests"); + logFilePath = Path.Combine(testsDataPath, "test.log"); + + if (!Directory.Exists(testsDataPath)) + { + Directory.CreateDirectory(testsDataPath); + + } + + if (File.Exists(logFilePath)) + { + File.Delete(this.logFilePath); + } + } + + [TestCleanup] + public void TestCleanup() + { + File.Delete(this.logFilePath); + } + + [TestMethod, Description("Test Loggy.Error() Method")] + public void TestLoggyError() + { + // Setup instance of Loggy + var logImp = new LoggerConfiguration() + .WriteTo.File(this.logFilePath) + .CreateLogger(); + + var log = new Loggy(new Serilog.Extensions.Logging.SerilogLoggerFactory(logImp).CreateLogger()); + + // Log an error message using Loggy.Error() + var logMessage = "New Loggy Error"; + log.Error(logMessage); + logImp.Dispose(); + + // Read in logged out error text + var logText = File.ReadAllText(this.logFilePath); + + // Validate logged out error text + Assert.IsTrue(logText.Contains(logMessage), $"Logged error message should contain '{logMessage}'"); + Assert.IsTrue(logText.Contains("[ERR]"), "Logged error message should contain '[ERR]'"); + } + + [TestMethod, Description("Test Loggy.Error() Method (Exception)")] + public void TestLoggyErrorException() + { + // Setup instance of Loggy + var logImp = new LoggerConfiguration() + .WriteTo.File(this.logFilePath) + .CreateLogger(); + var log = new Loggy(new Serilog.Extensions.Logging.SerilogLoggerFactory(logImp).CreateLogger()); + + // Trigger an exception error and log it using Loggy.Error() + var logMessage = "New Loggy Exception Error"; + var badFilePath = Path.Combine(EnvironmentUtil.CreateAppDataPath(), "Tests", "test1.log"); + + var exceptionError = $"System.IO.FileNotFoundException: Could not find file '{badFilePath}'."; + try + { + var nullObject = File.ReadAllBytes(badFilePath); + } + catch (Exception e) + { + log.Error(e, logMessage); + } + + logImp.Dispose(); + + // Read in logged out exception error text + var logText = File.ReadAllText(this.logFilePath); + + // Validate logged out exception error text + Assert.IsTrue(logText.Contains(logMessage), $"Logged error message should contain '{logMessage}'"); + Assert.IsTrue(logText.Contains("[ERR]"), "Logged error message should contain '[ERR]'"); + Assert.IsTrue(logText.Contains(exceptionError), $"Logged error message should contain exception error '{exceptionError}'"); + } + + [TestMethod, Description("Test Loggy.Information() Method")] + public void TestLoggyInformation() + { + // Setup instance of Loggy + var logImp = new LoggerConfiguration() + .WriteTo.File(this.logFilePath) + .CreateLogger(); + var log = new Loggy(new Serilog.Extensions.Logging.SerilogLoggerFactory(logImp).CreateLogger()); + + // Log an info message using Loggy.Information() + var logMessage = "New Loggy Information"; + log.Information(logMessage); + logImp.Dispose(); + + // Read in logged out info text + var logText = File.ReadAllText(this.logFilePath); + + // Validate logged out info text + Assert.IsTrue(logText.Contains(logMessage), $"Logged info message should contain '{logMessage}'"); + Assert.IsTrue(logText.Contains("[INF]"), "Logged info message should contain '[INF]'"); + } + + [TestMethod, Description("Test Loggy.Debug() Method")] + public void TestLoggyDebug() + { + // Setup instance of Loggy + var logImp = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.File(this.logFilePath) + .CreateLogger(); + var log = new Loggy(new Serilog.Extensions.Logging.SerilogLoggerFactory(logImp).CreateLogger()); + + // Log a debug message using Loggy.Debug() + var logMessage = "New Loggy Debug"; + log.Debug(logMessage); + logImp.Dispose(); + + // Read in logged out debug text + var logText = File.ReadAllText(this.logFilePath); + + // Validate logged out debug text + Assert.IsTrue(logText.Contains(logMessage), $"Logged debug message should contain '{logMessage}'"); + Assert.IsTrue(logText.Contains("[DBG]"), "Logged debug message should contain '[DBG]'"); + } + + [TestMethod, Description("Test Loggy.Verbose() Method")] + public void TestLoggyVerbose() + { + // Setup instance of Loggy + var logImp = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.File(this.logFilePath) + .CreateLogger(); + var log = new Loggy(new Serilog.Extensions.Logging.SerilogLoggerFactory(logImp).CreateLogger()); + + // Log a verbose message using Loggy.Verbose() + var logMessage = "New Loggy Verbose"; + log.Verbose(logMessage); + logImp.Dispose(); + + // Read in logged out verbose text + var logText = File.ReadAllText(this.logFilePath); + + // Validate logged out verbose text + Assert.IsTrue(logText.Contains(logMessage), $"Logged verbose message should contain '{logMessage}'"); + Assert.IsTrue(logText.Contains("[VRB]"), "Logged verbose message should contain '[VRB]'"); + } + + [TestMethod, Description("Test Loggy.Warning() Method")] + public void TestLoggyWarning() + { + // Setup instance of Loggy + var logImp = new LoggerConfiguration() + .WriteTo.File(this.logFilePath) + .CreateLogger(); + + var log = new Loggy(new Serilog.Extensions.Logging.SerilogLoggerFactory(logImp).CreateLogger()); + + // Log a warning message using Loggy.Warning() + var logMessage = "New Loggy Warning"; + log.Warning(logMessage); + logImp.Dispose(); + + // Read in logged out warning text + var logText = File.ReadAllText(this.logFilePath); + + // Validate logged out warning text + Assert.IsTrue(logText.Contains(logMessage), $"Logged warning message should contain '{logMessage}'"); + Assert.IsTrue(logText.Contains("[WRN]"), "Logged warning message should contain '[WRN]'"); + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/MiscAcmeTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/MiscAcmeTests.cs new file mode 100644 index 000000000..7cec545f7 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/MiscAcmeTests.cs @@ -0,0 +1,222 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Certify.ACME.Anvil; +using Certify.ACME.Anvil.Acme; +using Certify.Models; +using Certify.Providers.ACME.Anvil; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Moq.Protected; + +namespace Certify.Core.Tests.Unit +{ + [TestClass] + public class MiscAcmeTests + { + + [TestMethod, Description("Test Directory Query")] + public async Task TestAcmeDirectory() + { + + var directoryJson = """ + + { + "newNonce": "https://acme.dev.certifytheweb.com/v2/newNonce", + "newAccount": "https://acme.dev.certifytheweb.com/v2/newAccount", + "newOrder": "https://acme.dev.certifytheweb.com/v2/newOrder", + "revokeCert": "https://acme.dev.certifytheweb.com/v2/revokeCert", + "keyChange": "https://acme.dev.certifytheweb.com/v2/keyChange", + "meta": { + "termsOfService": "https://acme.dev.certifytheweb.com/v2/tc.pdf", + "website": "https://certifytheweb.com", + "caaIdentities": ["certifytheweb.com"], + "externalAccountRequired": false + } + } + + """; + + var mockMessageHandler = new Mock(); + + mockMessageHandler + .Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(directoryJson.Trim(), Encoding.UTF8, "application/json") + }); + + using ILoggerFactory factory = LoggerFactory.Create(builder => builder.AddDebug()); + var logger = factory.CreateLogger(nameof(MiscTests)); + + var loggingHandler = new LoggingHandler(mockMessageHandler.Object, new Loggy(logger), maxRequestsPerSecond: 2); + var customHttpClient = new System.Net.Http.HttpClient(loggingHandler); + + var acmeHttpClient = new AcmeHttpClient(WellKnownServers.LetsEncryptStaging, customHttpClient); + + var acmeContext = new AcmeContext(WellKnownServers.LetsEncryptStagingV2, http: acmeHttpClient); + + var dir = await acmeContext.GetDirectory(throwOnError: true); + + Assert.IsNotNull(dir); + } + + [TestMethod, Description("Test Directory Query Rate Limit 429")] + public async Task TestAcmeDirectoryRateLimit() + { + // Some CAs have different type of rate limit, occasionally it's at the server or traffic manager level + // and is not aware of ACME problem responses etc. This example matches ZeroSSLs rate limit behaviour (if it encounters more than 7 requests per second) + + var directoryResponseRateLimited = """ + + + 429 Too Many Requests + +

429 Too Many Requests

+
nginx
+ + + + """; + + // test message gets disposed after being consumed so we generate a new one for every call + var rateLimitedResponseMessageFactory = () => + { + var msg = new HttpResponseMessage + { + Content = new StringContent(directoryResponseRateLimited.Trim(), Encoding.UTF8, "text/html"), + StatusCode = (HttpStatusCode)429 + }; + msg.Headers.Add("Retry-After", "5"); + return msg; + }; + + var mockMessageHandler = new Mock(); + + mockMessageHandler + .Protected() + .SetupSequence>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(rateLimitedResponseMessageFactory()) + .ReturnsAsync(rateLimitedResponseMessageFactory()) + .ReturnsAsync(rateLimitedResponseMessageFactory()); + + using ILoggerFactory factory = LoggerFactory.Create(builder => builder.AddDebug()); + var logger = factory.CreateLogger(nameof(MiscTests)); + + var loggingHandler = new LoggingHandler(mockMessageHandler.Object, new Loggy(logger), maxRequestsPerSecond: 2); + var customHttpClient = new System.Net.Http.HttpClient(loggingHandler); + + var acmeHttpClient = new AcmeHttpClient(WellKnownServers.LetsEncryptStaging, customHttpClient); + + var acmeContext = new AcmeContext(WellKnownServers.LetsEncryptStagingV2, http: acmeHttpClient); + acmeContext.AutoRetryAttempts = 2; + + try + { + await acmeContext.GetDirectory(throwOnError: true); + } + catch (AcmeRequestException ex) + { + Assert.AreEqual("urn:ietf:params:acme:error:rateLimited", ex.Error.Type); + } + } + + [TestMethod, Description("Test Directory Query Rate Limit With Auto Retry")] + public async Task TestAcmeDirectoryRateLimitWithRetry() + { + // Some CAs have different type of rate limit, occasionally it's at the server or traffic manager level + // and is not aware of ACME problem responses etc. This example matches ZeroSSLs rate limit behaviour (if it encounters more than 7 requests per second) + + var directoryResponseRateLimited = """ + + + 429 Too Many Requests + +

429 Too Many Requests

+
nginx
+ + + + """; + + var directoryJson = """ + + { + "newNonce": "https://acme.dev.certifytheweb.com/v2/newNonce", + "newAccount": "https://acme.dev.certifytheweb.com/v2/newAccount", + "newOrder": "https://acme.dev.certifytheweb.com/v2/newOrder", + "revokeCert": "https://acme.dev.certifytheweb.com/v2/revokeCert", + "keyChange": "https://acme.dev.certifytheweb.com/v2/keyChange", + "meta": { + "termsOfService": "https://acme.dev.certifytheweb.com/v2/tc.pdf", + "website": "https://certifytheweb.com", + "caaIdentities": ["certifytheweb.com"], + "externalAccountRequired": false + } + } + + """; + + // test message gets disposed after being consumed so we generate a new one for every call + var rateLimitedResponseMessageFactory = (int retryAfter) => + { + var msg = new HttpResponseMessage + { + Content = new StringContent(directoryResponseRateLimited.Trim(), Encoding.UTF8, "text/html"), + StatusCode = (HttpStatusCode)429 + }; + + // optionally include retry-after header + if (retryAfter > 0) + { + msg.Headers.Add("Retry-After", "5"); + } + + return msg; + }; + + var directoryResponseMessage = new HttpResponseMessage + { + Content = new StringContent(directoryJson.Trim(), Encoding.UTF8, "application/json"), + StatusCode = (HttpStatusCode)200 + }; + + var mockMessageHandler = new Mock(); + + mockMessageHandler.Protected() + .SetupSequence>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(rateLimitedResponseMessageFactory(5)) + .ReturnsAsync(rateLimitedResponseMessageFactory(0)) + .ReturnsAsync(directoryResponseMessage); + + using ILoggerFactory factory = LoggerFactory.Create(builder => builder.AddDebug()); + + var logger = factory.CreateLogger(nameof(MiscTests)); + + var loggingHandler = new LoggingHandler(mockMessageHandler.Object, new Loggy(logger), maxRequestsPerSecond: 2); + var customHttpClient = new System.Net.Http.HttpClient(loggingHandler); + + var acmeHttpClient = new AcmeHttpClient(WellKnownServers.LetsEncryptStaging, customHttpClient); + + var acmeContext = new AcmeContext(WellKnownServers.LetsEncryptStagingV2, http: acmeHttpClient); + + ACME.Anvil.Acme.Resource.Directory dir = default; + try + { + dir = await acmeContext.GetDirectory(throwOnError: false); + } + catch (AcmeRequestException ex) + { + Assert.AreEqual("urn:ietf:params:acme:error:rateLimited", ex.Error.Type); + } + + Assert.IsNotNull(dir); + Assert.IsNotNull(dir.NewOrder); + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/MiscTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/MiscTests.cs new file mode 100644 index 000000000..e359e041d --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/MiscTests.cs @@ -0,0 +1,118 @@ +using System; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Certify.Models.API; +using Certify.Shared.Core.Utils; +using Certify.Shared.Core.Utils.PKI; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Org.BouncyCastle.X509; + +namespace Certify.Core.Tests.Unit +{ + [TestClass] + public class MiscTests + { + [TestMethod, Description("Test null/blank coalesce of string")] + public void TestNullOrBlankCoalesce() + { + string testValue = null; + + var result = testValue.WithDefault("ok"); + Assert.AreEqual(result, "ok"); + + testValue = "test"; + result = testValue.WithDefault("ok"); + Assert.AreEqual(result, "test"); + + var ca = new Models.CertificateAuthority(); + ca.Description = null; + result = ca.Description.WithDefault("default"); + Assert.AreEqual(result, "default"); + + ca = null; + result = ca?.Description.WithDefault("default"); + Assert.AreEqual(result, null); + } + + [TestMethod, Description("Test log parser using array of strings")] + public void TestLogParser() + { + var testLog = new string[] + { + "2023-06-14 13:00:30.480 +08:00 [WRN] ARI Update Renewal Info Failed[MGAwDQYJYIZIAWUDBAIBBQAEIDfbgj - 5Rkkn0NG7u0eFv_M1omHdEwY_mIQn6QxbuJ68BCA9ROYZMeqCkxyMzaMePORi17Gc9xSbp8XkoE1Ub0IPrwILBm8t23CUKQnarrc] Fail to load resource from 'https://acme-staging-v02.api.letsencrypt.org/draft-ietf-acme-ari-01/renewalInfo/'." , + "urn:ietf:params:acme: error: malformed: Certificate not found" , + "2023-06-14 13:01:11.139 +08:00 [INF] Performing Certificate Request: SporkDemo[zerossl][2390d803 - e036 - 4bf5 - 8fa5 - 590497392c35: 7]" + }; + + var items = LogParser.Parse(testLog); + + Assert.AreEqual(2, items.Length); + + Assert.AreEqual("WRN", items[0].LogLevel); + Assert.AreEqual("INF", items[1].LogLevel); + + } + + [TestMethod, Description("Test ntp check")] + public async Task TestNtp() + { + var check = await Certify.Management.Util.CheckTimeServer(); + + var timeDiff = check - DateTimeOffset.UtcNow; + + if (Math.Abs(timeDiff.Value.TotalSeconds) > 50) + { + Assert.Fail("NTP Time Difference Failed"); + } + } + +#if NET8_0_OR_GREATER + [TestMethod, Description("Test ARI CertID encoding example")] + public void TestARICertIDEncoding() + { + // https://letsencrypt.org/2024/04/25/guide-to-integrating-ari-into-existing-acme-clients + var certAKIbytes = Convert.FromHexString("69:88:5B:6B:87:46:40:41:E1:B3:7B:84:7B:A0:AE:2C:DE:01:C8:D4".Replace(":", "")); + var certSerialBytes = Convert.FromHexString("00:87:65:43:21".Replace(":", "")); + + var certId = Certify.Management.Util.ToUrlSafeBase64String(certAKIbytes) + + "." + + Certify.Management.Util.ToUrlSafeBase64String(certSerialBytes); + + Assert.AreEqual("aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE", certId); + } + + [TestMethod, Description("Test ARI CertID encoding example 2")] + public void TestARICertIDEncodingWithTestCert() + { + // https://letsencrypt.org/2024/04/25/guide-to-integrating-ari-into-existing-acme-clients + + // https://www.ietf.org/archive/id/draft-ietf-acme-ari-03.html#name-appendix-a-example-certific + + var testCertPem = @"-----BEGIN CERTIFICATE----- +MIIBQzCB66ADAgECAgUAh2VDITAKBggqhkjOPQQDAjAVMRMwEQYDVQQDEwpFeGFt +cGxlIENBMCIYDzAwMDEwMTAxMDAwMDAwWhgPMDAwMTAxMDEwMDAwMDBaMBYxFDAS +BgNVBAMTC2V4YW1wbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEeBZu +7cbpAYNXZLbbh8rNIzuOoqOOtmxA1v7cRm//AwyMwWxyHz4zfwmBhcSrf47NUAFf +qzLQ2PPQxdTXREYEnKMjMCEwHwYDVR0jBBgwFoAUaYhba4dGQEHhs3uEe6CuLN4B +yNQwCgYIKoZIzj0EAwIDRwAwRAIge09+S5TZAlw5tgtiVvuERV6cT4mfutXIlwTb ++FYN/8oCIClDsqBklhB9KAelFiYt9+6FDj3z4KGVelYM5MdsO3pK +-----END CERTIFICATE----- + +"; + var cert = new X509CertificateParser().ReadCertificate(ASCIIEncoding.ASCII.GetBytes(testCertPem)); + + var certId = CertUtils.GetARICertIdBase64(cert); + + Assert.AreEqual("aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE", certId); + } +#endif + [TestMethod, Description("Test Demo Managed Cert Generation")] + public void TestDemoDataGeneration() + { + var items = DemoDataGenerator.GenerateDemoItems(); + + Assert.IsTrue(items.Any()); + } + } +} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/RdapTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/RdapTests.cs similarity index 96% rename from src/Certify.Tests/Certify.Core.Tests.Unit/RdapTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Unit/Tests/RdapTests.cs index 6e4d50931..cb6c25d63 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/RdapTests.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/RdapTests.cs @@ -7,12 +7,6 @@ namespace Certify.Core.Tests.Unit [TestClass] public class RdapTests { - - public RdapTests() - { - - } - [TestMethod, Description("Test domain TLD check")] [DataTestMethod] [DataRow("example.com", "com")] diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/RenewalRequiredTests.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/RenewalRequiredTests.cs similarity index 100% rename from src/Certify.Tests/Certify.Core.Tests.Unit/RenewalRequiredTests.cs rename to src/Certify.Tests/Certify.Core.Tests.Unit/Tests/RenewalRequiredTests.cs diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/UpdateCheckTest.cs b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/UpdateCheckTest.cs similarity index 97% rename from src/Certify.Tests/Certify.Core.Tests.Unit/UpdateCheckTest.cs rename to src/Certify.Tests/Certify.Core.Tests.Unit/Tests/UpdateCheckTest.cs index 5cc91ed9b..efc82bd71 100644 --- a/src/Certify.Tests/Certify.Core.Tests.Unit/UpdateCheckTest.cs +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/Tests/UpdateCheckTest.cs @@ -22,6 +22,8 @@ public void TestUpdateCheck() result = updateChecker.CheckForUpdates("6.1.1").Result; + Assert.IsNotNull(result, "Update check result should not be null"); + // current version is newer than update version Assert.IsFalse(result.IsNewerVersion); Assert.IsFalse(result.MustUpdate, "No mandatory update required"); diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-4_6_2-win.dockerfile b/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-4_6_2-win.dockerfile new file mode 100644 index 000000000..9a0b7d4c3 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-4_6_2-win.dockerfile @@ -0,0 +1,29 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0-preview-windowsservercore-ltsc2022 AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 +EXPOSE 9696 + +# define build and copy required source files +FROM mcr.microsoft.com/dotnet/sdk:9.0-preview-windowsservercore-ltsc2022 AS build +WORKDIR /src +COPY ./certify/src ./certify/src +COPY ./certify-plugins/src ./certify-plugins/src +COPY ./certify-internal/src/Certify.Plugins ./certify-internal/src/Certify.Plugins +COPY ./libs/anvil ./libs/anvil +RUN dotnet build ./certify/src/Certify.Tests/Certify.Core.Tests.Unit/Certify.Core.Tests.Unit.csproj -f net462 -c Debug -o /app/build + +# build and publish (as Debug mode) to /app/publish +FROM build AS publish +COPY --from=build /app/build/x64/SQLite.Interop.dll /app/publish/x64/ +RUN dotnet publish ./certify/src/Certify.Tests/Certify.Core.Tests.Unit/Certify.Core.Tests.Unit.csproj -f net462 -c Debug -o /app/publish +RUN dotnet publish ./certify-internal/src/Certify.Plugins/Plugins.All/Plugins.All.csproj -f net462 -c Debug -o /app/publish/Plugins +COPY ./libs/Posh-ACME/Posh-ACME /app/publish/Scripts/DNS/PoshACME + +# copy build from /app/publish in sdk image to final image +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . + +# run the service, alternatively we could runs tests etc +ENTRYPOINT ["dotnet", "test", "Certify.Core.Tests.Unit.dll", "-f", "net462"] diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-9_0-linux.dockerfile b/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-9_0-linux.dockerfile new file mode 100644 index 000000000..abb4010d7 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-9_0-linux.dockerfile @@ -0,0 +1,28 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 +EXPOSE 9696 +RUN wget https://dl.smallstep.com/gh-release/cli/docs-cli-install/v0.23.0/step-cli_0.23.0_amd64.deb && dpkg -i step-cli_0.23.0_amd64.deb + +# define build and copy required source files +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src +COPY ./certify/src ./certify/src +COPY ./certify-plugins/src ./certify-plugins/src +COPY ./certify-internal/src/Certify.Plugins ./certify-internal/src/Certify.Plugins +COPY ./libs/anvil ./libs/anvil + +# build and publish (as Release mode) to /app/publish +FROM build AS publish +RUN dotnet publish ./certify/src/Certify.Tests/Certify.Core.Tests.Unit/Certify.Core.Tests.Unit.csproj -f net9.0 -c Debug -o /app/publish +RUN dotnet publish ./certify-internal/src/Certify.Plugins/Plugins.All/Plugins.All.csproj -f net9.0 -c Debug -o /app/publish/plugins +COPY ./libs/Posh-ACME/Posh-ACME /app/publish/Scripts/DNS/PoshACME + +# copy build from /app/publish in sdk image to final image +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . + +# run the service, alternatively we could runs tests etc +ENTRYPOINT ["dotnet", "test", "Certify.Core.Tests.Unit.dll", "-f", "net9.0"] diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-9_0-win.dockerfile b/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-9_0-win.dockerfile new file mode 100644 index 000000000..62599e8f0 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-9_0-win.dockerfile @@ -0,0 +1,31 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0-preview-windowsservercore-ltsc2022 AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 +EXPOSE 9696 +RUN mkdir C:\temp && pwsh -Command "Invoke-WebRequest -Method 'GET' -uri 'https://dl.smallstep.com/gh-release/cli/docs-cli-install/v0.24.4/step_windows_0.24.4_amd64.zip' -Outfile 'C:\temp\step_windows_0.24.4_amd64.zip'" && tar -oxzf C:\temp\step_windows_0.24.4_amd64.zip -C "C:\Program Files" && rmdir /s /q C:\temp +USER ContainerAdministrator +RUN setx /M PATH "%PATH%;C:\Program Files\step_0.24.4\bin" +USER ContainerUser + +# define build and copy required source files +FROM mcr.microsoft.com/dotnet/sdk:9.0-preview-windowsservercore-ltsc2022 AS build +WORKDIR /src +COPY ./certify/src ./certify/src +COPY ./certify-plugins/src ./certify-plugins/src +COPY ./certify-internal/src/Certify.Plugins ./certify-internal/src/Certify.Plugins +COPY ./libs/anvil ./libs/anvil + +# build and publish (as Debug mode) to /app/publish +FROM build AS publish +RUN dotnet publish ./certify/src/Certify.Tests/Certify.Core.Tests.Unit/Certify.Core.Tests.Unit.csproj -f net9.0 -c Debug -o /app/publish +RUN dotnet publish ./certify-internal/src/Certify.Plugins/Plugins.All/Plugins.All.csproj -f net9.0 -c Debug -o /app/publish/plugins +COPY ./libs/Posh-ACME/Posh-ACME /app/publish/Scripts/DNS/PoshACME + +# copy build from /app/publish in sdk image to final image +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . + +# run the service, alternatively we could runs tests etc +ENTRYPOINT ["dotnet", "test", "Certify.Core.Tests.Unit.dll", "-f", "net9.0"] diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/linux_compose.yaml b/src/Certify.Tests/Certify.Core.Tests.Unit/linux_compose.yaml new file mode 100644 index 000000000..2485a9d0b --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/linux_compose.yaml @@ -0,0 +1,37 @@ +name: certify-core-tests-unit-linux +services: + + certify-core-tests-unit-9_0: + image: certify-core-tests-unit-9_0-linux:latest + build: + context: ../../../../ + dockerfile: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-9_0-linux.dockerfile + ports: + - 80:80 + - 443:443 + - 9696:9696 + # environment: + # VSTEST_HOST_DEBUG: 1 + volumes: + - step:/mnt/step_share + # entrypoint: "dotnet test Certify.Core.Tests.Unit.dll -f net9.0 --filter 'ClassName=Certify.Core.Tests.Unit.CertifyManagerAccountTests'" + # entrypoint: "dotnet test Certify.Core.Tests.Unit.dll -f net9.0 --filter 'Name=TestCertifyManagerGetAccountDetails'" + depends_on: + step-ca: + condition: service_healthy + + step-ca: + image: smallstep/step-ca:latest + hostname: step-ca + ports: + - 9000:9000 + environment: + DOCKER_STEPCA_INIT_NAME: Smallstep + DOCKER_STEPCA_INIT_DNS_NAMES: localhost,step-ca + DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT: true + DOCKER_STEPCA_INIT_ACME: true + volumes: + - step:/home/step + +volumes: + step: {} diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/step-ca-win-build.bat b/src/Certify.Tests/Certify.Core.Tests.Unit/step-ca-win-build.bat new file mode 100644 index 000000000..3e792a7a2 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/step-ca-win-build.bat @@ -0,0 +1,5 @@ + mkdir C:\temp +pwsh -Command "Invoke-WebRequest -Method 'GET' -uri 'https://dl.smallstep.com/gh-release/cli/docs-cli-install/v0.24.4/step_windows_0.24.4_amd64.zip' -Outfile 'C:\temp\step_windows_0.24.4_amd64.zip'" && tar -oxzf C:\temp\step_windows_0.24.4_amd64.zip -C "C:\Program Files" +pwsh -Command "Invoke-WebRequest -Method 'GET' -uri 'https://dl.smallstep.com/gh-release/certificates/gh-release-header/v0.24.2/step-ca_windows_0.24.2_amd64.zip' -Outfile 'C:\temp\step-ca_windows_0.24.2_amd64.zip'" && tar -oxzf C:\temp\step-ca_windows_0.24.2_amd64.zip -C "C:\Program Files" +mkdir "C:\Program Files\SDelete" && pwsh -Command "Invoke-WebRequest -Method 'GET' -uri 'https://download.sysinternals.com/files/SDelete.zip' -Outfile 'C:\temp\SDelete.zip'" && tar -oxzf C:\temp\SDelete.zip -C "C:\Program Files\SDelete" +rmdir /s /q C:\temp diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/step-ca-win-init.bat b/src/Certify.Tests/Certify.Core.Tests.Unit/step-ca-win-init.bat new file mode 100644 index 000000000..56fde2918 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/step-ca-win-init.bat @@ -0,0 +1,63 @@ +FOR /F "tokens=* USEBACKQ" %%F IN (`step path`) DO ( + SET STEPPATH=%%F +) + +IF EXIST %STEPPATH%\config\ca.json ( + step-ca --password-file %STEPPATH%\secrets\password %STEPPATH%\config\ca.json + EXIT 0 +) + +IF "%DOCKER_STEPCA_INIT_NAME%"=="" ( + echo "there is no ca.json config file; please run step ca init, or provide config parameters via DOCKER_STEPCA_INIT_ vars" + EXIT 1 +) + +IF "%DOCKER_STEPCA_INIT_DNS_NAMES%"=="" ( + echo "there is no ca.json config file; please run step ca init, or provide config parameters via DOCKER_STEPCA_INIT_ vars" + EXIT 1 +) + +IF "%DOCKER_STEPCA_INIT_PROVISIONER_NAME%"=="" SET DOCKER_STEPCA_INIT_PROVISIONER_NAME=admin +IF "%DOCKER_STEPCA_INIT_ADMIN_SUBJECT%"=="" SET DOCKER_STEPCA_INIT_ADMIN_SUBJECT=step +IF "%DOCKER_STEPCA_INIT_ADDRESS%"=="" SET DOCKER_STEPCA_INIT_ADDRESS=:9000 + +IF NOT "%DOCKER_STEPCA_INIT_PASSWORD%"=="" ( +pwsh -Command "Out-File -FilePath "$Env:STEPPATH\password" -InputObject "$Env:DOCKER_STEPCA_INIT_PASSWORD";"^ + "Out-File -FilePath "$Env:STEPPATH\provisioner_password" -InputObject "$Env:DOCKER_STEPCA_INIT_PASSWORD";" +) ELSE IF NOT "%DOCKER_STEPCA_INIT_PASSWORD_FILE%"=="" ( +pwsh -Command "Out-File -FilePath "$Env:STEPPATH\password" -InputObject "$Env:DOCKER_STEPCA_INIT_PASSWORD_FILE";"^ + "Out-File -FilePath "$Env:STEPPATH\provisioner_password" -InputObject "$Env:DOCKER_STEPCA_INIT_PASSWORD_FILE";" +) ELSE ( +pwsh -Command "$psw = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'.tochararray() | Get-Random -Count 40 | Join-String;"^ + "Out-File -FilePath "$Env:STEPPATH\password" -InputObject $psw;"^ + "Out-File -FilePath "$Env:STEPPATH\provisioner_password" -InputObject $psw;"^ + "Remove-Variable psw" +) + +setlocal + +SET INIT_ARGS=--deployment-type standalone --name %DOCKER_STEPCA_INIT_NAME% --dns %DOCKER_STEPCA_INIT_DNS_NAMES% --provisioner %DOCKER_STEPCA_INIT_PROVISIONER_NAME% --password-file %STEPPATH%\password --provisioner-password-file %STEPPATH%\provisioner_password --address %DOCKER_STEPCA_INIT_ADDRESS% + +IF "%DOCKER_STEPCA_INIT_SSH%"=="true" SET INIT_ARGS=%INIT_ARGS% -ssh +IF "%DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT%"=="true" SET INIT_ARGS=%INIT_ARGS% --remote-management --admin-subject %DOCKER_STEPCA_INIT_ADMIN_SUBJECT% + +step ca init %INIT_ARGS% +SET /p psw=<%STEPPATH%\provisioner_password +echo "👉 Your CA administrative password is: %psw%" +echo "🤫 This will only be displayed once." + +endlocal + +SET HEALTH_URL=https://%DOCKER_STEPCA_INIT_DNS_NAMES%%DOCKER_STEPCA_INIT_ADDRESS%/health + +sdelete64 -accepteula -nobanner -q %STEPPATH%\provisioner_password + +move "%STEPPATH%\password" "%STEPPATH%\secrets\password" + +:: Current error with running this program in Windows Docker Container causes issue reading DB first time, so they must be deleted to be recreated +rmdir /s /q %STEPPATH%\db + +:: Current error with running this program in Windows Docker Container causes ACME not to be set with --acme +IF "%DOCKER_STEPCA_INIT_ACME%"=="true" step ca provisioner add acme --type ACME + +step-ca --password-file %STEPPATH%\secrets\password %STEPPATH%\config\ca.json diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/step-ca-win.dockerfile b/src/Certify.Tests/Certify.Core.Tests.Unit/step-ca-win.dockerfile new file mode 100644 index 000000000..a69d6aea7 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/step-ca-win.dockerfile @@ -0,0 +1,20 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0-nanoserver-ltsc2022 AS base +WORKDIR /app +EXPOSE 9000 +COPY ./step-ca-win-build.bat . +RUN step-ca-win-build.bat + +USER ContainerAdministrator +RUN setx /M PATH "%PATH%;C:\Program Files\step_0.24.4\bin;C:\Program Files\step-ca_0.24.2;C:\Program Files\SDelete" +USER ContainerUser + +FROM mcr.microsoft.com/dotnet/sdk:8.0-windowsservercore-ltsc2022 AS netapi + +FROM base AS final + +COPY ./step-ca-win-init.bat . +COPY --from=netapi /Windows/System32/netapi32.dll /Windows/System32/netapi32.dll + +HEALTHCHECK CMD curl -Method GET -f %HEALTH_URL% || exit 1 + +CMD step-ca-win-init.bat && cmd diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/unit-test-linux.runsettings b/src/Certify.Tests/Certify.Core.Tests.Unit/unit-test-linux.runsettings new file mode 100644 index 000000000..0499b1c82 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/unit-test-linux.runsettings @@ -0,0 +1,46 @@ + + + + + + $HOME/.nuget/packages/microsoft.codecoveraged/17.8.0/build/netstandard2.0 + ./TestResults-Linux + true + + + + + + + Cobertura + + + + + .*Certify.*$ + .*Plugin.Datastore.*$ + + + .*Certify.Core.Tests.Unit.dll$ + .*Moq.dll$ + .*Microsoft.*$ + + + True + + + + + + + + + + + normal + + + + + + diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/unit-test.runsettings b/src/Certify.Tests/Certify.Core.Tests.Unit/unit-test.runsettings new file mode 100644 index 000000000..72ea904e6 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/unit-test.runsettings @@ -0,0 +1,46 @@ + + + + + + %SystemDrive%\%HOMEPATH%\.nuget\packages\microsoft.codecoverage\17.8.0\build\netstandard2.0 + .\TestResults-windows + true + + + + + + + Cobertura + + + + + .*Certify.*$ + .*Plugin.Datastore.*$ + + + .*Certify.Core.Tests.Unit.dll$ + .*Moq.dll$ + .*Microsoft.*$ + + + True + + + + + + + + + + + normal + + + + + + diff --git a/src/Certify.Tests/Certify.Core.Tests.Unit/windows_compose.yaml b/src/Certify.Tests/Certify.Core.Tests.Unit/windows_compose.yaml new file mode 100644 index 000000000..3c78e9086 --- /dev/null +++ b/src/Certify.Tests/Certify.Core.Tests.Unit/windows_compose.yaml @@ -0,0 +1,62 @@ +name: certify-core-tests-unit-win +services: + + certify-core-tests-unit-9_0: + image: certify-core-tests-unit-9_0-win:latest + build: + context: ../../../../ + dockerfile: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-9_0-win.dockerfile + # environment: + # VSTEST_HOST_DEBUG: 1 + ports: + - 80:80 + - 443:443 + - 9696:9696 + # entrypoint: "dotnet test Certify.Core.Tests.Unit.dll -f net9.0 --filter 'ClassName=Certify.Core.Tests.Unit.CertifyManagerAccountTests'" + # entrypoint: "dotnet test Certify.Core.Tests.Unit.dll -f net9.0 --filter 'Name=TestCertifyManagerGetAccountDetailsDefinedCertificateAuthorityId'" + volumes: + - step:C:\step_share + profiles: ["9_0"] + depends_on: + step-ca: + condition: service_healthy + + certify-core-tests-unit-4_6_2: + image: certify-core-tests-unit-4_6_2-win:latest + build: + context: ../../../../ + dockerfile: ./certify/src/Certify.Tests/Certify.Core.Tests.Unit/certify-core-tests-unit-4_6_2-win.dockerfile + # environment: + # VSTEST_HOST_DEBUG: 1 + ports: + - 80:80 + - 443:443 + - 9696:9696 + # entrypoint: "dotnet test Certify.Core.Tests.Unit.dll -f net462 --filter 'ClassName=Certify.Core.Tests.Unit.CertifyManagerAccountTests'" + # entrypoint: "dotnet test Certify.Core.Tests.Unit.dll -f net462 --filter 'Name=TestCertifyManagerGetAccountDetailsDefinedCertificateAuthorityId'" + volumes: + - step:C:\step_share + profiles: ["4_6_2"] + depends_on: + step-ca: + condition: service_healthy + + step-ca: + image: step-ca-win:latest + build: + context: . + dockerfile: ./step-ca-win.dockerfile + hostname: step-ca + profiles: ["4_6_2", "9_0"] + ports: + - 9000:9000 + environment: + DOCKER_STEPCA_INIT_NAME: Smallstep + DOCKER_STEPCA_INIT_DNS_NAMES: localhost + DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT: true + DOCKER_STEPCA_INIT_ACME: true + volumes: + - step:C:\Users\ContainerUser\.step + +volumes: + step: {} diff --git a/src/Certify.Tests/Certify.Service.Tests.Integration/Certify.Service.Tests.Integration.csproj b/src/Certify.Tests/Certify.Service.Tests.Integration/Certify.Service.Tests.Integration.csproj index 9d3c8e642..2c33df410 100644 --- a/src/Certify.Tests/Certify.Service.Tests.Integration/Certify.Service.Tests.Integration.csproj +++ b/src/Certify.Tests/Certify.Service.Tests.Integration/Certify.Service.Tests.Integration.csproj @@ -1,7 +1,8 @@ - net7.0 + net9.0 Debug;Release; + AnyCPU @@ -12,7 +13,7 @@ DEBUG;TRACE prompt 4 - x64 + AnyCPU 1701;1702;NU1701 @@ -23,24 +24,7 @@ prompt 4 - - true - bin\x64\Debug\ - DEBUG;TRACE - full - x64 - prompt - MinimumRecommendedRules.ruleset - - - bin\x64\Release\ - TRACE - true - pdbonly - x64 - prompt - MinimumRecommendedRules.ruleset - + Debug AnyCPU @@ -65,7 +49,7 @@ - x64 + AnyCPU 1701;1702;NU1701 @@ -75,13 +59,13 @@ - + - - - - - + + + + + diff --git a/src/Certify.Tests/Certify.Service.Tests.Integration/ServiceAuthTests.cs b/src/Certify.Tests/Certify.Service.Tests.Integration/ServiceAuthTests.cs index 8380a8096..8880b2258 100644 --- a/src/Certify.Tests/Certify.Service.Tests.Integration/ServiceAuthTests.cs +++ b/src/Certify.Tests/Certify.Service.Tests.Integration/ServiceAuthTests.cs @@ -10,8 +10,10 @@ public class ServiceAuthTests : ServiceTestBase [TestMethod] public async Task TestAuthFlow() { + AuthContext authContext = null; + // use windows auth to acquire initial auth key - var authKey = await _client.GetAuthKeyWindows(); + var authKey = await _client.GetAuthKeyWindows(authContext); Assert.IsNotNull(authKey); // attempt request without jwt auth being set yet @@ -20,21 +22,21 @@ public async Task TestAuthFlow() // check should throw exception await Assert.ThrowsExceptionAsync(async () => { - var noAuthResult = await _client.GetManagedCertificates(new Models.ManagedCertificateFilter { }); + var noAuthResult = await _client.GetManagedCertificates(new Models.ManagedCertificateFilter { }, authContext); Assert.IsNull(noAuthResult); }); // use auth key to get JWT - var jwt = await _client.GetAccessToken(authKey); + var jwt = await _client.GetAccessToken(authKey, authContext); Assert.IsNotNull(jwt); // attempt request with JWT set - var authedResult = await _client.GetManagedCertificates(new Models.ManagedCertificateFilter { }); + var authedResult = await _client.GetManagedCertificates(new Models.ManagedCertificateFilter { }, authContext); Assert.IsNotNull(authedResult); // refresh JWT - var refreshedToken = await _client.RefreshAccessToken(); + var refreshedToken = await _client.RefreshAccessToken(authContext); Assert.IsNotNull(jwt); } diff --git a/src/Certify.Tests/Certify.UI.Tests.Integration/Certify.UI.Tests.Integration.csproj b/src/Certify.Tests/Certify.UI.Tests.Integration/Certify.UI.Tests.Integration.csproj index 18b711624..1a1ee27f9 100644 --- a/src/Certify.Tests/Certify.UI.Tests.Integration/Certify.UI.Tests.Integration.csproj +++ b/src/Certify.Tests/Certify.UI.Tests.Integration/Certify.UI.Tests.Integration.csproj @@ -1,7 +1,8 @@  - net7.0-windows + net9.0-windows Debug;Release; + AnyCPU true @@ -20,25 +21,9 @@ prompt 4 AnyCPU + 1701;1702;NU1701 - - true - bin\x64\Debug\ - DEBUG;TRACE - full - x64 - prompt - MinimumRecommendedRules.ruleset - - - bin\x64\Release\ - TRACE - true - pdbonly - x64 - prompt - MinimumRecommendedRules.ruleset - + Debug AnyCPU @@ -60,7 +45,8 @@ - x64 + AnyCPU + 1701;1702;NU1701 @@ -69,17 +55,13 @@ - + - - - - - - - - + + + + diff --git a/src/Certify.Tests/Certify.UI.Tests.Integration/ViewModelTests.cs b/src/Certify.Tests/Certify.UI.Tests.Integration/ViewModelTests.cs index 5c2ff6794..096938bcf 100644 --- a/src/Certify.Tests/Certify.UI.Tests.Integration/ViewModelTests.cs +++ b/src/Certify.Tests/Certify.UI.Tests.Integration/ViewModelTests.cs @@ -1,67 +1,14 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Certify.Client; -using Certify.Models; +using Certify.Models; using Certify.Models.Config; using Certify.Models.Shared.Validation; using Certify.UI.ViewModel; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Moq; namespace Certify.UI.Tests.Integration { [TestClass] public class ViewModelTest { - [TestMethod, Ignore] - public async Task TestViewModelSetup() - { - var mockClient = new Mock(); - - mockClient.Setup(c => c.GetPreferences()).Returns( - Task.FromResult(new Models.Preferences { }) - ); - - mockClient.Setup(c => c.GetManagedCertificates(It.IsAny())) - .Returns( - Task.FromResult(new List { - new ManagedCertificate{ - Id= Guid.NewGuid().ToString(), - Name="Test Managed Certificate" - } - }) - ); - - mockClient.Setup(c => c.GetAccounts()) - .Returns( - Task.FromResult( - new List { - new AccountDetails { - Email = "test@example.com", - IsStagingAccount = true, - CertificateAuthorityId = StandardCertAuthorities.LETS_ENCRYPT - } - }) - ); - - mockClient.Setup(c => c.GetCredentials()) - .Returns( - Task.FromResult(new List { }) - ); - - var appModel = new AppViewModel(mockClient.Object); - - await appModel.LoadSettingsAsync(); - - Assert.IsTrue(appModel.ManagedCertificates.Count > 0, "Should have managed sites"); - - Assert.IsTrue(appModel.HasRegisteredContacts, "Should have a registered contact"); - - await appModel.RefreshStoredCredentialsList(); - - appModel.RenewAll(new RenewalSettings { }); - } [TestMethod] public void TestManagedCertViewModelValidationWithDomains() @@ -231,6 +178,12 @@ public void TestManagedCertViewModelValidationWithDomains() Assert.IsFalse(result.IsValid, result.Message); Assert.AreEqual(ValidationErrorCodes.SAN_LIMIT.ToString(), result.ErrorCode); + model.SelectedItem = null; + result = model.Validate(applyAutoConfiguration: true); + + Assert.IsFalse(result.IsValid, result.Message); + Assert.AreEqual(ValidationErrorCodes.ITEM_NOT_FOUND.ToString(), result.ErrorCode); + } [TestMethod] diff --git a/src/Certify.UI.Desktop/App.xaml b/src/Certify.UI.Desktop/App.xaml index efbd87683..644a1a01f 100644 --- a/src/Certify.UI.Desktop/App.xaml +++ b/src/Certify.UI.Desktop/App.xaml @@ -1,8 +1,8 @@ - @@ -53,7 +53,7 @@ - @@ -71,8 +71,8 @@ - - + + diff --git a/src/Certify.UI.Desktop/Certify.UI.Desktop.csproj b/src/Certify.UI.Desktop/Certify.UI.Desktop.csproj index bc5b55f80..684e2f372 100644 --- a/src/Certify.UI.Desktop/Certify.UI.Desktop.csproj +++ b/src/Certify.UI.Desktop/Certify.UI.Desktop.csproj @@ -1,22 +1,16 @@ - - - WinExe - net7.0-windows - true - true - icon.ico - Certify.UI.App - AnyCPU;x64 - True - - - - - - - - - - - + + WinExe + net9.0-windows + true + true + icon.ico + Certify.UI.App + AnyCPU + True + NU1701 + + + + + \ No newline at end of file diff --git a/src/Certify.UI.Shared/Certify.UI.Shared.csproj b/src/Certify.UI.Shared/Certify.UI.Shared.csproj index a8973f02b..e2586a16e 100644 --- a/src/Certify.UI.Shared/Certify.UI.Shared.csproj +++ b/src/Certify.UI.Shared/Certify.UI.Shared.csproj @@ -1,11 +1,12 @@ - net462;net7.0-windows; + net462;net9.0-windows; true true 6.0.0.* + NU1701 @@ -32,16 +33,19 @@ - - - - + + + + NU1701 + + - - - + + + NU1701 + diff --git a/src/Certify.UI.Shared/Controls/AboutControl.xaml b/src/Certify.UI.Shared/Controls/AboutControl.xaml index df6c73b89..72bd281b4 100644 --- a/src/Certify.UI.Shared/Controls/AboutControl.xaml +++ b/src/Certify.UI.Shared/Controls/AboutControl.xaml @@ -81,24 +81,16 @@ Click="UpdateCheck_Click" Content="{x:Static res:SR.AboutControl_CheckForUpdateButton}" DockPanel.Dock="Top" /> - + + + action) + { + Cancel(lastCancellationTokenSource); + + var tokenSrc = lastCancellationTokenSource = new CancellationTokenSource(); + + try + { + await Task.Delay(new TimeSpan(milliseconds), tokenSrc.Token); + if (!tokenSrc.IsCancellationRequested) + { + await Task.Run(action, tokenSrc.Token); + } + } + catch (TaskCanceledException) + { + } + } - if (lvManagedCertificates.SelectedIndex == -1 && _appViewModel.SelectedItem != null) + public void Cancel(CancellationTokenSource source) { - // if the data model's selected item has come into view after filter box text - // changed, select the item in the list - if (defaultView.Filter(_appViewModel.SelectedItem)) + if (source != null) { - lvManagedCertificates.SelectedItem = _appViewModel.SelectedItem; + source.Cancel(); + source.Dispose(); } } + + public void Dispose() + { + Cancel(lastCancellationTokenSource); + } + + ~Debouncer() + { + Dispose(); + } + } + + private Debouncer _filterDebouncer = new Debouncer(); + + private async void TxtFilter_TextChanged(object sender, TextChangedEventArgs e) + { + // refresh db results, then refresh UI view + + _appViewModel.FilterKeyword = txtFilter.Text; + + await _filterDebouncer.Debounce(_appViewModel.RefreshManagedCertificates); + + var defaultView = CollectionViewSource.GetDefaultView(lvManagedCertificates.ItemsSource); + + defaultView.Refresh(); + + /* if (lvManagedCertificates.SelectedIndex == -1 && _appViewModel.SelectedItem != null) + { + // if the data model's selected item has come into view after filter box text + // changed, select the item in the list + if (defaultView.Filter(_appViewModel.SelectedItem)) + { + lvManagedCertificates.SelectedItem = _appViewModel.SelectedItem; + } + }*/ } private async void TxtFilter_PreviewKeyDown(object sender, KeyEventArgs e) @@ -169,6 +228,8 @@ private async void TxtFilter_PreviewKeyDown(object sender, KeyEventArgs e) private void ResetFilter() { + _appViewModel.FilterKeyword = string.Empty; + txtFilter.Text = ""; txtFilter.Focus(); @@ -371,6 +432,16 @@ private void GettingStarted_FilterApplied(string filter) { txtFilter.Text = filter; } + + private async void Prev_Click(object sender, RoutedEventArgs e) + { + await _appViewModel.ManagedCertificatesPrevPage(); + } + + private async void Next_Click(object sender, RoutedEventArgs e) + { + await _appViewModel.ManagedCertificatesNextPage(); + } } public static class StringExtensions diff --git a/src/Certify.UI.Shared/Controls/Settings/CertificateAuthorities.xaml b/src/Certify.UI.Shared/Controls/Settings/CertificateAuthorities.xaml index bc1787558..293bd78a4 100644 --- a/src/Certify.UI.Shared/Controls/Settings/CertificateAuthorities.xaml +++ b/src/Certify.UI.Shared/Controls/Settings/CertificateAuthorities.xaml @@ -15,13 +15,12 @@ - - Certificate Authorities are the organisations who can issue trusted certificates. You need to register an account for each (ACME) Certificate Authority you wish to use. Accounts can either be Production (live, trusted certificates) or Staging (test, non-trusted). - + - - If you register with multiple authorities this may enable you to use automatic Certificate Authority Failover, so if your preferred Certificate Authority can't issue a new certificate an alternative compatible provider can be used automatically. - +
private void Init() { - Log = new Loggy( - new LoggerConfiguration() - .MinimumLevel.Verbose() - .WriteTo.Debug() + + var serilogLog = new Serilog.LoggerConfiguration() + .Enrich.FromLogContext() + .MinimumLevel.Verbose() .WriteTo.File(Path.Combine(EnvironmentUtil.CreateAppDataPath("logs"), "ui.log"), shared: true, flushToDiskInterval: new TimeSpan(0, 0, 10)) - .CreateLogger() - ); + .CreateLogger(); + + Log = new Loggy(new Serilog.Extensions.Logging.SerilogLoggerFactory(serilogLog).CreateLogger()); ProgressResults = new ObservableCollection(); diff --git a/src/Certify.UI.Shared/Windows/EditAccountDialog.xaml b/src/Certify.UI.Shared/Windows/EditAccountDialog.xaml index 2ab1d51b4..500cd180a 100644 --- a/src/Certify.UI.Shared/Windows/EditAccountDialog.xaml +++ b/src/Certify.UI.Shared/Windows/EditAccountDialog.xaml @@ -7,7 +7,7 @@ xmlns:local="clr-namespace:Certify.UI.Windows" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:res="clr-namespace:Certify.Locales;assembly=Certify.Locales" - Title="Edit ACME Account" + Title="{x:Static res:SR.Account_Edit_SectionTitle}" Width="514" Height="416" ResizeMode="CanResizeWithGrip" @@ -43,7 +43,7 @@ VerticalAlignment="Top" DockPanel.Dock="Top" Style="{StaticResource Instructions}" - TextWrapping="Wrap"> + TextWrapping="Wrap"> + TextWrapping="Wrap"> diff --git a/src/Certify.UI.Shared/Windows/EditAccountDialog.xaml.cs b/src/Certify.UI.Shared/Windows/EditAccountDialog.xaml.cs index 63b6731f5..a597f783b 100644 --- a/src/Certify.UI.Shared/Windows/EditAccountDialog.xaml.cs +++ b/src/Certify.UI.Shared/Windows/EditAccountDialog.xaml.cs @@ -141,7 +141,7 @@ private async void Save_Click(object sender, RoutedEventArgs e) } else { - MessageBox.Show(Certify.Locales.SR.New_Contact_NeedAgree); + MessageBox.Show(Certify.Locales.SR.Account_Edit_AgreeConditions); } } diff --git a/src/Certify.UI.Shared/Windows/EditCertificateAuthority.xaml b/src/Certify.UI.Shared/Windows/EditCertificateAuthority.xaml index 3fc7669e9..1adac373d 100644 --- a/src/Certify.UI.Shared/Windows/EditCertificateAuthority.xaml +++ b/src/Certify.UI.Shared/Windows/EditCertificateAuthority.xaml @@ -51,7 +51,7 @@ Height="23" HorizontalAlignment="Left" VerticalAlignment="Top" - Controls:TextBoxHelper.Watermark="(Display Name for the Certificate Authority)" + Controls:TextBoxHelper.Watermark="{x:Static res:SR.EditCertificateAuthority_TitleHelp}" Text="{Binding Model.Item.Title}" TextWrapping="Wrap" /> @@ -88,7 +88,7 @@ Height="23" HorizontalAlignment="Left" VerticalAlignment="Top" - Controls:TextBoxHelper.Watermark="(Url for the production directory endpoint)" + Controls:TextBoxHelper.Watermark="{x:Static res:SR.EditCertificateAuthority_ProductionDirectoryHelp}" Text="{Binding Model.Item.ProductionAPIEndpoint}" TextWrapping="Wrap" /> diff --git a/src/Certify.UI.Shared/Windows/EditServerConnection.xaml b/src/Certify.UI.Shared/Windows/EditServerConnection.xaml index 50bc3e64d..baac89cd2 100644 --- a/src/Certify.UI.Shared/Windows/EditServerConnection.xaml +++ b/src/Certify.UI.Shared/Windows/EditServerConnection.xaml @@ -88,7 +88,7 @@ Width="120" Margin="0,0,8,0" VerticalAlignment="Top" - Content="Use https" /> + Content="Use Https" /> diff --git a/src/Certify.UI.Shared/Windows/ImportExport.xaml b/src/Certify.UI.Shared/Windows/ImportExport.xaml index e180c83ee..67b953e83 100644 --- a/src/Certify.UI.Shared/Windows/ImportExport.xaml +++ b/src/Certify.UI.Shared/Windows/ImportExport.xaml @@ -17,7 +17,7 @@ Import/Export Settings - You can create an export file to bundle all of the related settings and file for this instance together. Note: sensitive content is encrypted but you should not share this file with untrusted sources or use unsecured storage. + "{x:Static properties:SR.Settings_Export_Intro}" To import or export, you should specify a password to use for encryption/decryption: - - Export - Export a settings bundle including managed certificate settings, certificate files and encrypted credentials. - - - - Import - Import a settings bundle exported from another instance of the app. - - - + + + + Export a settings bundle including managed certificate settings, certificate files and encrypted credentials. + + + + + + Import a settings bundle exported from another instance of the app. + + + + - - + + + + + + - - + + - + + + - - diff --git a/src/Certify.UI.Shared/Windows/ImportExport.xaml.cs b/src/Certify.UI.Shared/Windows/ImportExport.xaml.cs index e73e58b04..c6c229066 100644 --- a/src/Certify.UI.Shared/Windows/ImportExport.xaml.cs +++ b/src/Certify.UI.Shared/Windows/ImportExport.xaml.cs @@ -3,9 +3,8 @@ using System.Linq; using System.Text; using System.Windows; - -using Certify.Config.Migration; using Certify.Models; +using Certify.Models.Config.Migration; using Certify.UI.Shared; using Microsoft.Win32; using Newtonsoft.Json; @@ -68,6 +67,7 @@ private async void Import_Click(object sender, RoutedEventArgs e) } Model.ImportSettings.OverwriteExisting = (OverwriteExisting.IsChecked == true); + Model.ImportSettings.IncludeDeployment = (IncludeDeployment.IsChecked == true); Model.ImportSettings.EncryptionSecret = txtSecret.Password; Model.InProgress = true; @@ -104,6 +104,10 @@ private async void CompleteImport_Click(object sender, RoutedEventArgs e) if (MessageBox.Show("Are you sure you wish to perform the import as shown in the preview? The import cannot be reverted once complete.", "Perform Import?", MessageBoxButton.YesNoCancel) == MessageBoxResult.Yes) { Model.InProgress = true; + + Model.ImportSettings.OverwriteExisting = (OverwriteExisting.IsChecked == true); + Model.ImportSettings.IncludeDeployment = (IncludeDeployment.IsChecked == true); + var results = await MainViewModel.PerformSettingsImport(Model.Package, Model.ImportSettings, false); PrepareImportSummary(false, results); diff --git a/src/Certify.UI.Shared/Windows/MainWindow.xaml.cs b/src/Certify.UI.Shared/Windows/MainWindow.xaml.cs index 4cdc52e0f..f6c53b8e6 100644 --- a/src/Certify.UI.Shared/Windows/MainWindow.xaml.cs +++ b/src/Certify.UI.Shared/Windows/MainWindow.xaml.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; diff --git a/src/Certify.UI.sln b/src/Certify.UI.sln index e202963f1..e312b4c08 100644 --- a/src/Certify.UI.sln +++ b/src/Certify.UI.sln @@ -17,6 +17,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{12876723-F648-4E76-9242-110F5635A4B1}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig + Directory.Build.props = Directory.Build.props EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{9A70DEC0-70C4-42A7-B15A-647EC432E7F7}" diff --git a/src/Certify.UI/App.xaml b/src/Certify.UI/App.xaml index 014fd7976..450fd8628 100644 --- a/src/Certify.UI/App.xaml +++ b/src/Certify.UI/App.xaml @@ -1,8 +1,8 @@ - @@ -53,7 +53,7 @@ - @@ -71,8 +71,8 @@ - - + + diff --git a/src/Certify.UI/Certify.UI.csproj b/src/Certify.UI/Certify.UI.csproj index 5e47bfd30..68f45af6b 100644 --- a/src/Certify.UI/Certify.UI.csproj +++ b/src/Certify.UI/Certify.UI.csproj @@ -61,24 +61,6 @@ app.manifest - - bin\x64\Debug\ - DEBUG;TRACE - false - x64 - prompt - ..\CodeAnalysis.ruleset - true - - - bin\Release\ - TRACE - true - x64 - prompt - MinimumRecommendedRules.ruleset - full - @@ -150,7 +132,7 @@ - 6.8.0 + 6.8.1 runtime; build; native; contentfiles; analyzers all @@ -158,7 +140,7 @@ 4.7.0.9 - 2.4.10 + 3.0.0-alpha0492 0.5.0.1 @@ -176,11 +158,8 @@ 4.1.0 all - - 2.12.0 - - 7.0.2 + 8.0.1 4.3.4 diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 000000000..78d0ac19d --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,11 @@ + + + preview + true + + + + + + +