diff --git a/.dockerignore b/.dockerignore index 3729ff0..852d6cb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,25 +1,17 @@ -**/.classpath -**/.dockerignore -**/.env -**/.git -**/.gitignore -**/.project -**/.settings -**/.toolstarget +# Docker build context is the repository root. Keep it small and free of build artifacts. +**/bin +**/obj **/.vs **/.vscode -**/*.*proj.user -**/*.dbmdl -**/*.jfm -**/azds.yaml -**/bin -**/charts -**/docker-compose* +.git +.github +.bot +test +docs +**/*.user +**/.dockerignore **/Dockerfile* -**/node_modules -**/npm-debug.log -**/obj -**/secrets.dev.yaml -**/values.dev.yaml -LICENSE -README.md \ No newline at end of file +**/docker-compose* +**/coverage* +**/*.md +LICENSE.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..39a212f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,92 @@ +# Remove the line below if you want to inherit .editorconfig settings from higher directories +root = true + +#### Core EditorConfig Options #### + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{cs,csx,vb,vbx}] +indent_size = 4 +tab_width = 4 + +[*.{xml,csproj,props,targets,config,nuspec,slnx}] +indent_size = 2 +tab_width = 2 + +[*.{json,yml,yaml}] +indent_size = 2 +tab_width = 2 + +[*.md] +trim_trailing_whitespace = false + +#### .NET Coding Conventions #### + +[*.cs] + +# Organize usings +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +# File-scoped namespaces (required) +csharp_style_namespace_declarations = file_scoped:error + +# No top-level statements (required) +csharp_style_prefer_top_level_statements = false:error + +# this. and Me. preferences +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +# var preferences +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = false:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_constructors = false:suggestion +csharp_style_expression_bodied_properties = true:suggestion +csharp_style_expression_bodied_accessors = true:suggestion +csharp_style_expression_bodied_lambdas = true:suggestion + +# Null-checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Prefer explicit guard clauses over conditional expressions in returns and assignments +dotnet_style_prefer_conditional_expression_over_return = false +dotnet_style_prefer_conditional_expression_over_assignment = false + +# Pattern matching +csharp_style_prefer_pattern_matching = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_switch_expression = true:suggestion + +# Code block preferences +csharp_prefer_braces = true:suggestion + +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true + +# Indentation preferences +csharp_indent_case_contents = true +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..df37723 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,33 @@ +# Contributing to Static Content Provider + +Thank you for your interest in contributing! + +## How to Contribute + +1. Fork the repository. +2. Create a feature branch. +3. Commit your changes following the project's commit conventions. +4. Push to your branch and open a pull request against `main`. + +## Development Setup + +```bash +dotnet restore Codebelt.Cdn.Origin.slnx +dotnet build Codebelt.Cdn.Origin.slnx +dotnet test Codebelt.Cdn.Origin.slnx +``` + +## Code Standards + +- Target `net10.0` and use modern minimal hosting. +- Prefer ASP.NET Core and the BCL over new dependencies. New dependencies require justification (see `AGENTS.md`). +- Use file-scoped namespaces and follow the existing code style (enforced via `.editorconfig`; verify with `dotnet format --severity info --verify-no-changes`). +- All public APIs must have XML documentation comments. +- Behavioural changes to the HTTP contract require functional tests and a `README.md` update. + +## Pull Request Guidelines + +- Keep pull requests focused on a single concern. +- Maintain 100% line and branch coverage for application-owned decision logic. +- Update `CHANGELOG.md`. +- Ensure all CI checks pass before requesting review. diff --git a/.github/actions/container-attest-provenance/action.yml b/.github/actions/container-attest-provenance/action.yml new file mode 100644 index 0000000..bb94507 --- /dev/null +++ b/.github/actions/container-attest-provenance/action.yml @@ -0,0 +1,20 @@ +name: Attest container provenance +description: Create and registry-publish a GitHub build-provenance attestation for a container image. + +inputs: + subject-name: + description: Fully qualified image repository without a tag. + required: true + subject-digest: + description: Immutable image digest in sha256 form. + required: true + +runs: + using: composite + steps: + - name: Attest image provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ${{ inputs.subject-name }} + subject-digest: ${{ inputs.subject-digest }} + push-to-registry: true diff --git a/.github/actions/container-attest-sbom/action.yml b/.github/actions/container-attest-sbom/action.yml new file mode 100644 index 0000000..0e7f600 --- /dev/null +++ b/.github/actions/container-attest-sbom/action.yml @@ -0,0 +1,24 @@ +name: Attest container SBOM +description: Create and registry-publish a GitHub SBOM attestation for a container image. + +inputs: + subject-name: + description: Fully qualified image repository without a tag. + required: true + subject-digest: + description: Immutable image digest in sha256 form. + required: true + sbom-path: + description: SPDX JSON SBOM path. + required: true + +runs: + using: composite + steps: + - name: Attest image SBOM + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ${{ inputs.subject-name }} + subject-digest: ${{ inputs.subject-digest }} + sbom-path: ${{ inputs.sbom-path }} + push-to-registry: true diff --git a/.github/actions/container-sbom/action.yml b/.github/actions/container-sbom/action.yml new file mode 100644 index 0000000..2a1ba47 --- /dev/null +++ b/.github/actions/container-sbom/action.yml @@ -0,0 +1,28 @@ +name: Generate container SBOM +description: Generate an SPDX JSON SBOM for a local Docker image. + +inputs: + image: + description: Fully qualified local image reference, including its tag. + required: true + sbom-path: + description: Output path for the SPDX JSON SBOM. + required: true + +outputs: + sbom-path: + description: The generated SBOM path. + value: ${{ inputs.sbom-path }} + +runs: + using: composite + steps: + - name: Generate SPDX JSON SBOM + uses: anchore/sbom-action@e11c554f704a0b820cbf8c51673f6945e0731532 # v0.20.0 + with: + image: ${{ inputs.image }} + format: spdx-json + output-file: ${{ inputs.sbom-path }} + upload-artifact: false + upload-release-assets: false + dependency-snapshot: false diff --git a/.github/actions/docker-build/action.yml b/.github/actions/docker-build/action.yml new file mode 100644 index 0000000..b63d9cf --- /dev/null +++ b/.github/actions/docker-build/action.yml @@ -0,0 +1,39 @@ +name: Build Docker image +description: Build a Docker image into the local Docker daemon without pushing it. + +inputs: + context: + description: Docker build context. + required: false + default: . + file: + description: Dockerfile path. + required: false + default: src/Codebelt.Cdn.Origin/Dockerfile + image: + description: Fully qualified local image reference, including its tag. + required: true + +outputs: + image-id: + description: The local Docker image ID. + value: ${{ steps.build.outputs.image-id }} + +runs: + using: composite + steps: + - id: build + name: Build image + shell: bash + env: + BUILD_CONTEXT: ${{ inputs.context }} + DOCKERFILE: ${{ inputs.file }} + IMAGE: ${{ inputs.image }} + run: | + set -euo pipefail + + test -n "$IMAGE" + docker build --pull --file "$DOCKERFILE" --tag "$IMAGE" "$BUILD_CONTEXT" + + image_id="$(docker image inspect --format '{{.Id}}' "$IMAGE")" + printf 'image-id=%s\n' "$image_id" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/docker-load/action.yml b/.github/actions/docker-load/action.yml new file mode 100644 index 0000000..1e8cd5a --- /dev/null +++ b/.github/actions/docker-load/action.yml @@ -0,0 +1,34 @@ +name: Load Docker image +description: Load a Docker image tar archive into the local Docker daemon. + +inputs: + image: + description: Fully qualified image reference expected after loading. + required: true + tarball: + description: Docker image tar archive path. + required: true + +outputs: + image-id: + description: The loaded Docker image ID. + value: ${{ steps.load.outputs.image-id }} + +runs: + using: composite + steps: + - id: load + name: Load image archive + shell: bash + env: + IMAGE: ${{ inputs.image }} + TARBALL: ${{ inputs.tarball }} + run: | + set -euo pipefail + + test -n "$IMAGE" + test -f "$TARBALL" + docker image load --input "$TARBALL" + + image_id="$(docker image inspect --format '{{.Id}}' "$IMAGE")" + printf 'image-id=%s\n' "$image_id" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/docker-login/action.yml b/.github/actions/docker-login/action.yml new file mode 100644 index 0000000..4350219 --- /dev/null +++ b/.github/actions/docker-login/action.yml @@ -0,0 +1,30 @@ +name: Log in to Docker registry +description: Authenticate the local Docker daemon to one registry using password-stdin. + +inputs: + registry: + description: Registry host, without a repository path. + required: true + username: + description: Registry username. + required: true + password: + description: Registry password or token. + required: true + +runs: + using: composite + steps: + - name: Authenticate Docker daemon + shell: bash + env: + REGISTRY: ${{ inputs.registry }} + USERNAME: ${{ inputs.username }} + PASSWORD: ${{ inputs.password }} + run: | + set -euo pipefail + + test -n "$REGISTRY" + test -n "$USERNAME" + test -n "$PASSWORD" + printf '%s' "$PASSWORD" | docker login "$REGISTRY" --username "$USERNAME" --password-stdin diff --git a/.github/actions/docker-push/action.yml b/.github/actions/docker-push/action.yml new file mode 100644 index 0000000..e0196fc --- /dev/null +++ b/.github/actions/docker-push/action.yml @@ -0,0 +1,47 @@ +name: Push Docker image +description: Push a loaded Docker image and expose the registry digest. + +inputs: + image: + description: Fully qualified image reference, including its tag. + required: true + +outputs: + image-digest: + description: Immutable registry digest returned by Docker after the push. + value: ${{ steps.push.outputs.image-digest }} + +runs: + using: composite + steps: + - id: push + name: Push image + shell: bash + env: + IMAGE: ${{ inputs.image }} + PUSH_LOG: ${{ runner.temp }}/codebelt-docker-push.log + run: | + set -euo pipefail + + test -n "$IMAGE" + docker image inspect "$IMAGE" >/dev/null + + set +e + docker image push "$IMAGE" 2>&1 | tee "$PUSH_LOG" + push_status=${PIPESTATUS[0]} + set -e + + if [[ "$push_status" -ne 0 ]]; then + exit "$push_status" + fi + + image_digest="$(grep -oE 'digest: sha256:[0-9a-f]{64}' "$PUSH_LOG" | tail -n 1 | sed 's/.*digest: //')" + if [[ -z "$image_digest" ]]; then + repository_digest="$(docker image inspect --format '{{index .RepoDigests 0}}' "$IMAGE" 2>/dev/null || true)" + if [[ "$repository_digest" == *@* ]]; then + image_digest="${repository_digest##*@}" + fi + fi + + test -n "$image_digest" + printf 'image-digest=%s\n' "$image_digest" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/docker-save/action.yml b/.github/actions/docker-save/action.yml new file mode 100644 index 0000000..31194d7 --- /dev/null +++ b/.github/actions/docker-save/action.yml @@ -0,0 +1,52 @@ +name: Save Docker image +description: Save a local Docker image to a tar archive without contacting a registry. + +inputs: + image: + description: Fully qualified local image reference, including its tag. + required: true + tarball: + description: Output path for the Docker image tar archive. + required: true + additional-images: + description: Optional newline-delimited image references to include in the same archive. + required: false + default: '' + +outputs: + tarball: + description: The saved Docker image tar archive path. + value: ${{ steps.save.outputs.tarball }} + +runs: + using: composite + steps: + - id: save + name: Save image archive + shell: bash + env: + IMAGE: ${{ inputs.image }} + TARBALL: ${{ inputs.tarball }} + ADDITIONAL_IMAGES: ${{ inputs.additional-images }} + run: | + set -euo pipefail + + test -n "$IMAGE" + test -n "$TARBALL" + + images=("$IMAGE") + while IFS= read -r additional_image; do + if [[ -n "$additional_image" ]]; then + images+=("$additional_image") + fi + done <<< "$ADDITIONAL_IMAGES" + + for image in "${images[@]}"; do + docker image inspect "$image" >/dev/null + done + + mkdir -p "$(dirname -- "$TARBALL")" + docker image save --output "$TARBALL" "${images[@]}" + test -s "$TARBALL" + + printf 'tarball=%s\n' "$TARBALL" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/docker-tag-semver/action.yml b/.github/actions/docker-tag-semver/action.yml new file mode 100644 index 0000000..fac8e59 --- /dev/null +++ b/.github/actions/docker-tag-semver/action.yml @@ -0,0 +1,53 @@ +name: Tag Docker image with SemVer +description: Create a Docker tag from a build version, removing one leading v. + +inputs: + source-image: + description: Fully qualified local source image reference, including its tag. + required: true + repository: + description: Fully qualified target image repository without a tag. + required: true + version: + description: SemVer value, normally from the build job output. + required: true + +outputs: + version: + description: Docker-compatible SemVer without a leading v. + value: ${{ steps.tag.outputs.version }} + image: + description: Fully qualified image reference with the SemVer tag. + value: ${{ steps.tag.outputs.image }} + +runs: + using: composite + steps: + - id: tag + name: Create SemVer image tag + shell: bash + env: + SOURCE_IMAGE: ${{ inputs.source-image }} + REPOSITORY: ${{ inputs.repository }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + + test -n "$SOURCE_IMAGE" + test -n "$REPOSITORY" + test -n "$VERSION" + docker image inspect "$SOURCE_IMAGE" >/dev/null + + version="${VERSION#v}" + semver_pattern='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$' + if [[ ! "$version" =~ $semver_pattern ]]; then + echo "::error::The build version '$VERSION' is not a Docker-compatible SemVer after removing a leading v." + exit 1 + fi + + image="$REPOSITORY:$version" + docker image tag "$SOURCE_IMAGE" "$image" + docker image inspect "$image" >/dev/null + + printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" + printf 'image=%s\n' "$image" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/docker-tag-trunkver/action.yml b/.github/actions/docker-tag-trunkver/action.yml new file mode 100644 index 0000000..26f778b --- /dev/null +++ b/.github/actions/docker-tag-trunkver/action.yml @@ -0,0 +1,62 @@ +name: Tag Docker image with TrunkVer +description: Create a Docker tag using a generated or supplied TrunkVer value. + +inputs: + source-image: + description: Fully qualified local source image reference, including its tag. + required: true + repository: + description: Fully qualified target image repository without a tag. + required: true + trunkver: + description: Existing TrunkVer to reuse during promotion; leave empty to generate one. + required: false + default: '' + +outputs: + trunkver: + description: Docker-compatible TrunkVer value. + value: ${{ steps.tag.outputs.trunkver }} + image: + description: Fully qualified image reference with the TrunkVer tag. + value: ${{ steps.tag.outputs.image }} + +runs: + using: composite + steps: + - id: generate + name: Generate TrunkVer + if: ${{ inputs.trunkver == '' }} + uses: crftd-tech/trunkver@4173fc34c1add2a020485db1d255dc1ee8e8c5cc # main + + - id: tag + name: Create TrunkVer image tag + shell: bash + env: + SOURCE_IMAGE: ${{ inputs.source-image }} + REPOSITORY: ${{ inputs.repository }} + GENERATED_TRUNKVER: ${{ steps.generate.outputs.trunkver }} + SUPPLIED_TRUNKVER: ${{ inputs.trunkver }} + run: | + set -euo pipefail + + test -n "$SOURCE_IMAGE" + test -n "$REPOSITORY" + docker image inspect "$SOURCE_IMAGE" >/dev/null + + trunkver="$SUPPLIED_TRUNKVER" + if [[ -z "$trunkver" ]]; then + trunkver="$GENERATED_TRUNKVER" + fi + + if [[ ! "$trunkver" =~ ^[0-9A-Za-z][0-9A-Za-z_.-]{0,127}$ ]]; then + echo "::error::The TrunkVer '$trunkver' is not a valid Docker tag." + exit 1 + fi + + image="$REPOSITORY:$trunkver" + docker image tag "$SOURCE_IMAGE" "$image" + docker image inspect "$image" >/dev/null + + printf 'trunkver=%s\n' "$trunkver" >> "$GITHUB_OUTPUT" + printf 'image=%s\n' "$image" >> "$GITHUB_OUTPUT" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..51282cf --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,629 @@ +--- +description: 'Writing Unit Tests' +applyTo: "**/*.{cs,csproj}" +--- + +# Writing Unit Tests +This document provides instructions for writing unit tests for a project/solution. Please follow these guidelines to ensure consistency and maintainability. + +## 1. Base Class + +**Always inherit from the `Test` base class** for all unit test classes. +This ensures consistent setup, teardown, and output handling across all tests. + +> Important: Do NOT add `using Xunit.Abstractions`. xUnit v3 no longer exposes that namespace; including it is incorrect and will cause compilation errors. Use the `Codebelt.Extensions.Xunit` Test base class and `using Xunit;` as shown in the examples below. If you need access to test output, rely on the Test base class (which accepts the appropriate output helper) rather than importing `Xunit.Abstractions`. + +```csharp +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Your.Namespace; + +public class YourTestClass : Test +{ + public YourTestClass(ITestOutputHelper output) : base(output) + { + } + + // Your tests here +} +``` + +## 2. Test Method Attributes + +- Use `[Fact]` for standard unit tests. +- Use `[Theory]` with `[InlineData]` or other data sources for parameterized tests. + +## 3. Naming Conventions + +- **Test classes**: End with `Test` (e.g., `DateSpanTest`). +- **Test methods**: Use descriptive names that state the expected behavior (e.g., `ShouldReturnTrue_WhenConditionIsMet`). + +## 4. Assertions + +- Use `Assert` methods from xUnit for all assertions. +- Prefer explicit and expressive assertions (e.g., `Assert.Equal`, `Assert.NotNull`, `Assert.Contains`). + +## 5. File and Namespace Organization + +- **Always use file-scoped namespaces** (`namespace YourProject.Foo.Bar;`) — the entire codebase has been refactored to file-scoped namespaces. Never use block-scoped namespaces. +- Place test files in the appropriate test project and folder structure. +- Use namespaces that mirror the source code structure. The namespace of a test file MUST match the namespace of the System Under Test (SUT). Do NOT append ".Tests", ".Benchmarks" or similar suffixes to the namespace. Only the assembly/project name should indicate that the file is a test/benchmark (for example: YourProject.Foo.Tests assembly, but namespace YourProject.Foo). + - Example: If the SUT class is declared as: + ```csharp + namespace YourProject.Foo.Bar; + + public class Zoo { /* ... */ } + ``` + then the corresponding unit test class must use the exact same namespace: + ```csharp + namespace YourProject.Foo.Bar; + + public class ZooTest : Test { /* ... */ } + ``` + - Do NOT use: + ```csharp + namespace YourProject.Foo.Bar.Tests { /* ... */ } // ❌ + namespace YourProject.Foo.Bar.Benchmarks { /* ... */ } // ❌ + ``` + - The unit tests for the YourProject.Foo assembly live in the YourProject.Foo.Tests assembly. + - The functional tests for the YourProject.Foo assembly live in the YourProject.Foo.FunctionalTests assembly. + - Test class names end with Test and live in the same namespace as the class being tested, e.g., the unit tests for the Boo class that resides in the YourProject.Foo assembly would be named BooTest and placed in the YourProject.Foo namespace in the YourProject.Foo.Tests assembly. + - Modify the associated .csproj file to override the root namespace so the compiled namespace matches the SUT. Example: + ```xml + + YourProject.Foo + + ``` +- When generating test scaffolding automatically, resolve the SUT's namespace from the source file (or project/assembly metadata) and use that exact namespace in the test file header. + +- Notes: + - This rule ensures type discovery and XML doc links behave consistently and reduces confusion when reading tests. + - Keep folder structure aligned with the production code layout to make locating SUT <-> test pairs straightforward. + +## 6. Example Test + +```csharp +using System; +using System.Globalization; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace YourProject; + +/// +/// Tests for the class. +/// +public class DateSpanTest : Test +{ + public DateSpanTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Parse_ShouldGetOneMonthOfDifference_UsingIso8601String() + { + var start = new DateTime(2021, 3, 5).ToString("O"); + var end = new DateTime(2021, 4, 5).ToString("O"); + + var span = DateSpan.Parse(start, end); + + Assert.Equal("0:01:31:00:00:00.0", span.ToString()); + Assert.Equal(0, span.Years); + Assert.Equal(1, span.Months); + Assert.Equal(31, span.Days); + Assert.Equal(0, span.Hours); + Assert.Equal(0, span.Minutes); + Assert.Equal(0, span.Seconds); + Assert.Equal(0, span.Milliseconds); + + Assert.Equal(0.08493150684931507, span.TotalYears); + Assert.Equal(1, span.TotalMonths); + Assert.Equal(31, span.TotalDays); + Assert.Equal(744, span.TotalHours); + Assert.Equal(44640, span.TotalMinutes); + Assert.Equal(2678400, span.TotalSeconds); + Assert.Equal(2678400000, span.TotalMilliseconds); + + Assert.Equal(6, span.GetWeeks()); + Assert.Equal(-1566296493, span.GetHashCode()); + + TestOutput.WriteLine(span.ToString()); + } +} +``` + +## 7. Additional Guidelines + +- Keep tests focused and isolated. +- Do not rely on external systems except for xUnit itself and Codebelt.Extensions.Xunit (and derived from this). +- Ensure tests are deterministic and repeatable. + +## 8. Test Doubles + +- Preferred test doubles include dummies, fakes, stubs and spies if and when the design allows it. +- Under special circumstances, mock can be used (using Moq library). +- Before overriding methods, verify that the method is virtual or abstract; this rule also applies to mocks. +- Never mock IMarshaller; always use a new instance of JsonMarshaller. + +## 9. Avoid `InternalsVisibleTo` in Tests + +- **Do not** use `InternalsVisibleTo` to access internal types or members from test projects. +- Prefer **indirect testing via public APIs** that depend on the internal implementation (public facades, public extension methods, or other public entry points). + +### Preferred Pattern + +**Pattern name:** Public Facade Testing (also referred to as *Public API Proxy Testing*) + +**Description:** +Internal classes and methods must be validated by exercising the public API that consumes them. Tests should assert observable behavior exposed by the public surface rather than targeting internal implementation details directly. + +### Example Mapping + +- **Internal helper:** `DelimitedString` (internal static class) +- **Public API:** `TestOutputHelperExtensions.WriteLines()` (public extension method) +- **Test strategy:** Write tests for `WriteLines()` and verify its public behavior. The internal call to `DelimitedString.Create()` is exercised implicitly. + +### Benefits + +- Avoids exposing internal types to test assemblies. +- Ensures tests reflect real-world usage patterns. +- Maintains strong encapsulation and a clean public API. +- Tests remain resilient to internal refactoring as long as public behavior is preserved. + +### When to Apply + +- Internal logic is fully exercised through existing public APIs. +- Public entry points provide sufficient coverage of internal code paths. +- The internal implementation exists solely as a helper or utility for public-facing functionality. + +## 10. ExcludeFromCodeCoverage Prohibition + +**Do not use `ExcludeFromCodeCoverage` attribute on any code.** This includes: + +- Test classes or test methods +- Production code +- Configuration code +- Any other code path + +### Rationale + +- Excluding code from coverage hides gaps and creates false confidence in test completeness. +- If a code path cannot or should not be tested, refactor the code to eliminate that path rather than hiding it from metrics. +- Every executable line should be covered by tests or be genuinely unreachable (dead code to be removed). + +### Alternative Approaches + +- **Untestable code paths**: Refactor to separate concerns and eliminate the untestable path. +- **External dependencies**: Use test doubles (fakes, stubs, spies) instead of excluding from coverage. +- **Configuration-only code**: Move to configuration files or extract into testable methods. +- **Generated or third-party code**: These should not be in the primary codebase; use NuGet packages or dedicated vendor folders if necessary. + +--- +description: 'Writing Performance Tests' +applyTo: "tuning/**, **/*Benchmark*.cs" +--- + +# Writing Performance Tests +This document provides guidance for writing performance tests (benchmarks) for a project/solution using BenchmarkDotNet. Follow these guidelines to keep benchmarks consistent, readable, and comparable. + +## 1. Naming and Placement + +- Place micro- and component-benchmarks under the `tuning/` folder or in projects named `*.Benchmarks`. +- Place benchmark files in the appropriate benchmark project and folder structure. +- Use namespaces that mirror the source code structure, e.g. do not suffix with `Benchmarks`. +Namespace rule: DO NOT append `.Benchmarks` to the namespace. Benchmarks must live in the same namespace as the production assembly. Example: if the production assembly uses `namespace YourProject.Security.Cryptography`, the benchmark file should also use: + ``` + namespace YourProject.Security.Cryptography + { + public class Sha512256Benchmark { /* ... */ } + } + ``` +The class name must end with `Benchmark`, but the namespace must match the assembly (no `.Benchmarks` suffix). +- The benchmarks for the YourProject.Bar assembly live in the YourProject.Bar.Benchmarks assembly. +- Benchmark class names end with Benchmark and live in the same namespace as the class being measured, e.g., the benchmarks for the Zoo class that resides in the YourProject.Bar assembly would be named ZooBenchmark and placed in the YourProject.Bar namespace in the YourProject.Bar.Benchmarks assembly. +- Modify the associated .csproj file to override the root namespace, e.g., YourProject.Bar. + +## 2. Attributes and Configuration + +- Use `BenchmarkDotNet` attributes to express intent and collect relevant metrics: + - `[MemoryDiagnoser]` to capture memory allocations. + - `[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]` to group related benchmarks. + - `[Params]` for input sizes or variations to exercise multiple scenarios. + - `[GlobalSetup]` for one-time initialization that's not part of measured work. + - `[Benchmark]` on methods representing measured operations; consider `Baseline = true` and `Description` to improve report clarity. +- Keep benchmark configuration minimal and explicit; prefer in-class attributes over large shared configs unless re-used widely. + +## 3. Structure and Best Practices + +- Keep benchmarks focused: each `Benchmark` method should measure a single logical operation. +- Avoid doing expensive setup work inside a measured method; use `[GlobalSetup]`, `[IterationSetup]`, or cached fields instead. +- Use `Params` to cover micro, mid and macro input sizes (for example: small, medium, large) and verify performance trends across them. +- Use small, deterministic data sets and avoid external systems (network, disk, DB). If external systems are necessary, mark them clearly and do not include them in CI benchmark runs by default. +- Capture results that are meaningful: time, allocations, and if needed custom counters. Prefer `MemoryDiagnoser` and descriptive `Description` values. + +## 4. Naming Conventions for Methods + +- Method names should be descriptive and indicate the scenario, e.g., `Parse_Short`, `ComputeHash_Large`. +- When comparing implementations, mark one method with `Baseline = true` and use similar names so reports are easy to read. + +## 5. Example Benchmark + +```csharp +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; + +namespace YourProject +{ + [MemoryDiagnoser] + [GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] + public class SampleOperationBenchmark + { + [Params(8, 256, 4096)] + public int Count { get; set; } + + private byte[] _payload; + + [GlobalSetup] + public void Setup() + { + _payload = new byte[Count]; + // deterministic initialization + } + + [Benchmark(Baseline = true, Description = "Operation - baseline")] + public int Operation_Baseline() => SampleOperation.Process(_payload); + + [Benchmark(Description = "Operation - optimized")] + public int Operation_Optimized() => SampleOperation.ProcessOptimized(_payload); + } +} +``` + +## 6. Reporting and CI + +- Benchmarks are primarily for local and tuning runs; be cautious about running heavy BenchmarkDotNet workloads in CI. Prefer targeted runs or harnesses for CI where appropriate. +- Keep benchmark projects isolated (e.g., `tuning/*.csproj`) so they don't affect package builds or production artifacts. + +## 7. Additional Guidelines + +- Keep benchmarks readable and well-documented; add comments explaining non-obvious choices. +- If a benchmark exposes regressions or optimizations, add a short note in the benchmark file referencing the relevant issue or PR. +- For any shared helpers for benchmarking, prefer small utility classes inside the `tuning` projects rather than cross-cutting changes to production code. + +For further examples, refer to the benchmark files under the `tuning/` folder. + +--- +description: 'Writing XML documentation' +applyTo: "**/*.cs" +--- + +# Writing XML documentation +This document provides instructions for writing XML documentation. + +## 1. Documentation Style + +- Use the same documentation style as found throughout the codebase. +- Add XML doc comments to public and protected classes and methods where appropriate. +- Example: + +```csharp +using System; +using System.Collections.Generic; +using System.IO; +using Cuemon.Collections.Generic; +using Cuemon.Configuration; +using Cuemon.IO; +using Cuemon.Text; + +namespace Cuemon.Security +{ + /// + /// Represents the base class from which all implementations of hash algorithms and checksums should derive. + /// + /// The type of the configured options. + /// + /// + /// + public abstract class Hash : Hash, IConfigurable where TOptions : ConvertibleOptions, new() + { + /// + /// Initializes a new instance of the class. + /// + /// The which may be configured. + protected Hash(Action setup) + { + Options = Patterns.Configure(setup); + } + + /// + /// Gets the configured options of this instance. + /// + /// The configured options of this instance. + public TOptions Options { get; } + + + /// + /// The endian-initializer of this instance. + /// + /// An instance of the configured options. + protected sealed override void EndianInitializer(EndianOptions options) + { + options.ByteOrder = Options.ByteOrder; + } + } + + /// + /// Represents the base class that defines the public facing structure to expose. + /// + /// + public abstract class Hash : IHash + { + /// + /// Initializes a new instance of the class. + /// + protected Hash() + { + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(bool input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(byte input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(char input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(DateTime input) + { + return ComputeHash(Convertible.GetBytes(input)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(DBNull input) + { + return ComputeHash(Convertible.GetBytes(input)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(decimal input) + { + return ComputeHash(Convertible.GetBytes(input)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(double input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(short input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(int input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(long input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(sbyte input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(float input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(ushort input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(uint input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(ulong input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// The which may be configured. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(string input, Action setup = null) + { + return ComputeHash(Convertible.GetBytes(input, setup)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(Enum input) + { + return ComputeHash(Convertible.GetBytes(input, EndianInitializer)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(params IConvertible[] input) + { + return ComputeHash(Arguments.ToEnumerableOf(input)); + } + + /// + /// Computes the hash value for the specified sequence of . + /// + /// The sequence of to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(IEnumerable input) + { + return ComputeHash(Convertible.GetBytes(input)); + } + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public abstract HashResult ComputeHash(byte[] input); + + /// + /// Computes the hash value for the specified . + /// + /// The to compute the hash code for. + /// A containing the computed hash code of the specified . + public virtual HashResult ComputeHash(Stream input) + { + return ComputeHash(Patterns.SafeInvoke(() => new MemoryStream(), destination => + { + Decorator.Enclose(input).CopyStream(destination); + return destination; + }).ToArray()); + } + + /// + /// Defines the initializer that must implement. + /// + /// An instance of the configured options. + protected abstract void EndianInitializer(EndianOptions options); + } +} + +namespace Cuemon.Security +{ + /// + /// Configuration options for . + /// + public class FowlerNollVoOptions : ConvertibleOptions + { + /// + /// Initializes a new instance of the class. + /// + /// + /// The following table shows the initial property values for an instance of . + /// + /// + /// Property + /// Initial Value + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public FowlerNollVoOptions() + { + Algorithm = FowlerNollVoAlgorithm.Fnv1a; + ByteOrder = Endianness.BigEndian; + } + + /// + /// Gets or sets the algorithm of the Fowler-Noll-Vo hash function. + /// + /// The algorithm of the Fowler-Noll-Vo hash function. + public FowlerNollVoAlgorithm Algorithm { get; set; } + } +} +``` diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f7c25ca --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/.github/workflows" + schedule: + interval: "weekly" + - package-ecosystem: "docker" + directory: "/src/Codebelt.Cdn.Origin" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml new file mode 100644 index 0000000..13e7621 --- /dev/null +++ b/.github/workflows/ci-pipeline.yml @@ -0,0 +1,609 @@ +name: Static Content Provider CI Pipeline + +on: + pull_request: + branches: [main] + workflow_dispatch: + inputs: + run_mac_tests: + type: boolean + description: Run the macOS test matrix despite the additional cost and runtime. + default: false + publish_image: + type: boolean + description: Publish the saved container image to the configured Staging registry after all gates pass. + default: false + container_repository: + type: string + description: Fully qualified image repository without a tag. + required: true + default: jcr.codebelt.net/geekle/web-cdn-origin + promote_dockerhub: + type: boolean + description: Promote the verified Staging image to Docker Hub after Production approval. + default: false + dockerhub_repository: + type: string + description: Docker Hub repository without the registry host or a tag. + required: true + default: codebeltnet/web-cdn-origin + +permissions: + contents: read + +jobs: + init: + name: initialize + runs-on: ubuntu-24.04 + outputs: + run-mac-tests: ${{ steps.vars.outputs.run-mac-tests }} + run-privileged-jobs: ${{ steps.vars.outputs.run-privileged-jobs }} + publish-image: ${{ steps.vars.outputs.publish-image }} + container-repository: ${{ steps.vars.outputs.container-repository }} + container-registry: ${{ steps.vars.outputs.container-registry }} + promote-dockerhub: ${{ steps.vars.outputs.promote-dockerhub }} + dockerhub-repository: ${{ steps.vars.outputs.dockerhub-repository }} + steps: + - id: vars + name: Calculate workflow variables + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + RUN_MAC_INPUT: ${{ inputs.run_mac_tests }} + PUBLISH_IMAGE_INPUT: ${{ inputs.publish_image }} + PROMOTE_DOCKERHUB_INPUT: ${{ inputs.promote_dockerhub }} + CONTAINER_REPOSITORY_INPUT: ${{ inputs.container_repository }} + DOCKERHUB_REPOSITORY_INPUT: ${{ inputs.dockerhub_repository }} + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + CURRENT_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + + if [[ "$EVENT_NAME" == "workflow_dispatch" && "$RUN_MAC_INPUT" == "true" ]]; then + echo "run-mac-tests=true" >> "$GITHUB_OUTPUT" + else + echo "run-mac-tests=false" >> "$GITHUB_OUTPUT" + fi + + if [[ "$EVENT_NAME" == "workflow_dispatch" && "$PUBLISH_IMAGE_INPUT" == "true" ]]; then + echo "publish-image=true" >> "$GITHUB_OUTPUT" + else + echo "publish-image=false" >> "$GITHUB_OUTPUT" + fi + + if [[ "$EVENT_NAME" == "workflow_dispatch" && "$PROMOTE_DOCKERHUB_INPUT" == "true" ]]; then + if [[ "$PUBLISH_IMAGE_INPUT" != "true" ]]; then + echo "::error::promote_dockerhub requires publish_image to be true." + exit 1 + fi + echo "promote-dockerhub=true" >> "$GITHUB_OUTPUT" + else + echo "promote-dockerhub=false" >> "$GITHUB_OUTPUT" + fi + + if [[ "$EVENT_NAME" == "pull_request" && "$HEAD_REPOSITORY" != "$CURRENT_REPOSITORY" ]]; then + echo "run-privileged-jobs=false" >> "$GITHUB_OUTPUT" + else + echo "run-privileged-jobs=true" >> "$GITHUB_OUTPUT" + fi + + container_repository="$CONTAINER_REPOSITORY_INPUT" + if [[ -z "$container_repository" ]]; then + container_repository="jcr.codebelt.net/geekle/web-cdn-origin" + fi + + container_repository_pattern='^[a-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$' + if [[ ! "$container_repository" =~ $container_repository_pattern ]]; then + echo "::error::container_repository must be a lowercase fully qualified image repository without a tag." + exit 1 + fi + + printf 'container-repository=%s\n' "$container_repository" >> "$GITHUB_OUTPUT" + printf 'container-registry=%s\n' "${container_repository%%/*}" >> "$GITHUB_OUTPUT" + + dockerhub_repository="$DOCKERHUB_REPOSITORY_INPUT" + if [[ -z "$dockerhub_repository" ]]; then + dockerhub_repository="codebeltnet/web-cdn-origin" + fi + + if [[ ! "$dockerhub_repository" =~ ^[a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._-]*$ ]]; then + echo "::error::dockerhub_repository must be a lowercase Docker Hub namespace/repository without a tag." + exit 1 + fi + + printf 'dockerhub-repository=%s\n' "$dockerhub_repository" >> "$GITHUB_OUTPUT" + + build: + name: call-build + needs: [init] + strategy: + matrix: + arch: [X64, ARM64] + configuration: [Debug, Release] + uses: codebeltnet/jobs-dotnet-build/.github/workflows/default.yml@917611a9989486b09f5a7ae160dc78014b541f9c # v3 + with: + projects: src/**/*.csproj + configuration: ${{ matrix.configuration }} + runs-on: ${{ matrix.arch == 'ARM64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} + upload-build-artifact-name: build-${{ matrix.configuration }}-${{ matrix.arch }} + secrets: inherit + + test_linux: + name: call-test-linux + needs: [build] + strategy: + fail-fast: false + matrix: + configuration: [Debug, Release] + arch: [X64, ARM64] + uses: codebeltnet/jobs-dotnet-test/.github/workflows/default.yml@a645b9332f981573961895075ff66d21e625185b # v3 + with: + projects: test/**/*.csproj + runs-on: ${{ matrix.arch == 'ARM64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} + configuration: ${{ matrix.configuration }} + build-switches: -p:SkipSignAssembly=true + restore: true + build: true + download-pattern: build-${{ matrix.configuration }}-${{ matrix.arch }} + + test_windows: + name: call-test-windows + needs: [build] + strategy: + fail-fast: false + matrix: + configuration: [Debug, Release] + arch: [X64, ARM64] + uses: codebeltnet/jobs-dotnet-test/.github/workflows/default.yml@a645b9332f981573961895075ff66d21e625185b # v3 + with: + projects: test/**/*.csproj + runs-on: ${{ matrix.arch == 'ARM64' && 'windows-11-arm' || 'windows-2025' }} + configuration: ${{ matrix.configuration }} + build-switches: -p:SkipSignAssembly=true + restore: true + build: true + download-pattern: build-${{ matrix.configuration }}-${{ matrix.arch }} + + test_mac: + if: ${{ needs.init.outputs.run-mac-tests == 'true' }} + name: call-test-mac + needs: [init, build] + strategy: + fail-fast: false + matrix: + arch: [X64, ARM64] + configuration: [Debug, Release] + uses: codebeltnet/jobs-dotnet-test/.github/workflows/default.yml@a645b9332f981573961895075ff66d21e625185b # v3 + with: + projects: test/**/*.csproj + runs-on: ${{ matrix.arch == 'ARM64' && 'macos-26' || 'macos-26-intel' }} + configuration: ${{ matrix.configuration }} + build-switches: -p:SkipSignAssembly=true + restore: true + build: true + download-pattern: build-${{ matrix.configuration }}-${{ matrix.arch }} + + test_qualitygate: + if: ${{ always() }} + name: test-qualitygate + needs: [init, test_linux, test_windows, test_mac] + runs-on: ubuntu-24.04 + steps: + - name: Evaluate test results + shell: bash + env: + RUN_MAC_TESTS: ${{ needs.init.outputs.run-mac-tests }} + TEST_LINUX_RESULT: ${{ needs.test_linux.result }} + TEST_WINDOWS_RESULT: ${{ needs.test_windows.result }} + TEST_MAC_RESULT: ${{ needs.test_mac.result }} + run: | + set -euo pipefail + + require_success() { + local job_name="$1" + local job_result="$2" + + if [[ "$job_result" != "success" ]]; then + echo "::error::$job_name finished with '$job_result'." + exit 1 + fi + } + + require_success_or_skip() { + local job_name="$1" + local job_enabled="$2" + local job_result="$3" + + if [[ "$job_enabled" == "true" ]]; then + require_success "$job_name" "$job_result" + return + fi + + if [[ "$job_result" != "success" && "$job_result" != "skipped" ]]; then + echo "::error::$job_name finished with '$job_result' while disabled." + exit 1 + fi + } + + require_success "test_linux" "$TEST_LINUX_RESULT" + require_success "test_windows" "$TEST_WINDOWS_RESULT" + require_success_or_skip "test_mac" "$RUN_MAC_TESTS" "$TEST_MAC_RESULT" + + sonarcloud: + if: ${{ always() && needs.init.outputs.run-privileged-jobs == 'true' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success' }} + name: call-sonarcloud + needs: [init, build, test_qualitygate] + uses: codebeltnet/jobs-sonarcloud/.github/workflows/default.yml@b62fadec0fae16ae2a35a6c2238555f6f965cc5e # v3 + with: + organization: geekle + projectKey: web-cdn-origin + version: ${{ needs.build.outputs.version }} + secrets: inherit + + codecov: + if: ${{ always() && needs.init.outputs.run-privileged-jobs == 'true' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success' }} + name: call-codecov + needs: [init, build, test_qualitygate] + uses: codebeltnet/jobs-codecov/.github/workflows/default.yml@a9957d94ecee89937cbd41a004c2246cf5f9c8c1 # v1 + with: + repository: codebeltnet/web-cdn-origin + secrets: inherit + + codeql: + if: ${{ always() && needs.init.outputs.run-privileged-jobs == 'true' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success' }} + name: call-codeql + needs: [init, build, test_qualitygate] + uses: codebeltnet/jobs-codeql/.github/workflows/default.yml@6ea53b54820b6015a958b6e4b4d64f6315aa8723 # v3 + permissions: + security-events: write + + container_build: + name: build-container + needs: [init, build] + runs-on: ubuntu-24.04 + outputs: + semver: ${{ steps.tag_semver.outputs.version }} + trunkver: ${{ steps.tag_trunkver.outputs.trunkver }} + permissions: + contents: read + env: + IMAGE_REPOSITORY: ${{ needs.init.outputs.container-repository }} + BUILD_IMAGE_REF: ${{ needs.init.outputs.container-repository }}:build-${{ github.run_id }}-${{ github.run_attempt }} + BUILD_VERSION: ${{ needs.build.outputs.version }} + IMAGE_ARTIFACT_NAME: container-image-${{ github.run_id }}-${{ github.run_attempt }} + SBOM_ARTIFACT_NAME: container-sbom-${{ github.run_id }}-${{ github.run_attempt }} + steps: + - name: Check out source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Build Docker image + uses: ./.github/actions/docker-build + with: + image: ${{ env.BUILD_IMAGE_REF }} + context: . + file: src/Codebelt.Cdn.Origin/Dockerfile + + - id: tag_semver + name: Tag image with SemVer + uses: ./.github/actions/docker-tag-semver + with: + source-image: ${{ env.BUILD_IMAGE_REF }} + repository: ${{ env.IMAGE_REPOSITORY }} + version: ${{ env.BUILD_VERSION }} + + - id: tag_trunkver + name: Tag image with TrunkVer + uses: ./.github/actions/docker-tag-trunkver + with: + source-image: ${{ env.BUILD_IMAGE_REF }} + repository: ${{ env.IMAGE_REPOSITORY }} + + - name: Save Docker image + uses: ./.github/actions/docker-save + with: + image: ${{ steps.tag_semver.outputs.image }} + additional-images: ${{ steps.tag_trunkver.outputs.image }} + tarball: ${{ runner.temp }}/codebelt-cdn-origin/image.tar + + - name: Generate container SBOM + uses: ./.github/actions/container-sbom + with: + image: ${{ steps.tag_semver.outputs.image }} + sbom-path: ${{ runner.temp }}/codebelt-cdn-origin/sbom.spdx.json + + - name: Upload saved image tarball + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ env.IMAGE_ARTIFACT_NAME }} + path: ${{ runner.temp }}/codebelt-cdn-origin/image.tar + if-no-files-found: error + compression-level: 0 + retention-days: 7 + + - name: Upload container SBOM + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ env.SBOM_ARTIFACT_NAME }} + path: ${{ runner.temp }}/codebelt-cdn-origin/sbom.spdx.json + if-no-files-found: error + retention-days: 7 + + container_publish: + if: ${{ always() && needs.init.outputs.publish-image == 'true' && github.event_name != 'pull_request' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success' && needs.sonarcloud.result == 'success' && needs.codecov.result == 'success' && needs.codeql.result == 'success' && needs.container_build.result == 'success' }} + name: publish-container + needs: [init, build, test_qualitygate, sonarcloud, codecov, codeql, container_build] + runs-on: ubuntu-24.04 + environment: Staging + permissions: + contents: read + outputs: + image-digest: ${{ steps.push_semver.outputs.image-digest }} + env: + SEMVER_IMAGE_REF: ${{ needs.init.outputs.container-repository }}:${{ needs.container_build.outputs.semver }} + TRUNKVER_IMAGE_REF: ${{ needs.init.outputs.container-repository }}:${{ needs.container_build.outputs.trunkver }} + IMAGE_ARTIFACT_NAME: container-image-${{ github.run_id }}-${{ github.run_attempt }} + REGISTRY: ${{ needs.init.outputs.container-registry }} + steps: + - name: Check out local actions + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 1 + + - name: Download saved image tarball + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ env.IMAGE_ARTIFACT_NAME }} + path: ${{ runner.temp }}/container-image + + - name: Load saved Docker image + uses: ./.github/actions/docker-load + with: + image: ${{ env.SEMVER_IMAGE_REF }} + tarball: ${{ runner.temp }}/container-image/image.tar + + - name: Verify saved image tags + shell: bash + env: + SEMVER_IMAGE_REF: ${{ env.SEMVER_IMAGE_REF }} + TRUNKVER_IMAGE_REF: ${{ env.TRUNKVER_IMAGE_REF }} + run: | + set -euo pipefail + + semver_image_id="$(docker image inspect --format '{{.Id}}' "$SEMVER_IMAGE_REF")" + trunkver_image_id="$(docker image inspect --format '{{.Id}}' "$TRUNKVER_IMAGE_REF")" + if [[ "$semver_image_id" != "$trunkver_image_id" ]]; then + echo "::error::SemVer and TrunkVer tags do not refer to the same loaded image." + exit 1 + fi + + - name: Log in to image registry + uses: ./.github/actions/docker-login + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - id: push_semver + name: Push SemVer image + uses: ./.github/actions/docker-push + with: + image: ${{ env.SEMVER_IMAGE_REF }} + + - id: push_trunkver + name: Push TrunkVer image + uses: ./.github/actions/docker-push + with: + image: ${{ env.TRUNKVER_IMAGE_REF }} + + - name: Verify Staging tag digests + shell: bash + env: + SEMVER_DIGEST: ${{ steps.push_semver.outputs.image-digest }} + TRUNKVER_DIGEST: ${{ steps.push_trunkver.outputs.image-digest }} + run: | + set -euo pipefail + + if [[ -z "$SEMVER_DIGEST" || "$SEMVER_DIGEST" != "$TRUNKVER_DIGEST" ]]; then + echo "::error::SemVer and TrunkVer tags were published with different image digests." + exit 1 + fi + + - name: Log out of image registry + if: ${{ always() }} + shell: bash + env: + REGISTRY: ${{ env.REGISTRY }} + run: docker logout "$REGISTRY" + + container_promote_dockerhub: + if: ${{ always() && needs.init.outputs.publish-image == 'true' && needs.init.outputs.promote-dockerhub == 'true' && github.event_name != 'pull_request' && needs.container_attest.result == 'success' }} + name: promote-container-dockerhub + needs: [init, container_build, container_publish, container_attest] + runs-on: ubuntu-24.04 + environment: Production + permissions: + contents: read + outputs: + image-digest: ${{ steps.push_semver.outputs.image-digest }} + env: + STAGING_SEMVER_IMAGE_REF: ${{ needs.init.outputs.container-repository }}:${{ needs.container_build.outputs.semver }} + STAGING_TRUNKVER_IMAGE_REF: ${{ needs.init.outputs.container-repository }}:${{ needs.container_build.outputs.trunkver }} + STAGING_IMAGE_DIGEST: ${{ needs.container_publish.outputs.image-digest }} + DOCKERHUB_REPOSITORY: docker.io/${{ needs.init.outputs.dockerhub-repository }} + DOCKERHUB_REGISTRY: docker.io + IMAGE_ARTIFACT_NAME: container-image-${{ github.run_id }}-${{ github.run_attempt }} + steps: + - name: Check out local actions + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 1 + + - name: Download saved image tarball + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ env.IMAGE_ARTIFACT_NAME }} + path: ${{ runner.temp }}/container-image + + - name: Load saved Staging image + uses: ./.github/actions/docker-load + with: + image: ${{ env.STAGING_SEMVER_IMAGE_REF }} + tarball: ${{ runner.temp }}/container-image/image.tar + + - name: Verify saved Staging tags + shell: bash + env: + SEMVER_IMAGE_REF: ${{ env.STAGING_SEMVER_IMAGE_REF }} + TRUNKVER_IMAGE_REF: ${{ env.STAGING_TRUNKVER_IMAGE_REF }} + run: | + set -euo pipefail + + semver_image_id="$(docker image inspect --format '{{.Id}}' "$SEMVER_IMAGE_REF")" + trunkver_image_id="$(docker image inspect --format '{{.Id}}' "$TRUNKVER_IMAGE_REF")" + if [[ "$semver_image_id" != "$trunkver_image_id" ]]; then + echo "::error::The saved SemVer and TrunkVer tags do not refer to the same image." + exit 1 + fi + + - name: Log in to Docker Hub + uses: ./.github/actions/docker-login + with: + registry: ${{ env.DOCKERHUB_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Retag image with Docker Hub SemVer + id: tag_dockerhub_semver + uses: ./.github/actions/docker-tag-semver + with: + source-image: ${{ env.STAGING_SEMVER_IMAGE_REF }} + repository: ${{ env.DOCKERHUB_REPOSITORY }} + version: ${{ needs.container_build.outputs.semver }} + + - name: Retag image with Docker Hub TrunkVer + id: tag_dockerhub_trunkver + uses: ./.github/actions/docker-tag-trunkver + with: + source-image: ${{ env.STAGING_TRUNKVER_IMAGE_REF }} + repository: ${{ env.DOCKERHUB_REPOSITORY }} + trunkver: ${{ needs.container_build.outputs.trunkver }} + + - id: push_semver + name: Push Docker Hub SemVer image + uses: ./.github/actions/docker-push + with: + image: ${{ steps.tag_dockerhub_semver.outputs.image }} + + - id: push_trunkver + name: Push Docker Hub TrunkVer image + uses: ./.github/actions/docker-push + with: + image: ${{ steps.tag_dockerhub_trunkver.outputs.image }} + + - name: Verify Docker Hub tag digests + shell: bash + env: + STAGING_IMAGE_DIGEST: ${{ env.STAGING_IMAGE_DIGEST }} + SEMVER_DIGEST: ${{ steps.push_semver.outputs.image-digest }} + TRUNKVER_DIGEST: ${{ steps.push_trunkver.outputs.image-digest }} + run: | + set -euo pipefail + + if [[ -z "$SEMVER_DIGEST" || "$SEMVER_DIGEST" != "$TRUNKVER_DIGEST" ]]; then + echo "::error::Docker Hub SemVer and TrunkVer tags have different image digests." + exit 1 + fi + + if [[ -n "$STAGING_IMAGE_DIGEST" && "$STAGING_IMAGE_DIGEST" != "$SEMVER_DIGEST" ]]; then + echo "::error::The Docker Hub digest differs from the verified Staging digest." + exit 1 + fi + + - name: Log out of Docker Hub + if: ${{ always() }} + shell: bash + env: + REGISTRY: ${{ env.DOCKERHUB_REGISTRY }} + run: docker logout "$REGISTRY" + + container_attest_dockerhub: + if: ${{ always() && needs.init.outputs.publish-image == 'true' && needs.init.outputs.promote-dockerhub == 'true' && github.event_name != 'pull_request' && needs.container_promote_dockerhub.result == 'success' }} + name: attest-container-dockerhub + needs: [init, container_build, container_promote_dockerhub] + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write + attestations: write + artifact-metadata: write + packages: write + env: + IMAGE_REPOSITORY: docker.io/${{ needs.init.outputs.dockerhub-repository }} + IMAGE_DIGEST: ${{ needs.container_promote_dockerhub.outputs.image-digest }} + SBOM_ARTIFACT_NAME: container-sbom-${{ github.run_id }}-${{ github.run_attempt }} + steps: + - name: Check out local actions + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 1 + + - name: Download container SBOM + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ env.SBOM_ARTIFACT_NAME }} + path: ${{ runner.temp }}/container-sbom + + - name: Attest Docker Hub container provenance + uses: ./.github/actions/container-attest-provenance + with: + subject-name: ${{ env.IMAGE_REPOSITORY }} + subject-digest: ${{ env.IMAGE_DIGEST }} + + - name: Attest Docker Hub container SBOM + uses: ./.github/actions/container-attest-sbom + with: + subject-name: ${{ env.IMAGE_REPOSITORY }} + subject-digest: ${{ env.IMAGE_DIGEST }} + sbom-path: ${{ runner.temp }}/container-sbom/sbom.spdx.json + + container_attest: + if: ${{ always() && needs.init.outputs.publish-image == 'true' && github.event_name != 'pull_request' && needs.container_publish.result == 'success' }} + name: attest-container + needs: [init, build, container_build, container_publish] + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write + attestations: write + artifact-metadata: write + packages: write + env: + IMAGE_REPOSITORY: ${{ needs.init.outputs.container-repository }} + IMAGE_DIGEST: ${{ needs.container_publish.outputs.image-digest }} + SBOM_ARTIFACT_NAME: container-sbom-${{ github.run_id }}-${{ github.run_attempt }} + steps: + - name: Check out local actions + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 1 + + - name: Download container SBOM + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ env.SBOM_ARTIFACT_NAME }} + path: ${{ runner.temp }}/container-sbom + + - name: Attest container provenance + uses: ./.github/actions/container-attest-provenance + with: + subject-name: ${{ env.IMAGE_REPOSITORY }} + subject-digest: ${{ env.IMAGE_DIGEST }} + + - name: Attest container SBOM + uses: ./.github/actions/container-attest-sbom + with: + subject-name: ${{ env.IMAGE_REPOSITORY }} + subject-digest: ${{ env.IMAGE_DIGEST }} + sbom-path: ${{ runner.temp }}/container-sbom/sbom.spdx.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cb9f692 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,70 @@ +# Agent Instructions for Static Content Provider (Codebelt.Cdn.Origin) + +Durable guidance for humans and AI agents working in this repository. Read this before changing code. + +## Mission + +`Codebelt.Cdn.Origin` is a small, production-grade, **read-only static content provider** built on **.NET 10** and **Kestrel**. It serves physical files supplied at runtime and is designed for two deployment scenarios: + +1. An **origin server behind a CDN** (for example AWS CloudFront, Cloudflare, Azure Front Door, Google Cloud CDN). +2. A **separately deployed asset host** that keeps static content outside the website or business application. + +The architectural value is **segregation of duties, independent deployment, cacheability, origin offloading, and edge distribution**. It is *not* justified by legacy HTTP/1.x domain sharding or browser connection parallelism. + +## Non-goals + +This application is **not**, and must not become: + +- an upload or object-storage service; +- a directory browser; +- a reverse proxy; +- a dynamic website; +- a place for business logic. + +Keep it small, focused, framework-first, and operationally robust. + +## Engineering rules + +- **.NET 10 is required.** Target `net10.0`. Use modern minimal hosting (`WebApplication`), not the legacy `Program`/`Startup` pair. +- **Framework-first.** Prefer ASP.NET Core and the BCL over custom infrastructure. The production project intentionally has **zero third-party package references**. A new dependency requires a clear, written justification in the pull request and must not merely replace straightforward framework or BCL functionality. +- **Static content is read-only.** The content root is treated as a read-only mount. Never add write, upload, or delete paths. +- **HTTP semantics are part of the public contract.** `ETag`, `Last-Modified`, conditional requests (`If-None-Match`, `If-Modified-Since`, `304`), range requests (`Range`, `If-Range`, `206`, `416`), `HEAD` parity, and cache directives are behavioural guarantees. Changing them is a breaking change and requires tests plus a `README.md`/`CHANGELOG.md` update. +- **Security defaults must remain restrictive.** Unknown file types are rejected by default. Directory browsing is disabled. Path traversal outside the content root is prevented. Wildcard CORS origins must never be combined with credentials. Do not relax a default without justification and tests. +- **Behavioural changes require tests and documentation.** Update the functional HTTP matrix and the `README.md` whenever observable behaviour changes. +- **Version discipline.** The version is set once in `Directory.Build.props` (`2.0.0`). Do **not** repeatedly bump it while the work is unmerged. + +## Project structure + +- `src/Codebelt.Cdn.Origin/` — production source (framework-only). +- `test/Codebelt.Cdn.Origin.Tests/` — unit tests for isolated decision logic (options validation, cache policy, MIME mappings, content-root validation, health check). +- `test/Codebelt.Cdn.Origin.FunctionalTests/` — integration tests that exercise the real ASP.NET Core pipeline against a temporary physical content directory. +- Root `Directory.Build.props` / `Directory.Packages.props` centralize build configuration and package versions. + +## Test conventions + +- Unit tests live in `*.Tests`; functional tests live in `*.FunctionalTests`. Both use xUnit v3 with the `Microsoft.Testing.Platform` runner and inherit from the `Codebelt.Extensions.Xunit` `Test` base class. +- Test namespaces mirror the system under test (no `.Tests`/`.FunctionalTests` suffix on the namespace); the `RootNamespace` is overridden in each test `.csproj`. +- Do **not** use `InternalsVisibleTo` or `[ExcludeFromCodeCoverage]`. Decision logic that must be unit-tested is exposed as public API. +- Target **100% line and branch coverage for application-owned decision logic**. Use deterministic fixtures, known timestamps, and isolated temporary directories. + +## Commands + +Run from the repository root. + +```bash +# Restore, build (warnings are errors for source projects) +dotnet restore Codebelt.Cdn.Origin.slnx +dotnet build Codebelt.Cdn.Origin.slnx -c Release + +# Formatting, code style and analyzers (must produce no changes) +dotnet format Codebelt.Cdn.Origin.slnx --severity info --verify-no-changes + +# Tests +dotnet test Codebelt.Cdn.Origin.slnx -c Release + +# Tests with coverage (line + branch) +dotnet test Codebelt.Cdn.Origin.slnx -c Release /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura /p:Include="[Codebelt.Cdn.Origin]*" + +# Container +docker build -t codebeltnet/web-cdn-origin:2.0.0 -f src/Codebelt.Cdn.Origin/Dockerfile . +``` diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f58ce0d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,129 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.0.0] - 2026-08-09 + +This is a **major** release representing a deliberate modernization of the Static Content Provider. The application keeps its focus — a small, read-only, framework-first static content origin for CDN and segregated asset-host scenarios — but the implementation, configuration contract, container, and DevOps are fully modernized for .NET 10 and contemporary cloud-native patterns. + +### Added + +- Modern minimal hosting on .NET 10 (`WebApplication`) replacing the legacy `Program`/`Startup` model, +- Strongly typed, validated configuration model (`CdnOrigin` section) covering content root, default documents, cache profiles, CORS, compression, content-type mappings, and health checks. Invalid configuration fails fast at startup, +- Two explicit cache profiles: **revalidate** (mutable URLs) and **immutable** (versioned / content-addressed URLs selected by configured path prefixes), +- Framework CORS with configurable origins, exposed headers, `Cross-Origin-Resource-Policy`, and `Timing-Allow-Origin`. Wildcard origins can never be combined with credentials, +- Optional response compression (Brotli/Gzip) restricted to compressible content types, off by default because edge compression is preferred behind a CDN, +- Operational endpoints `/health/live` and `/health/ready` (readiness verifies the content root is present and readable). Health responses are `Cache-Control: no-store`, +- `405 Method Not Allowed` with an `Allow` header for unsupported methods against existing files, +- Structured startup logging of the effective, non-sensitive configuration, +- Case-insensitive path lookup via `PortablePhysicalFileProvider` from the Codebelt ecosystem, so asset URLs work consistently on case-sensitive and case-insensitive file systems. Symlink and junction resolution prevents traversal-based bypasses, +- Cache prefix matching is now case-insensitive for robustness on case-insensitive filesystems, +- Hardened container: .NET 10 runtime, non-root user, non-privileged port `8080`, read-only-root-filesystem compatible, minimal image, +- Multi-architecture container support: X64 and ARM64 images built and published to registries, +- Reusable GitHub composite actions for container lifecycle: `docker-build`, `docker-load`, `docker-save`, `docker-push`, `docker-login`, `docker-tag-semver`, `docker-tag-trunkver`, and supply-chain security actions (`container-sbom`, `container-attest-sbom`, `container-attest-provenance`), +- Docker Hub promotion workflow with gated Production environment approval and SemVer/TrunkVer tagging strategy, +- Multi-OS test matrix validating across Ubuntu 24.04, Windows 2025, and macOS 26 in both Debug and Release configurations, +- Test environment configuration (`testEnvironments.json`) for consistent Docker-based test execution, +- Comprehensive unit and functional test suite using xUnit v3 with `Codebelt.Extensions.Xunit` patterns, +- Governance and engineering documentation: `AGENTS.md` defining build rules, test conventions, and version discipline; `.editorconfig` for project-wide consistency; centralized `Directory.Build.props` and `Directory.Packages.props`; and complete CI pipeline with artifact management. + +### Changed + +- **Configuration is now hierarchical and strongly typed.** The flat `1.x` environment variables are replaced by the `CdnOrigin` section (still overridable via environment variables). See the migration table in `README.md`, +- Cache durations are expressed as standard `TimeSpan` values instead of a numeric value plus a separate time-unit variable, +- `ETag` and `Last-Modified` are now produced by the ASP.NET Core static file middleware from file identity and modification metadata. The server no longer reads or hashes file contents to build an `ETag`, +- The default container port is `8080` (was `80`) and the process runs as a non-root user, +- Application startup now follows `MinimalWebProgram` bootstrap pattern for framework-first initialization, +- Functional tests migrated to `Codebelt.Extensions.Xunit` patterns with modern `WebApplication` test server and `Microsoft.Testing.Platform` runner. + +### Removed + +- **Custom MD5 content hashing for `ETag`** (`ETAG_BYTESTOREAD` and the `StreamExtensions` helper). The framework `ETag` replaces it and removes per-request full-file reads, +- **`no-transform`** is no longer emitted by default, so a CDN may legitimately transform or optimize responses, +- **`Expires`** header. `Cache-Control: max-age` is authoritative; the redundant `Expires` header is gone, +- **Server-side response caching** (`AddResponseCaching`). A CDN origin should not cache its own responses; the CDN and HTTP clients handle caching, +- **Cuemon dependencies** and the Visual Studio container-tooling package. The production project now references only the ASP.NET Core shared framework, +- **`ServeUnknownFileTypes = true`.** Unknown file types are now rejected by default; add explicit MIME mappings through configuration to serve additional types, +- Legacy `Startup` class and associated extension methods. Modern minimal hosting replaces them. + +### Migration + +See the "Migration from 1.4.0 to 2.0.0" section of `README.md` for the full `1.x` → `2.0.0` configuration mapping and behavioural notes. + +## [1.4.0] - 2022-11-22 + +This is a **minor** release focused on runtime modernization and dependency updates. + +### Added + +- Support for .NET 7. + +### Changed + +- Dependency updates and lifecycle maintenance. + +## [1.3.0] - 2021-12-11 + +This is a **minor** release bringing .NET 6 support. + +### Changed + +- Updated to .NET 6 runtime. + +## [1.2.0] - 2021-05-20 + +This is a **minor** release adding response compression. + +### Added + +- Default implementation of response compression (Gzip/Brotli) for compressible content types. + +## [1.1.5] - 2021-05-02 + +This is a **patch** release with configuration and CORS improvements. + +### Changed + +- CORS support refactored; `Access-Control-Allow-Origin` now added as part of `OnPrepareResponse` for better static file compatibility, +- Project restructured, moving files to repository root for better discoverability. + +### Added + +- License and readme files at repository root. + +## [1.1.0] - 2021-04-30 + +This is a **minor** release focused on performance tuning and case-sensitivity fixes. + +### Added + +- Configurable ETag hashing with `ETAG_BYTESTOREAD` to control how many bytes are read per file, +- Opt-in MD5 hashing mode for faster ETag generation. + +### Fixed + +- Case-sensitivity bug on Windows and other case-insensitive filesystems. Static file serving now uses `CaseInsensitivePhysicalFileProvider` for cross-platform compatibility. + +## [1.0.0] - 2021-04-29 + +This is the initial release of the Static Content Provider. + +### Added + +- Environment-variable-only configuration model for container-first operation, +- Support for default files (e.g., `index.html`, `default.htm`), +- Custom cache control headers (`Cache-Control` and optional `Expires`), +- Content-type mappings for common static asset types, +- Content-based `ETag` generation with strong validation, +- Last-Modified header support for HTTP conditional requests, +- Basic license and documentation. + +[2.0.0]: https://github.com/codebeltnet/web-cdn-origin/compare/1.4.0...HEAD +[1.4.0]: https://github.com/codebeltnet/web-cdn-origin/compare/1.3.0...1.4.0 +[1.3.0]: https://github.com/codebeltnet/web-cdn-origin/compare/1.2.0...1.3.0 +[1.2.0]: https://github.com/codebeltnet/web-cdn-origin/compare/1.1.5...1.2.0 +[1.1.5]: https://github.com/codebeltnet/web-cdn-origin/compare/1.1.0...1.1.5 +[1.1.0]: https://github.com/codebeltnet/web-cdn-origin/compare/1.0.0...1.1.0 +[1.0.0]: https://github.com/codebeltnet/web-cdn-origin/releases/tag/1.0.0 diff --git a/Codebelt.Cdn.Origin.sln b/Codebelt.Cdn.Origin.sln deleted file mode 100644 index 3ea62b2..0000000 --- a/Codebelt.Cdn.Origin.sln +++ /dev/null @@ -1,25 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.31205.134 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codebelt.Cdn.Origin", "src\Codebelt.Cdn.Origin\Codebelt.Cdn.Origin.csproj", "{6F1740FB-2FA7-466B-88B9-D2570F88F497}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {6F1740FB-2FA7-466B-88B9-D2570F88F497}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6F1740FB-2FA7-466B-88B9-D2570F88F497}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6F1740FB-2FA7-466B-88B9-D2570F88F497}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6F1740FB-2FA7-466B-88B9-D2570F88F497}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {4403305C-319F-407A-A0B7-04449B5BEE3E} - EndGlobalSection -EndGlobal diff --git a/Codebelt.Cdn.Origin.slnx b/Codebelt.Cdn.Origin.slnx new file mode 100644 index 0000000..e9892c0 --- /dev/null +++ b/Codebelt.Cdn.Origin.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..1e375b1 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,60 @@ + + + $(MSBuildProjectDirectory.ToLower().StartsWith('$(MSBuildThisFileDirectory.ToLower())src')) + $(MSBuildProjectName.EndsWith('Tests')) + latest + + + + net10.0 + enable + enable + en-US + true + true + true + true + true + latest + Recommended + + 2.0.0 + + + + net10.0 + enable + enable + Exe + false + false + false + false + true + 0 + none + NU1701,NETSDK1206 + false + true + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..21ee46e --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,17 @@ + + + true + + + + + + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index eb72b05..3fe27fd 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,352 @@ -# Static Content Provider (CDN Origin / CDN) +# Static Content Provider (`Codebelt.Cdn.Origin`) -A project consisting of an ASP.NET Core project that has an assigned role of being a static content provider.\ -Could easily be used in regards to CDN as a CDN origin for Cloudflare, AWS Cloudfront, Azure CDN, Cloud CDN and many more. +A small, production-grade, **read-only static content provider** built on **.NET 10** and **Kestrel**. It serves physical files supplied at runtime and is designed to sit either behind a CDN as an **origin**, or as a **separately deployed asset host** that keeps static content out of your website or business application. -## Docker Setup +> The provider is deliberately minimal and framework-first. The production assembly references only the ASP.NET Core shared framework — no third-party packages. -### Environment variables +## Contents -`CACHECONTROL_MAXAGE` (double, default value is 12)\ -`CACHECONTROL_MAXAGE_TIMEUNIT` (enum, default value is Hours)\ -`CACHECONTROL_SHAREDMAXAGE` (double, default value is 168)\ -`CACHECONTROL_SHAREDMAXAGE_TIMEUNIT` (enum, default value is Hours)\ -`CDNROOT_DEFAULTFILES` (semi-colon-delimited string, default value is default.htm;default.html;index.htm;index.html)\ -`CDNROOT` (filePath to the static files that need to be published, default value is /cdnroot)\ -`ETAG_BYTESTOREAD` (int, default value is 2147483647 (strong validation)) +- [What it is](#what-it-is) +- [Deployment scenarios](#deployment-scenarios) +- [Architecture and request flow](#architecture-and-request-flow) +- [Supported HTTP capabilities](#supported-http-capabilities) +- [Cache-policy modes](#cache-policy-modes) +- [Configuration reference](#configuration-reference) +- [Running locally](#running-locally) +- [Docker](#docker) +- [CI and container promotion](#ci-and-container-promotion) +- [Kubernetes](#kubernetes) +- [AWS CloudFront origin](#aws-cloudfront-origin) +- [Security considerations](#security-considerations) +- [Migration from 1.4.0 to 2.0.0](#migration-from-140-to-200) +- [Local verification](#local-verification) -For `CACHECONTROL_MAXAGE_TIMEUNIT` and `CACHECONTROL_SHAREDMAXAGE_TIMEUNIT` the allowed values are: +## What it is -+ Days -+ Hours -+ Minutes -+ Seconds -+ Milliseconds -+ Tics +`Codebelt.Cdn.Origin` serves files from a configured **content root** over HTTP with correct, standards-compliant caching and conditional-request semantics. It does exactly one job — serve static content safely — and nothing else. -`CDNROOT_DEFAULTFILES` is used to activate the search for a default document, that will be served as default content. +It is **not**: -For large files `ETAG_BYTESTOREAD` could be set to reduce how many bytes is being read per file. +- an upload or object-storage service; +- a directory browser; +- a reverse proxy; +- a dynamic website; +- a place for business logic. -Do note, that if a file is read in its entirely, a strong ETag header value is generated; otherwise a weak ETag header value is generated. +## Deployment scenarios +### 1. CDN origin -### Embed files in image +The provider runs as the **origin** behind a CDN such as AWS CloudFront, Cloudflare, Azure Front Door, or Google Cloud CDN. The CDN caches and distributes the content at the edge; the origin only serves cache misses and revalidations. +### 2. Segregated asset host + +The provider hosts public assets (JavaScript, CSS, fonts, images) on a host that is **separate** from the website or business application, even without a CDN in front. + +### Why the separation exists + +The value of hosting static content separately is **architectural**, not a browser-connection trick: + +- **Segregation of duties** — static delivery is isolated from application logic and its failure modes. +- **Independent deployment and scaling** — assets ship and scale on their own cadence. +- **Cacheability** — a dedicated, cache-friendly surface with explicit, correct cache headers. +- **Origin offloading** — the CDN absorbs the vast majority of requests; the origin stays small and cheap. +- **Edge distribution** — content is served close to users through the CDN. + +> Note: On modern HTTP/2 and HTTP/3, serving assets from a separate domain does **not** improve performance through extra browser connection parallelism (that was an HTTP/1.x "domain sharding" technique and is now usually counter-productive because it prevents connection coalescing). The benefits above are about architecture, operability, and edge caching — not connection count. + +## Architecture and request flow + +``` +Client ──▶ CDN (edge cache) ──▶ Codebelt.Cdn.Origin (Kestrel) ──▶ Content root (read-only files) +``` + +The ASP.NET Core pipeline, in order: + +1. **Response compression** (optional, off by default) — Brotli/Gzip for compressible types only. +2. **CORS** (optional, on by default) — applies the configured policy and answers preflight requests. +3. **Health endpoints** — `/health/live` and `/health/ready` (mapped when enabled). +4. **Default documents** — rewrites a directory request to a default document when one exists. +5. **Static files** — serves `GET`/`HEAD` for existing files with the correct content type, validators, and cache headers; unknown file types are rejected. +6. **Terminal handler** — returns `404 Not Found`, or `405 Method Not Allowed` with an `Allow` header for an unsupported method against an existing file. + +The content root is validated at startup (exists, is a directory, is readable, and does not overlap the application directory). Invalid configuration fails fast. + +## Supported HTTP capabilities + +All of the following are provided by the framework static-file middleware and are part of the provider's public contract: + +| Capability | Behaviour | +| --- | --- | +| Methods | `GET` and `HEAD` (with correct `HEAD` parity — headers, no body) | +| `Content-Type` | Explicit, safe MIME mapping; unknown extensions rejected by default | +| `Content-Length` | Set for full and `HEAD` responses | +| `Last-Modified` | From file modification metadata | +| `ETag` | Derived from file identity and modification metadata — **the file is never read or hashed per request** | +| `If-None-Match` / `If-Modified-Since` | Conditional requests return `304 Not Modified` | +| `Range` / `If-Range` | Byte-range requests return `206 Partial Content`; `416 Range Not Satisfiable` for unsatisfiable ranges | +| Default documents | Configurable; `default.htm`, `default.html`, `index.htm`, `index.html` by default | +| Missing files | `404 Not Found` | +| Unsupported methods | `405 Method Not Allowed` with `Allow: GET, HEAD, OPTIONS` | +| Directory browsing | Disabled | +| Path casing | Case-insensitive lookup across case-sensitive and case-insensitive file systems | +| Path traversal | Prevented — access is confined to the content root | + +## Cache-policy modes + +Rather than emitting one set of directives for every file, the provider supports two explicit cache **profiles**: + +- **Revalidate** — for mutable URLs. Default: `public, max-age=12h, s-maxage=7d, must-revalidate`. +- **Immutable** — for versioned or content-addressed URLs. Default: `public, max-age=365d, immutable`. + +A request uses the **immutable** profile when its path starts with one of the configured `Cache:ImmutablePathPrefixes` (for example `/assets/`), matched case-insensitively; otherwise it uses the **revalidate** profile. + +Each profile exposes the relevant `Cache-Control` directives: `public`/`private`, `max-age`, `s-maxage`, `must-revalidate`, `no-cache`, `no-store`, `immutable`, `stale-while-revalidate`, `stale-if-error`, and `no-transform`. Contradictory combinations are rejected at startup, and `no-transform` is **not** emitted by default so a CDN may legitimately transform or optimize responses. + +## Configuration reference + +Configuration binds from the `CdnOrigin` section (via `appsettings.json`) and can be overridden with environment variables using the `__` (double underscore) separator, for example `CdnOrigin__ContentRoot`. + +### Static content + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `CdnOrigin:ContentRoot` | path | `/cdnroot` | Directory of physical files to serve. Must exist at startup. | +| `CdnOrigin:DefaultDocuments` | string[] | `default.htm`, `default.html`, `index.htm`, `index.html` | Default documents, tried in order. Leave empty to use the standard defaults. | + +### Cache + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `CdnOrigin:Cache:ImmutablePathPrefixes` | string[] | *(empty)* | Request-path prefixes served with the immutable profile, matched case-insensitively. | +| `CdnOrigin:Cache:Revalidate` | profile | `public`, `12:00:00`, `7.00:00:00`, `must-revalidate` | Profile for mutable URLs. | +| `CdnOrigin:Cache:Immutable` | profile | `public`, `365.00:00:00`, `immutable` | Profile for versioned/content-addressed URLs. | + +Each profile supports: `Public` (bool), `MaxAge` (`TimeSpan`), `SharedMaxAge` (`TimeSpan`), `MustRevalidate` (bool), `NoCache` (bool), `NoStore` (bool), `Immutable` (bool), `StaleWhileRevalidate` (`TimeSpan`), `StaleIfError` (`TimeSpan`), `NoTransform` (bool). Durations use standard `TimeSpan` strings (`hh:mm:ss` or `d.hh:mm:ss`). + +### CORS + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `CdnOrigin:Cors:Enabled` | bool | `true` | Enable CORS handling. | +| `CdnOrigin:Cors:AllowedOrigins` | string[] | *(empty = public)* | Allowed origins. Empty or `*` means public (any origin). | +| `CdnOrigin:Cors:ExposedHeaders` | string[] | *(empty)* | `Access-Control-Expose-Headers` values. | +| `CdnOrigin:Cors:AllowCredentials` | bool | `false` | Allow credentialed requests. Cannot be combined with a wildcard/public origin. | +| `CdnOrigin:Cors:CrossOriginResourcePolicy` | string | `cross-origin` | `Cross-Origin-Resource-Policy` header value; empty to omit. | +| `CdnOrigin:Cors:TimingAllowOrigin` | bool | `false` | Emit a `Timing-Allow-Origin` header. | + +### Compression + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `CdnOrigin:Compression:Enabled` | bool | `false` | Enable origin compression. Edge compression is usually preferred behind a CDN. | +| `CdnOrigin:Compression:EnableForHttps` | bool | `true` | Compress HTTPS responses. | +| `CdnOrigin:Compression:AdditionalMimeTypes` | string[] | *(empty)* | Extra compressible MIME types beyond the built-in defaults. | + +Already-compressed formats (images, video, WOFF2 fonts) are never compressed, and `Vary: Accept-Encoding` is set when compression applies. + +### Content types + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `CdnOrigin:ContentTypes:ServeUnknownFileTypes` | bool | `false` | Serve files with an unknown extension. Off by default. | +| `CdnOrigin:ContentTypes:DefaultContentType` | string | *(none)* | Required when `ServeUnknownFileTypes` is `true`. | +| `CdnOrigin:ContentTypes:Mappings` | map | *(empty)* | Additional extension→MIME mappings, e.g. `CdnOrigin:ContentTypes:Mappings:.foo = application/x-foo`. | + +### Health + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `CdnOrigin:Health:Enabled` | bool | `true` | Map the health endpoints. | +| `CdnOrigin:Health:LivePath` | path | `/health/live` | Liveness endpoint (process is up). | +| `CdnOrigin:Health:ReadyPath` | path | `/health/ready` | Readiness endpoint (content root available and readable). | + +Health responses are always `Cache-Control: no-store` so a CDN cannot cache them. + +## Running locally + +```bash +dotnet run --project src/Codebelt.Cdn.Origin/Codebelt.Cdn.Origin.csproj ``` -FROM codebeltnet/web-cdn-origin:1.2.1 -WORKDIR /cdnroot -ADD cdnroot . +Point the content root at a local directory: + +```bash +# bash +CdnOrigin__ContentRoot=/path/to/content dotnet run --project src/Codebelt.Cdn.Origin/Codebelt.Cdn.Origin.csproj +``` -WORKDIR /app +```powershell +# PowerShell +$env:CdnOrigin__ContentRoot = "C:\path\to\content"; dotnet run --project src/Codebelt.Cdn.Origin/Codebelt.Cdn.Origin.csproj ``` -`docker build -t yourAmazingImage:SomeTag -f Dockerfile .`\ -`docker run --name cdn-origin -d -p 8000:80 yourAmazingImage:SomeTag` -### Mount volume to image +## Docker + +The image runs as a non-root user on the conventional non-privileged port `8080`, supports a read-only root filesystem, and treats `/cdnroot` as a read-only content mount. + +Build from the repository root (so central build configuration is available): -`docker run --name cdn-origin -d -p 8000:80 -v some-path-with-static-files:/cdnroot codebeltnet/web-cdn-origin:1.2.1` +```bash +docker build -t codebeltnet/web-cdn-origin:2.0.0 -f src/Codebelt.Cdn.Origin/Dockerfile . +``` + +Mount content at runtime: -## Kubernetes Setup +```bash +docker run -d --name cdn-origin \ + --read-only \ + -p 8080:8080 \ + -v /path/to/content:/cdnroot:ro \ + codebeltnet/web-cdn-origin:2.0.0 +``` -### Running in Docker Desktop with mounted volume +Or bake content into a derived image: +```dockerfile +FROM codebeltnet/web-cdn-origin:2.0.0 +COPY ./cdnroot /cdnroot ``` + +## CI and container promotion + +Pull requests run the Debug/Release build and Linux/Windows test matrices, optionally including macOS. They also build the Dockerfile once on Linux/amd64, generate an SPDX JSON SBOM, save the image with `docker save`, and upload the tarball as an artifact. No registry credentials or push permissions are available to pull-request builds. + +The saved image receives two tags: + +- SemVer from `needs.build.outputs.version`, with one leading `v` removed. For example, `v2.0.0` becomes `2.0.0` for compatibility with the existing Docker Hub `1.4.0` naming. +- A [TrunkVer](https://trunkver.org/) generated once during the container build and reused for both registries. + +Use a manually dispatched run with `publish_image: true` to publish the saved tarball to the `Staging` environment. The default staging repository is `jcr.codebelt.net/geekle/web-cdn-origin`; override `container_repository` when needed. Configure `JCR_USERNAME` and `JCR_PASSWORD` as `Staging` environment secrets. + +To keep the exact same run artifact, set both `publish_image: true` and `promote_dockerhub: true` on the manual dispatch. The workflow publishes to Staging and then pauses at the protected `Production` environment, allowing you to verify JCR before approving Docker Hub publication. Configure required reviewers for `Production` and add `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` as its environment secrets. The default Docker Hub repository is `codebeltnet/web-cdn-origin`; override `dockerhub_repository` when needed. A run with `promote_dockerhub: false` intentionally stops after Staging; enabling it on a later run creates a new build artifact. + +This is a promotion of one immutable build artifact: the Docker Hub job downloads the same `docker save` tarball, uses `docker load`, retags it for Docker Hub, and pushes both tags. It does not rebuild or pull a new image. Digest gates verify that the SemVer and TrunkVer tags point to the same image in each registry and that the Docker Hub digest matches Staging. Attestation jobs publish GitHub build-provenance and SBOM attestations for the pushed digest, so each target registry must accept OCI attestation artifacts. + +## Kubernetes + +Deploy with the content mounted read-only and a hardened security context: + +```yaml apiVersion: apps/v1 kind: Deployment metadata: - name: codebelt-net + name: cdn-origin labels: - app: codebelt-net + app: cdn-origin spec: - replicas: 1 + replicas: 2 selector: matchLabels: - app: codebelt-net + app: cdn-origin template: metadata: labels: - app: codebelt-net + app: cdn-origin spec: - restartPolicy: Always containers: - - name: codebelt-net - image: codebeltnet/web-cdn-origin:1.2.1 - env: - - name: CACHECONTROL_SHAREDMAXAGE - value: "24" - - name: ETAG_BYTESTOREAD - value: "512" - ports: - - containerPort: 80 - volumeMounts: - - name: staticfiles - mountPath: /cdnroot + - name: cdn-origin + image: codebeltnet/web-cdn-origin:2.0.0 + ports: + - containerPort: 8080 + env: + - name: CdnOrigin__Cache__ImmutablePathPrefixes__0 + value: "/assets/" + securityContext: + runAsNonRoot: true + runAsUser: 1654 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + readinessProbe: + httpGet: + path: /health/ready + port: 8080 + livenessProbe: + httpGet: + path: /health/live + port: 8080 + volumeMounts: + - name: content + mountPath: /cdnroot + readOnly: true volumes: - - name: staticfiles - hostPath: - type: Directory - path: /run/desktop/mnt/host/c/codebelt-net/cdnroot + - name: content + persistentVolumeClaim: + claimName: cdn-content + readOnly: true +``` + +## AWS CloudFront origin + +Run the provider as a custom origin behind CloudFront: + +1. Deploy the provider (Kubernetes, ECS, a VM, etc.) and expose it over HTTPS through a load balancer. +2. Create a CloudFront distribution with a **custom origin** pointing at the provider's hostname. +3. Let the origin's `Cache-Control` drive edge TTLs (CloudFront "Use origin cache headers"). The **revalidate** profile governs mutable URLs; place versioned/fingerprinted assets under an `ImmutablePathPrefixes` entry so they receive the **immutable** profile. +4. Forward the `Origin` header if you serve cross-origin assets so CORS behaves correctly, and forward `Range` for media. + +Because the origin emits correct validators and cache directives, CloudFront revalidates efficiently with `If-None-Match`/`If-Modified-Since` and serves ranges natively. + +## Security considerations + +- **Read-only by design** — there are no write, upload, or delete paths. +- **Restrictive defaults** — unknown file types are rejected, directory browsing is disabled, and path traversal outside the content root is prevented. +- **CORS safety** — a wildcard/public origin can never be combined with credentials; this is enforced at startup. +- **No application-file exposure** — the content root is validated at startup, resolving symbolic links and junctions before checking for overlap with the application directory. +- **Hardened container** — non-root user, non-privileged port, read-only-root-filesystem friendly, minimal image, and no baked-in credentials or CDN configuration. +- **Health is not cacheable** — health responses are `Cache-Control: no-store`. + +## Migration from 1.4.0 to 2.0.0 + +`2.0.0` is a deliberate major-version modernization. Behaviour that remained sound is preserved; breaking changes are listed below. See `CHANGELOG.md` for the full list. + +### Configuration mapping + +| `1.x` (environment variable) | `2.0.0` | +| --- | --- | +| `CDNROOT` | `CdnOrigin__ContentRoot` | +| `CDNROOT_DEFAULTFILES` (`;`-delimited) | `CdnOrigin__DefaultDocuments__0`, `__1`, … (array) | +| `CACHECONTROL_MAXAGE` + `CACHECONTROL_MAXAGE_TIMEUNIT` | `CdnOrigin__Cache__Revalidate__MaxAge` (`TimeSpan`, e.g. `12:00:00`) | +| `CACHECONTROL_SHAREDMAXAGE` + `CACHECONTROL_SHAREDMAXAGE_TIMEUNIT` | `CdnOrigin__Cache__Revalidate__SharedMaxAge` (`TimeSpan`, e.g. `7.00:00:00`) | +| `ETAG_BYTESTOREAD` | *(removed)* — `ETag` is produced from file metadata | + +### Behavioural changes + +- **`ETag`** is now produced by the framework from file identity and modification metadata. The server no longer reads or hashes file contents per request (`ETAG_BYTESTOREAD` and the custom MD5 hashing are gone). +- **Cache durations** are standard `TimeSpan` values instead of a number plus a separate time-unit variable. +- **`no-transform`** is no longer emitted by default. +- **`Expires`** is removed; `Cache-Control: max-age` is authoritative. +- **Server-side response caching** is removed — the CDN and HTTP clients handle caching. +- **Case-insensitive path lookup** is preserved for compatibility with asset URLs on every supported file system; ambiguous case-only matches are rejected. +- **Unknown file types are rejected by default** (previously served); add explicit MIME mappings to serve additional types. +- **CORS is configurable** instead of always emitting `Access-Control-Allow-Origin: *`; the default remains public. +- **The container port is `8080`** (was `80`) and the process runs as a **non-root** user. + +## Local verification + +```bash +# Restore, build (warnings are errors for source projects) +dotnet restore Codebelt.Cdn.Origin.slnx +dotnet build Codebelt.Cdn.Origin.slnx -c Release + +# Formatting, code style, and analyzers (must produce no changes) +dotnet format Codebelt.Cdn.Origin.slnx --severity info --verify-no-changes + +# Tests +dotnet test Codebelt.Cdn.Origin.slnx -c Release + +# Tests with line + branch coverage for the application assembly (generated code excluded) +dotnet test test/Codebelt.Cdn.Origin.Tests/Codebelt.Cdn.Origin.Tests.csproj -c Release \ + /p:CollectCoverage=true /p:CoverletOutputFormat=json /p:CoverletOutput=./artifacts/coverage.json \ + /p:Include="[Codebelt.Cdn.Origin]*" /p:ExcludeByFile="**/*.g.cs" +dotnet test test/Codebelt.Cdn.Origin.FunctionalTests/Codebelt.Cdn.Origin.FunctionalTests.csproj -c Release \ + /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura /p:CoverletOutput=./artifacts/coverage.cobertura.xml \ + /p:MergeWith=./artifacts/coverage.json /p:Include="[Codebelt.Cdn.Origin]*" /p:ExcludeByFile="**/*.g.cs" + +# Container +docker build -t codebeltnet/web-cdn-origin:2.0.0 -f src/Codebelt.Cdn.Origin/Dockerfile . ``` + Code with passion; love your code; deliver with confidence 👨‍💻️🔥❤️🚀😎 diff --git a/src/Codebelt.Cdn.Origin/CaseInsensitivePhysicalFileProvider.cs b/src/Codebelt.Cdn.Origin/CaseInsensitivePhysicalFileProvider.cs deleted file mode 100644 index fbef9bf..0000000 --- a/src/Codebelt.Cdn.Origin/CaseInsensitivePhysicalFileProvider.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.IO; -using System.Linq; -using Microsoft.Extensions.FileProviders; -using Microsoft.Extensions.FileProviders.Physical; -using Microsoft.Extensions.Primitives; - -namespace Codebelt.Cdn.Origin -{ - public class CaseInsensitivePhysicalFileProvider : IFileProvider // kudos to Pierluc SS @ https://stackoverflow.com/questions/50096995/make-asp-net-core-server-kestrel-case-sensitive-on-windows - { - private readonly PhysicalFileProvider _provider; - private static ConcurrentDictionary _paths; - - public CaseInsensitivePhysicalFileProvider(string root, ExclusionFilters filters = ExclusionFilters.Sensitive) - { - _provider = new PhysicalFileProvider(root, filters); - _paths = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - } - - public IFileInfo GetFileInfo(string subpath) - { - var actualPath = GetActualFilePath(subpath); - return _provider.GetFileInfo(actualPath); - } - - public IDirectoryContents GetDirectoryContents(string subpath) - { - var actualPath = GetActualFilePath(subpath); - return _provider.GetDirectoryContents(actualPath); - } - - public IChangeToken Watch(string filter) => _provider.Watch(filter); - - // Determines (and caches) the actual path for a file - private string GetActualFilePath(string path) - { - // Check if this has already been matched before - if (_paths.ContainsKey(path)) return _paths[path]; - - // Break apart the path and get the root folder to work from - var currPath = _provider.Root; - var segments = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); - - // Start stepping up the folders to replace with the correct cased folder name - for (var i = 0; i < segments.Length; i++) - { - var part = segments[i]; - var last = i == segments.Length - 1; - - // Ignore the root - if (part.Equals("~")) continue; - - // Process the file name if this is the last segment - part = last ? GetFileName(part, currPath) : GetDirectoryName(part, currPath); - - // If no matches were found, just return the original string - if (part == null) return path; - - // Update the actualPath with the correct name casing - currPath = Path.Combine(currPath, part); - segments[i] = part; - } - - // Save this path for later use - var actualPath = string.Join(Path.DirectorySeparatorChar, segments); - _paths.TryAdd(path, actualPath); - return actualPath; - } - - // Searches for a matching file name in the current directory regardless of case - private static string GetFileName(string part, string folder) => - new DirectoryInfo(folder).GetFiles().FirstOrDefault(file => file.Name.Equals(part, StringComparison.OrdinalIgnoreCase))?.Name; - - // Searches for a matching folder in the current directory regardless of case - private static string GetDirectoryName(string part, string folder) => - new DirectoryInfo(folder).GetDirectories().FirstOrDefault(dir => dir.Name.Equals(part, StringComparison.OrdinalIgnoreCase))?.Name; - } -} \ No newline at end of file diff --git a/src/Codebelt.Cdn.Origin/Codebelt.Cdn.Origin.csproj b/src/Codebelt.Cdn.Origin/Codebelt.Cdn.Origin.csproj index 2c7b5a0..72e06bd 100644 --- a/src/Codebelt.Cdn.Origin/Codebelt.Cdn.Origin.csproj +++ b/src/Codebelt.Cdn.Origin/Codebelt.Cdn.Origin.csproj @@ -1,14 +1,11 @@ - - - - net8.0 - Linux - - + - - - + + - diff --git a/src/Codebelt.Cdn.Origin/Codebelt.Cdn.Origin.sln b/src/Codebelt.Cdn.Origin/Codebelt.Cdn.Origin.sln deleted file mode 100644 index 57ef8f7..0000000 --- a/src/Codebelt.Cdn.Origin/Codebelt.Cdn.Origin.sln +++ /dev/null @@ -1,25 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.5.002.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Codebelt.Cdn.Origin", "Codebelt.Cdn.Origin.csproj", "{0D3931D8-70B0-4E1A-8320-1A3D75B62864}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {0D3931D8-70B0-4E1A-8320-1A3D75B62864}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0D3931D8-70B0-4E1A-8320-1A3D75B62864}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0D3931D8-70B0-4E1A-8320-1A3D75B62864}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0D3931D8-70B0-4E1A-8320-1A3D75B62864}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {28193794-332A-4C0A-8886-AB01511D432B} - EndGlobalSection -EndGlobal diff --git a/src/Codebelt.Cdn.Origin/Configuration/CacheOptions.cs b/src/Codebelt.Cdn.Origin/Configuration/CacheOptions.cs new file mode 100644 index 0000000..f11f20c --- /dev/null +++ b/src/Codebelt.Cdn.Origin/Configuration/CacheOptions.cs @@ -0,0 +1,59 @@ +namespace Codebelt.Cdn.Origin.Configuration; + +/// +/// Specifies the cache policy options, expressed as two explicit profiles: a revalidated profile for mutable +/// URLs and an immutable profile for versioned or content-addressed URLs. +/// +/// +/// The following table shows the initial property values for an instance of . +/// +/// +/// Property +/// Initial Value +/// +/// +/// +/// Empty (all content uses the profile) +/// +/// +/// +/// public, max-age=12h, s-maxage=7d, must-revalidate +/// +/// +/// +/// public, max-age=365d, immutable +/// +/// +/// +public sealed class CacheOptions +{ + /// + /// Gets or sets the request path prefixes that select the cache profile. + /// + /// The request path prefixes served with the immutable cache profile. Matching is case-insensitive. + /// Prefixes should represent versioned or content-addressed URLs whose content never changes. + public IList ImmutablePathPrefixes { get; set; } = []; + + /// + /// Gets or sets the cache profile applied to mutable content that should be revalidated. + /// + /// The revalidated cache profile. + public CacheProfileOptions Revalidate { get; set; } = new() + { + Public = true, + MaxAge = TimeSpan.FromHours(12), + SharedMaxAge = TimeSpan.FromDays(7), + MustRevalidate = true + }; + + /// + /// Gets or sets the cache profile applied to versioned or content-addressed immutable content. + /// + /// The immutable cache profile. + public CacheProfileOptions Immutable { get; set; } = new() + { + Public = true, + MaxAge = TimeSpan.FromDays(365), + Immutable = true + }; +} diff --git a/src/Codebelt.Cdn.Origin/Configuration/CacheProfileOptions.cs b/src/Codebelt.Cdn.Origin/Configuration/CacheProfileOptions.cs new file mode 100644 index 0000000..909a3c8 --- /dev/null +++ b/src/Codebelt.Cdn.Origin/Configuration/CacheProfileOptions.cs @@ -0,0 +1,72 @@ +namespace Codebelt.Cdn.Origin.Configuration; + +/// +/// Specifies the directives of a single Cache-Control profile. +/// +/// +/// Directives that would contradict one another (for example together with +/// , or together with ) are rejected +/// during options validation so that the emitted header is always coherent. +/// +public sealed class CacheProfileOptions +{ + /// + /// Gets or sets a value indicating whether the response may be stored by any cache (public) or only by a private cache (private). + /// + /// true to emit public; otherwise private. The default is true. + public bool Public { get; set; } = true; + + /// + /// Gets or sets the freshness lifetime for private caches (max-age). + /// + /// The freshness lifetime, or null to omit max-age. + public TimeSpan? MaxAge { get; set; } + + /// + /// Gets or sets the freshness lifetime for shared caches such as a CDN (s-maxage). + /// + /// The shared freshness lifetime, or null to omit s-maxage. + public TimeSpan? SharedMaxAge { get; set; } + + /// + /// Gets or sets a value indicating whether a stale response must be revalidated with the origin (must-revalidate). + /// + /// true to emit must-revalidate; otherwise false. + public bool MustRevalidate { get; set; } + + /// + /// Gets or sets a value indicating whether caches must revalidate before reuse (no-cache). + /// + /// true to emit no-cache; otherwise false. + public bool NoCache { get; set; } + + /// + /// Gets or sets a value indicating whether the response may not be stored by any cache (no-store). + /// + /// true to emit no-store; otherwise false. + public bool NoStore { get; set; } + + /// + /// Gets or sets a value indicating whether the response will not change and need not be revalidated (immutable). + /// + /// true to emit immutable; otherwise false. + public bool Immutable { get; set; } + + /// + /// Gets or sets the window during which a stale response may be served while it is revalidated in the background (stale-while-revalidate). + /// + /// The stale-while-revalidate window, or null to omit the directive. + public TimeSpan? StaleWhileRevalidate { get; set; } + + /// + /// Gets or sets the window during which a stale response may be served when the origin is unreachable (stale-if-error). + /// + /// The stale-if-error window, or null to omit the directive. + public TimeSpan? StaleIfError { get; set; } + + /// + /// Gets or sets a value indicating whether intermediaries are forbidden from transforming the response (no-transform). + /// + /// true to emit no-transform; otherwise false. The default is false so a CDN may optimize responses. + public bool NoTransform { get; set; } +} diff --git a/src/Codebelt.Cdn.Origin/Configuration/CdnOriginOptions.cs b/src/Codebelt.Cdn.Origin/Configuration/CdnOriginOptions.cs new file mode 100644 index 0000000..93d2059 --- /dev/null +++ b/src/Codebelt.Cdn.Origin/Configuration/CdnOriginOptions.cs @@ -0,0 +1,83 @@ +namespace Codebelt.Cdn.Origin.Configuration; + +/// +/// Specifies the root configuration options for the Static Content Provider. +/// +/// +/// The following table shows the initial property values for an instance of . +/// +/// +/// Property +/// Initial Value +/// +/// +/// +/// /cdnroot +/// +/// +/// +/// default.htm; default.html; index.htm; index.html +/// +/// +/// +public sealed class CdnOriginOptions +{ + /// + /// The configuration section name used to bind . + /// + public const string SectionName = "CdnOrigin"; + + /// + /// The default document file names served when is not configured. + /// + public static readonly IReadOnlyList StandardDefaultDocuments = + [ + "default.htm", + "default.html", + "index.htm", + "index.html" + ]; + + /// + /// Gets or sets the absolute path to the directory of physical files that are served as static content. + /// + /// The absolute path to the static content directory. + /// The directory must exist at startup; it may be an empty mount that is populated at runtime. + public string ContentRoot { get; set; } = "/cdnroot"; + + /// + /// Gets or sets the ordered list of default document file names served when a directory is requested. + /// + /// The ordered list of default document file names. When empty, is used. + public IList DefaultDocuments { get; set; } = []; + + /// + /// Gets or sets the cache policy options. + /// + /// The cache policy options. + public CacheOptions Cache { get; set; } = new(); + + /// + /// Gets or sets the cross-origin resource sharing (CORS) options. + /// + /// The cross-origin resource sharing options. + public CorsPolicyOptions Cors { get; set; } = new(); + + /// + /// Gets or sets the response compression options. + /// + /// The response compression options. + public CompressionOptions Compression { get; set; } = new(); + + /// + /// Gets or sets the content-type mapping options. + /// + /// The content-type mapping options. + public ContentTypeOptions ContentTypes { get; set; } = new(); + + /// + /// Gets or sets the health check options. + /// + /// The health check options. + public HealthOptions Health { get; set; } = new(); +} diff --git a/src/Codebelt.Cdn.Origin/Configuration/CdnOriginOptionsValidator.cs b/src/Codebelt.Cdn.Origin/Configuration/CdnOriginOptionsValidator.cs new file mode 100644 index 0000000..8ff3bfa --- /dev/null +++ b/src/Codebelt.Cdn.Origin/Configuration/CdnOriginOptionsValidator.cs @@ -0,0 +1,151 @@ +using Microsoft.Extensions.Options; + +namespace Codebelt.Cdn.Origin.Configuration; + +/// +/// Validates so that invalid configuration fails fast at startup and the +/// emitted HTTP behaviour is always coherent. +/// +/// +public sealed class CdnOriginOptionsValidator : IValidateOptions +{ + /// + /// Validates the specified . + /// + /// The name of the options instance being validated, if any. + /// The to validate. + /// A describing whether validation succeeded. + /// cannot be null. + public ValidateOptionsResult Validate(string? name, CdnOriginOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var failures = new List(); + + if (string.IsNullOrWhiteSpace(options.ContentRoot)) + { + failures.Add("CdnOrigin:ContentRoot must be configured."); + } + + for (int i = 0; i < options.DefaultDocuments.Count; i++) + { + if (string.IsNullOrWhiteSpace(options.DefaultDocuments[i])) + { + failures.Add($"CdnOrigin:DefaultDocuments[{i}] must not be empty."); + } + } + + ValidateCors(options.Cors, failures); + ValidateCache(options.Cache, failures); + ValidateContentTypes(options.ContentTypes, failures); + ValidateHealth(options.Health, failures); + + return failures.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(failures); + } + + private static void ValidateCors(CorsPolicyOptions cors, List failures) + { + if (!cors.Enabled) { return; } + + bool wildcard = cors.AllowedOrigins.Count == 0 || cors.AllowedOrigins.Contains(CorsPolicyOptions.AnyOrigin); + + if (wildcard && cors.AllowCredentials) + { + failures.Add("CdnOrigin:Cors cannot combine a wildcard (public) origin with AllowCredentials."); + } + + if (cors.AllowedOrigins.Contains(CorsPolicyOptions.AnyOrigin) && cors.AllowedOrigins.Count > 1) + { + failures.Add("CdnOrigin:Cors cannot combine the wildcard origin with explicit origins."); + } + } + + private static void ValidateCache(CacheOptions cache, List failures) + { + for (int i = 0; i < cache.ImmutablePathPrefixes.Count; i++) + { + string prefix = cache.ImmutablePathPrefixes[i]; + if (string.IsNullOrWhiteSpace(prefix) || !prefix.StartsWith('/')) + { + failures.Add($"CdnOrigin:Cache:ImmutablePathPrefixes[{i}] must start with '/'."); + } + } + + ValidateCacheProfile("Revalidate", cache.Revalidate, failures); + ValidateCacheProfile("Immutable", cache.Immutable, failures); + } + + private static void ValidateCacheProfile(string name, CacheProfileOptions profile, List failures) + { + if (profile.NoStore && (profile.MaxAge.HasValue || profile.SharedMaxAge.HasValue)) + { + failures.Add($"CdnOrigin:Cache:{name} cannot combine no-store with max-age or s-maxage."); + } + + if (profile.Immutable && profile.MustRevalidate) + { + failures.Add($"CdnOrigin:Cache:{name} cannot combine immutable with must-revalidate."); + } + + if (profile.Immutable && profile.NoCache) + { + failures.Add($"CdnOrigin:Cache:{name} cannot combine immutable with no-cache."); + } + + AddIfNegative($"CdnOrigin:Cache:{name}:MaxAge", profile.MaxAge, failures); + AddIfNegative($"CdnOrigin:Cache:{name}:SharedMaxAge", profile.SharedMaxAge, failures); + AddIfNegative($"CdnOrigin:Cache:{name}:StaleWhileRevalidate", profile.StaleWhileRevalidate, failures); + AddIfNegative($"CdnOrigin:Cache:{name}:StaleIfError", profile.StaleIfError, failures); + } + + private static void AddIfNegative(string setting, TimeSpan? value, List failures) + { + if (value is { } duration && duration < TimeSpan.Zero) + { + failures.Add($"{setting} cannot be negative."); + } + } + + private static void ValidateContentTypes(ContentTypeOptions contentTypes, List failures) + { + if (contentTypes.ServeUnknownFileTypes && string.IsNullOrWhiteSpace(contentTypes.DefaultContentType)) + { + failures.Add("CdnOrigin:ContentTypes:DefaultContentType must be set when ServeUnknownFileTypes is true."); + } + + foreach (KeyValuePair mapping in contentTypes.Mappings) + { + if (string.IsNullOrWhiteSpace(mapping.Key) || !mapping.Key.StartsWith('.')) + { + failures.Add($"CdnOrigin:ContentTypes:Mappings key '{mapping.Key}' must start with '.'."); + } + + if (string.IsNullOrWhiteSpace(mapping.Value)) + { + failures.Add($"CdnOrigin:ContentTypes:Mappings['{mapping.Key}'] must have a MIME value."); + } + } + } + + private static void ValidateHealth(HealthOptions health, List failures) + { + if (!health.Enabled) { return; } + + if (string.IsNullOrWhiteSpace(health.LivePath) || !health.LivePath.StartsWith('/')) + { + failures.Add("CdnOrigin:Health:LivePath must start with '/'."); + } + + if (string.IsNullOrWhiteSpace(health.ReadyPath) || !health.ReadyPath.StartsWith('/')) + { + failures.Add("CdnOrigin:Health:ReadyPath must start with '/'."); + } + + if (string.Equals(health.LivePath, health.ReadyPath, StringComparison.Ordinal)) + { + failures.Add("CdnOrigin:Health:LivePath and ReadyPath must differ."); + } + } +} diff --git a/src/Codebelt.Cdn.Origin/Configuration/CompressionOptions.cs b/src/Codebelt.Cdn.Origin/Configuration/CompressionOptions.cs new file mode 100644 index 0000000..564a968 --- /dev/null +++ b/src/Codebelt.Cdn.Origin/Configuration/CompressionOptions.cs @@ -0,0 +1,43 @@ +namespace Codebelt.Cdn.Origin.Configuration; + +/// +/// Specifies the response compression options. +/// +/// +/// Compression is disabled by default because edge compression is normally preferred when the provider runs +/// behind a CDN. When enabled, only compressible content types are compressed. +/// +/// +/// Property +/// Initial Value +/// +/// +/// +/// false +/// +/// +/// +/// true +/// +/// +/// +public sealed class CompressionOptions +{ + /// + /// Gets or sets a value indicating whether origin response compression is enabled. + /// + /// true to enable origin compression; otherwise false. The default is false. + public bool Enabled { get; set; } + + /// + /// Gets or sets a value indicating whether compression is applied to responses served over HTTPS. + /// + /// true to compress HTTPS responses; otherwise false. The default is true. + public bool EnableForHttps { get; set; } = true; + + /// + /// Gets or sets additional compressible MIME types beyond the built-in defaults. + /// + /// The additional compressible MIME types. + public IList AdditionalMimeTypes { get; set; } = []; +} diff --git a/src/Codebelt.Cdn.Origin/Configuration/ContentTypeOptions.cs b/src/Codebelt.Cdn.Origin/Configuration/ContentTypeOptions.cs new file mode 100644 index 0000000..5efdabe --- /dev/null +++ b/src/Codebelt.Cdn.Origin/Configuration/ContentTypeOptions.cs @@ -0,0 +1,39 @@ +namespace Codebelt.Cdn.Origin.Configuration; + +/// +/// Specifies the content-type mapping options that govern which file types are served. +/// +/// +/// Unknown file types are rejected by default. Additional file types can be served by adding explicit +/// extension-to-MIME rather than by enabling every unknown extension. +/// +/// +/// Property +/// Initial Value +/// +/// +/// +/// false +/// +/// +/// +public sealed class ContentTypeOptions +{ + /// + /// Gets or sets a value indicating whether files with an unknown extension are served. + /// + /// true to serve unknown file types using ; otherwise false. The default is false. + public bool ServeUnknownFileTypes { get; set; } + + /// + /// Gets or sets the content type used when is true and a file has an unknown extension. + /// + /// The default content type, or null when unknown file types are rejected. + public string? DefaultContentType { get; set; } + + /// + /// Gets or sets additional extension-to-MIME mappings, keyed by file extension (including the leading dot). + /// + /// The additional extension-to-MIME mappings. + public IDictionary Mappings { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/Codebelt.Cdn.Origin/Configuration/CorsPolicyOptions.cs b/src/Codebelt.Cdn.Origin/Configuration/CorsPolicyOptions.cs new file mode 100644 index 0000000..b1a3a8d --- /dev/null +++ b/src/Codebelt.Cdn.Origin/Configuration/CorsPolicyOptions.cs @@ -0,0 +1,78 @@ +namespace Codebelt.Cdn.Origin.Configuration; + +/// +/// Specifies the cross-origin resource sharing (CORS) options for public asset hosting. +/// +/// +/// The following table shows the initial property values for an instance of . +/// +/// +/// Property +/// Initial Value +/// +/// +/// +/// true +/// +/// +/// +/// * (public content mode) +/// +/// +/// +/// false +/// +/// +/// +/// cross-origin +/// +/// +/// +public sealed class CorsPolicyOptions +{ + /// + /// The name of the CORS policy registered for the Static Content Provider. + /// + public const string PolicyName = "CdnOrigin"; + + /// + /// The wildcard origin value that enables public content mode. + /// + public const string AnyOrigin = "*"; + + /// + /// Gets or sets a value indicating whether CORS handling is enabled. + /// + /// true to enable CORS handling; otherwise false. The default is true. + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets the allowed origins. + /// + /// The allowed origins. When empty, or when it contains a single entry, public content mode is used. + public IList AllowedOrigins { get; set; } = []; + + /// + /// Gets or sets the response headers exposed to the browser (Access-Control-Expose-Headers). + /// + /// The exposed response headers. + public IList ExposedHeaders { get; set; } = []; + + /// + /// Gets or sets a value indicating whether credentialed requests are allowed (Access-Control-Allow-Credentials). + /// + /// true to allow credentialed requests; otherwise false. Cannot be combined with . + public bool AllowCredentials { get; set; } + + /// + /// Gets or sets the value of the Cross-Origin-Resource-Policy response header emitted for served assets. + /// + /// The Cross-Origin-Resource-Policy value, or null to omit the header. The default is cross-origin. + public string? CrossOriginResourcePolicy { get; set; } = "cross-origin"; + + /// + /// Gets or sets a value indicating whether a Timing-Allow-Origin response header is emitted for served assets. + /// + /// true to emit Timing-Allow-Origin; otherwise false. + public bool TimingAllowOrigin { get; set; } +} diff --git a/src/Codebelt.Cdn.Origin/Configuration/HealthOptions.cs b/src/Codebelt.Cdn.Origin/Configuration/HealthOptions.cs new file mode 100644 index 0000000..d0c3ef5 --- /dev/null +++ b/src/Codebelt.Cdn.Origin/Configuration/HealthOptions.cs @@ -0,0 +1,46 @@ +namespace Codebelt.Cdn.Origin.Configuration; + +/// +/// Specifies the operational health check options. +/// +/// +/// The following table shows the initial property values for an instance of . +/// +/// +/// Property +/// Initial Value +/// +/// +/// +/// true +/// +/// +/// +/// /health/live +/// +/// +/// +/// /health/ready +/// +/// +/// +public sealed class HealthOptions +{ + /// + /// Gets or sets a value indicating whether the health check endpoints are mapped. + /// + /// true to map the health check endpoints; otherwise false. The default is true. + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets the request path of the liveness endpoint. + /// + /// The liveness endpoint path. The default is /health/live. + public string LivePath { get; set; } = "/health/live"; + + /// + /// Gets or sets the request path of the readiness endpoint. + /// + /// The readiness endpoint path. The default is /health/ready. + public string ReadyPath { get; set; } = "/health/ready"; +} diff --git a/src/Codebelt.Cdn.Origin/Dockerfile b/src/Codebelt.Cdn.Origin/Dockerfile index d426f66..83b4e2d 100644 --- a/src/Codebelt.Cdn.Origin/Dockerfile +++ b/src/Codebelt.Cdn.Origin/Dockerfile @@ -1,24 +1,37 @@ -FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS base -EXPOSE 80 - -ENV ASPNETCORE_HTTP_PORTS 80 - -FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build +# Static Content Provider (Codebelt.Cdn.Origin) container image. +# +# Build from the repository root so central build configuration is available: +# docker build -t codebeltnet/web-cdn-origin:2.0.0 -f src/Codebelt.Cdn.Origin/Dockerfile . +# +# The image runs as a non-root user on the conventional non-privileged port 8080, +# supports a read-only root filesystem, and treats /cdnroot as a read-only content mount. + +FROM dhi.io/dotnet:10-sdk-alpine3.24 AS build WORKDIR /src -COPY ["Codebelt.Cdn.Origin.csproj", "Codebelt.Cdn.Origin/"] -RUN dotnet restore "Codebelt.Cdn.Origin/Codebelt.Cdn.Origin.csproj" +# Copy the central build configuration first so restore is cached independently of source changes. +COPY ["Directory.Build.props", "Directory.Packages.props", "./"] +COPY ["src/Codebelt.Cdn.Origin/Codebelt.Cdn.Origin.csproj", "src/Codebelt.Cdn.Origin/"] +RUN dotnet restore "src/Codebelt.Cdn.Origin/Codebelt.Cdn.Origin.csproj" -COPY [".", "Codebelt.Cdn.Origin/"] +COPY [".editorconfig", "./"] +COPY ["src/", "src/"] +RUN dotnet publish "src/Codebelt.Cdn.Origin/Codebelt.Cdn.Origin.csproj" \ + -c Release -o /app/publish /p:UseAppHost=false +RUN mkdir -p /cdnroot -WORKDIR "/src/Codebelt.Cdn.Origin" -RUN dotnet build "Codebelt.Cdn.Origin.csproj" -c Release -o /app/build +FROM dhi.io/aspnetcore:10-alpine3.24 AS final +WORKDIR /app -FROM build AS publish -RUN dotnet publish "Codebelt.Cdn.Origin.csproj" -c Release -o /app/publish +# Serve on the conventional non-privileged container port; the default content mount point is /cdnroot. +ENV ASPNETCORE_HTTP_PORTS=8080 \ + CdnOrigin__ContentRoot=/cdnroot -FROM base AS final -WORKDIR /app +# The hardened runtime has no shell, so prepare the content mount point in the SDK stage. +COPY --from=build --chown=65532:65532 /cdnroot /cdnroot +COPY --from=build --chown=65532:65532 /app/publish . + +EXPOSE 8080 +USER 65532 -COPY --from=publish /app/publish . ENTRYPOINT ["dotnet", "Codebelt.Cdn.Origin.dll"] diff --git a/src/Codebelt.Cdn.Origin/Hosting/CachePolicyResolver.cs b/src/Codebelt.Cdn.Origin/Hosting/CachePolicyResolver.cs new file mode 100644 index 0000000..c0e0343 --- /dev/null +++ b/src/Codebelt.Cdn.Origin/Hosting/CachePolicyResolver.cs @@ -0,0 +1,119 @@ +using System.Globalization; +using Codebelt.Cdn.Origin.Configuration; + +namespace Codebelt.Cdn.Origin.Hosting; + +/// +/// Resolves the Cache-Control header value for a served asset by selecting either the revalidated or the +/// immutable cache profile based on the request path. +/// +/// +/// The header strings for both profiles are computed once at construction, so resolving a request performs only a +/// prefix comparison and never allocates or hashes file content. +/// +public sealed class CachePolicyResolver +{ + private readonly string _revalidateHeader; + private readonly string _immutableHeader; + private readonly string[] _immutablePrefixes; + + /// + /// Initializes a new instance of the class. + /// + /// The that describe the cache profiles. + /// cannot be null. + public CachePolicyResolver(CdnOriginOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + _revalidateHeader = BuildCacheControl(options.Cache.Revalidate); + _immutableHeader = BuildCacheControl(options.Cache.Immutable); + _immutablePrefixes = + [ + .. options.Cache.ImmutablePathPrefixes.Where(static prefix => !string.IsNullOrWhiteSpace(prefix)) + ]; + } + + /// + /// Resolves the Cache-Control header value for the specified request . + /// + /// The request path of the served asset. + /// The Cache-Control header value of the immutable profile when the path matches a configured immutable prefix; otherwise the revalidated profile value. + public string Resolve(PathString path) + { + return path.HasValue && MatchesImmutablePrefix(path.Value!) + ? _immutableHeader + : _revalidateHeader; + } + + private bool MatchesImmutablePrefix(string path) + { + for (int i = 0; i < _immutablePrefixes.Length; i++) + { + if (path.StartsWith(_immutablePrefixes[i], StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// + /// Builds the Cache-Control header value for the specified . + /// + /// The to render. + /// The rendered Cache-Control header value. + /// cannot be null. + public static string BuildCacheControl(CacheProfileOptions profile) + { + ArgumentNullException.ThrowIfNull(profile); + + if (profile.NoStore) + { + return "no-store"; + } + + var directives = new List(8) + { + profile.Public ? "public" : "private" + }; + + if (profile.NoCache) + { + directives.Add("no-cache"); + } + + AppendSeconds(directives, "max-age", profile.MaxAge); + AppendSeconds(directives, "s-maxage", profile.SharedMaxAge); + + if (profile.MustRevalidate) + { + directives.Add("must-revalidate"); + } + + if (profile.Immutable) + { + directives.Add("immutable"); + } + + AppendSeconds(directives, "stale-while-revalidate", profile.StaleWhileRevalidate); + AppendSeconds(directives, "stale-if-error", profile.StaleIfError); + + if (profile.NoTransform) + { + directives.Add("no-transform"); + } + + return string.Join(", ", directives); + } + + private static void AppendSeconds(List directives, string directive, TimeSpan? value) + { + if (value is { } duration) + { + long seconds = (long)duration.TotalSeconds; + directives.Add(string.Create(CultureInfo.InvariantCulture, $"{directive}={seconds}")); + } + } +} diff --git a/src/Codebelt.Cdn.Origin/Hosting/CdnOriginApplicationBuilderExtensions.cs b/src/Codebelt.Cdn.Origin/Hosting/CdnOriginApplicationBuilderExtensions.cs new file mode 100644 index 0000000..510cb08 --- /dev/null +++ b/src/Codebelt.Cdn.Origin/Hosting/CdnOriginApplicationBuilderExtensions.cs @@ -0,0 +1,181 @@ +using Codebelt.Cdn.Origin.Configuration; +using Cuemon.Extensions.FileProviders; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Options; + +namespace Codebelt.Cdn.Origin.Hosting; + +/// +/// Extension methods for the class that configure the Static Content Provider pipeline. +/// +public static partial class CdnOriginApplicationBuilderExtensions +{ + private const string AllowedMethods = "GET, HEAD, OPTIONS"; + + /// + /// Configures the request pipeline of the Static Content Provider and validates the content root at startup. + /// + /// The to configure. + /// A reference to after the operation has completed. + /// cannot be null. + /// The configured content root is invalid. + public static WebApplication UseCdnOrigin(this WebApplication app) + { + ArgumentNullException.ThrowIfNull(app); + + CdnOriginOptions options = app.Services.GetRequiredService>().Value; + CachePolicyResolver resolver = app.Services.GetRequiredService(); + + ContentRootValidationResult validation = ContentRootValidator.Validate(options.ContentRoot, AppContext.BaseDirectory); + if (!validation.Succeeded) + { + throw new InvalidOperationException($"Invalid Static Content Provider configuration. {validation.ErrorMessage}"); + } + + var fileProvider = new PortablePhysicalFileProvider(validation.ResolvedPath); + + if (options.Compression.Enabled) + { + app.UseResponseCompression(); + } + + if (options.Cors.Enabled) + { + app.UseCors(CorsPolicyOptions.PolicyName); + } + + if (options.Health.Enabled) + { + MapHealthEndpoints(app, options.Health); + } + + IList defaultDocuments = options.DefaultDocuments.Count > 0 + ? options.DefaultDocuments + : [.. CdnOriginOptions.StandardDefaultDocuments]; + app.UseDefaultFiles(CreateDefaultFilesOptions(fileProvider, defaultDocuments)); + + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = fileProvider, + ContentTypeProvider = ContentTypeProviderFactory.Create(options.ContentTypes), + ServeUnknownFileTypes = options.ContentTypes.ServeUnknownFileTypes, + DefaultContentType = options.ContentTypes.DefaultContentType, + RedirectToAppendTrailingSlash = false, + OnPrepareResponse = context => ApplyAssetHeaders(context, resolver, options.Cors) + }); + + app.Use((context, next) => HandleTerminalAsync(context, fileProvider, next)); + + LogEffectiveConfiguration(app, options); + + return app; + } + + private static DefaultFilesOptions CreateDefaultFilesOptions(IFileProvider fileProvider, IList defaultDocuments) + { + var defaultFilesOptions = new DefaultFilesOptions + { + FileProvider = fileProvider, + RedirectToAppendTrailingSlash = false + }; + + defaultFilesOptions.DefaultFileNames.Clear(); + foreach (string document in defaultDocuments) + { + defaultFilesOptions.DefaultFileNames.Add(document); + } + + return defaultFilesOptions; + } + + private static void MapHealthEndpoints(IEndpointRouteBuilder endpoints, HealthOptions health) + { + endpoints.MapHealthChecks(health.LivePath, new HealthCheckOptions + { + Predicate = static _ => false, + ResponseWriter = WriteHealthResponseAsync + }); + + endpoints.MapHealthChecks(health.ReadyPath, new HealthCheckOptions + { + Predicate = static registration => registration.Tags.Contains(ContentRootHealthCheck.ReadyTag), + ResponseWriter = WriteHealthResponseAsync + }); + } + + private static Task WriteHealthResponseAsync(HttpContext context, HealthReport report) + { + context.Response.Headers.CacheControl = "no-store"; + context.Response.ContentType = "text/plain"; + return context.Response.WriteAsync(report.Status.ToString()); + } + + private static void ApplyAssetHeaders(StaticFileResponseContext context, CachePolicyResolver resolver, CorsPolicyOptions cors) + { + IHeaderDictionary headers = context.Context.Response.Headers; + headers.CacheControl = resolver.Resolve(context.Context.Request.Path); + + if (!string.IsNullOrEmpty(cors.CrossOriginResourcePolicy)) + { + headers["Cross-Origin-Resource-Policy"] = cors.CrossOriginResourcePolicy; + } + + if (cors.TimingAllowOrigin) + { + bool wildcard = cors.AllowedOrigins.Count == 0 || cors.AllowedOrigins.Contains(CorsPolicyOptions.AnyOrigin); + headers["Timing-Allow-Origin"] = wildcard + ? CorsPolicyOptions.AnyOrigin + : string.Join(", ", cors.AllowedOrigins); + } + } + + private static Task HandleTerminalAsync(HttpContext context, IFileProvider fileProvider, RequestDelegate next) + { + if (context.GetEndpoint() is not null) + { + return next(context); + } + + HttpRequest request = context.Request; + bool isReadMethod = HttpMethods.IsGet(request.Method) || HttpMethods.IsHead(request.Method); + + if (!isReadMethod && FileExists(fileProvider, request.Path)) + { + context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + context.Response.Headers.Allow = AllowedMethods; + return Task.CompletedTask; + } + + context.Response.StatusCode = StatusCodes.Status404NotFound; + return Task.CompletedTask; + } + + private static bool FileExists(IFileProvider fileProvider, PathString path) + { + IFileInfo fileInfo = fileProvider.GetFileInfo(path.Value!); + return fileInfo.Exists && !fileInfo.IsDirectory; + } + + private static void LogEffectiveConfiguration(WebApplication app, CdnOriginOptions options) + { + ILogger logger = app.Services.GetRequiredService().CreateLogger("Codebelt.Cdn.Origin"); + LogReady( + logger, + options.ContentRoot, + options.DefaultDocuments.Count, + options.Cors.Enabled, + options.Cors.AllowedOrigins.Count, + options.Compression.Enabled, + options.Cache.ImmutablePathPrefixes.Count, + options.Health.Enabled); + } + + [LoggerMessage( + EventId = 1000, + Level = LogLevel.Information, + Message = "Static Content Provider ready. ContentRoot={ContentRoot}; DefaultDocuments={DefaultDocuments}; CorsEnabled={CorsEnabled}; AllowedOrigins={AllowedOrigins}; Compression={Compression}; ImmutablePrefixes={ImmutablePrefixes}; HealthChecks={HealthChecks}")] + private static partial void LogReady(ILogger logger, string contentRoot, int defaultDocuments, bool corsEnabled, int allowedOrigins, bool compression, int immutablePrefixes, bool healthChecks); +} diff --git a/src/Codebelt.Cdn.Origin/Hosting/CdnOriginServiceCollectionExtensions.cs b/src/Codebelt.Cdn.Origin/Hosting/CdnOriginServiceCollectionExtensions.cs new file mode 100644 index 0000000..7590173 --- /dev/null +++ b/src/Codebelt.Cdn.Origin/Hosting/CdnOriginServiceCollectionExtensions.cs @@ -0,0 +1,70 @@ +using Codebelt.Cdn.Origin.Configuration; +using Microsoft.Extensions.Options; + +namespace Codebelt.Cdn.Origin.Hosting; + +/// +/// Extension methods for the interface that register the Static Content Provider services. +/// +public static class CdnOriginServiceCollectionExtensions +{ + /// + /// The compressible MIME types enabled when origin compression is turned on. + /// + /// Only text-based and other genuinely compressible types are included; already-compressed formats such as images, video, and WOFF2 fonts are intentionally excluded. + public static readonly IReadOnlyList DefaultCompressibleMimeTypes = + [ + "text/plain", + "text/css", + "text/html", + "text/xml", + "text/javascript", + "application/javascript", + "application/json", + "application/xml", + "application/manifest+json", + "image/svg+xml", + "image/x-icon", + "application/wasm", + "font/ttf", + "font/otf" + ]; + + /// + /// Adds the Static Content Provider services to the specified . + /// + /// The to extend. + /// The used to bind . + /// A reference to after the operation has completed. + /// + /// cannot be null - or - cannot be null. + /// + /// + /// CORS, response compression, and health check services are always registered and configured from the final + /// bound options; whether their middleware runs is decided by UseCdnOrigin. This keeps service + /// registration and pipeline configuration consistent regardless of configuration source ordering. + /// + public static IServiceCollection AddCdnOrigin(this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services.AddOptions() + .Bind(configuration.GetSection(CdnOriginOptions.SectionName)) + .ValidateOnStart(); + services.AddSingleton, CdnOriginOptionsValidator>(); + + services.AddSingleton(static sp => new CachePolicyResolver(sp.GetRequiredService>().Value)); + + services.AddCors(); + services.ConfigureOptions(); + + services.AddResponseCompression(); + services.ConfigureOptions(); + + services.AddHealthChecks() + .AddCheck("content-root", tags: [ContentRootHealthCheck.ReadyTag]); + + return services; + } +} diff --git a/src/Codebelt.Cdn.Origin/Hosting/ConfigureCdnOriginCompressionOptions.cs b/src/Codebelt.Cdn.Origin/Hosting/ConfigureCdnOriginCompressionOptions.cs new file mode 100644 index 0000000..ca28842 --- /dev/null +++ b/src/Codebelt.Cdn.Origin/Hosting/ConfigureCdnOriginCompressionOptions.cs @@ -0,0 +1,36 @@ +using System.IO.Compression; +using Codebelt.Cdn.Origin.Configuration; +using Microsoft.AspNetCore.ResponseCompression; +using Microsoft.Extensions.Options; + +namespace Codebelt.Cdn.Origin.Hosting; + +/// +/// Configures response compression for the Static Content Provider from the bound , +/// enabling Brotli and Gzip for compressible content types only. +/// +/// +internal sealed class ConfigureCdnOriginCompressionOptions(IOptions options) : + IConfigureOptions, + IConfigureOptions, + IConfigureOptions +{ + public void Configure(ResponseCompressionOptions compressionOptions) + { + CompressionOptions compression = options.Value.Compression; + + compressionOptions.EnableForHttps = compression.EnableForHttps; + compressionOptions.Providers.Add(); + compressionOptions.Providers.Add(); + compressionOptions.MimeTypes = + [ + .. CdnOriginServiceCollectionExtensions.DefaultCompressibleMimeTypes + .Concat(compression.AdditionalMimeTypes) + .Distinct(StringComparer.OrdinalIgnoreCase) + ]; + } + + public void Configure(BrotliCompressionProviderOptions brotliOptions) => brotliOptions.Level = CompressionLevel.Fastest; + + public void Configure(GzipCompressionProviderOptions gzipOptions) => gzipOptions.Level = CompressionLevel.Fastest; +} diff --git a/src/Codebelt.Cdn.Origin/Hosting/ConfigureCdnOriginCorsOptions.cs b/src/Codebelt.Cdn.Origin/Hosting/ConfigureCdnOriginCorsOptions.cs new file mode 100644 index 0000000..d06dd36 --- /dev/null +++ b/src/Codebelt.Cdn.Origin/Hosting/ConfigureCdnOriginCorsOptions.cs @@ -0,0 +1,44 @@ +using Codebelt.Cdn.Origin.Configuration; +using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.Extensions.Options; + +namespace Codebelt.Cdn.Origin.Hosting; + +/// +/// Configures the named CORS policy for the Static Content Provider from the bound . +/// +/// +internal sealed class ConfigureCdnOriginCorsOptions(IOptions options) : IConfigureOptions +{ + public void Configure(CorsOptions corsOptions) + { + CorsPolicyOptions cors = options.Value.Cors; + + corsOptions.AddPolicy(CorsPolicyOptions.PolicyName, policy => + { + bool wildcard = cors.AllowedOrigins.Count == 0 || cors.AllowedOrigins.Contains(CorsPolicyOptions.AnyOrigin); + + if (wildcard) + { + policy.AllowAnyOrigin(); + } + else + { + policy.WithOrigins([.. cors.AllowedOrigins]); + } + + policy.WithMethods(HttpMethods.Get, HttpMethods.Head, HttpMethods.Options); + policy.AllowAnyHeader(); + + if (cors.ExposedHeaders.Count > 0) + { + policy.WithExposedHeaders([.. cors.ExposedHeaders]); + } + + if (cors.AllowCredentials && !wildcard) + { + policy.AllowCredentials(); + } + }); + } +} diff --git a/src/Codebelt.Cdn.Origin/Hosting/ContentRootHealthCheck.cs b/src/Codebelt.Cdn.Origin/Hosting/ContentRootHealthCheck.cs new file mode 100644 index 0000000..8b8e395 --- /dev/null +++ b/src/Codebelt.Cdn.Origin/Hosting/ContentRootHealthCheck.cs @@ -0,0 +1,59 @@ +using Codebelt.Cdn.Origin.Configuration; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; + +namespace Codebelt.Cdn.Origin.Hosting; + +/// +/// A readiness that verifies the configured static content root is available and readable. +/// +/// +public sealed class ContentRootHealthCheck : IHealthCheck +{ + /// + /// The tag applied to readiness health checks. + /// + public const string ReadyTag = "ready"; + + private readonly CdnOriginOptions _options; + private readonly string _applicationDirectory; + + /// + /// Initializes a new instance of the class. + /// + /// The monitored . + /// cannot be null. + public ContentRootHealthCheck(IOptions options) + : this((options ?? throw new ArgumentNullException(nameof(options))).Value, AppContext.BaseDirectory) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The monitored . + /// The application base directory whose files must not be exposed. + /// cannot be null. + public ContentRootHealthCheck(CdnOriginOptions options, string applicationDirectory) + { + ArgumentNullException.ThrowIfNull(options); + + _options = options; + _applicationDirectory = applicationDirectory; + } + + /// + /// Verifies that the configured static content root is available and readable. + /// + /// The . + /// A . + /// A that yields the . + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + ContentRootValidationResult result = ContentRootValidator.Validate(_options.ContentRoot, _applicationDirectory); + + return Task.FromResult(result.Succeeded + ? HealthCheckResult.Healthy($"Content root '{result.ResolvedPath}' is available.") + : HealthCheckResult.Unhealthy(result.ErrorMessage)); + } +} diff --git a/src/Codebelt.Cdn.Origin/Hosting/ContentRootProbe.cs b/src/Codebelt.Cdn.Origin/Hosting/ContentRootProbe.cs new file mode 100644 index 0000000..61ebc0f --- /dev/null +++ b/src/Codebelt.Cdn.Origin/Hosting/ContentRootProbe.cs @@ -0,0 +1,16 @@ +namespace Codebelt.Cdn.Origin.Hosting; + +/// +/// Represents an observed snapshot of the configured static content root used to make a validation decision. +/// +/// The fully resolved content root path. +/// A value indicating whether the path exists as either a file or a directory. +/// A value indicating whether the path is a directory. +/// A value indicating whether the directory can be enumerated. +/// A value indicating whether serving the path would expose application files. +public readonly record struct ContentRootProbe( + string ResolvedPath, + bool Exists, + bool IsDirectory, + bool IsReadable, + bool ExposesApplicationFiles); diff --git a/src/Codebelt.Cdn.Origin/Hosting/ContentRootValidationResult.cs b/src/Codebelt.Cdn.Origin/Hosting/ContentRootValidationResult.cs new file mode 100644 index 0000000..cd64e1d --- /dev/null +++ b/src/Codebelt.Cdn.Origin/Hosting/ContentRootValidationResult.cs @@ -0,0 +1,47 @@ +namespace Codebelt.Cdn.Origin.Hosting; + +/// +/// Represents the outcome of validating the configured static content root. +/// +public sealed class ContentRootValidationResult +{ + private ContentRootValidationResult(bool succeeded, string resolvedPath, string? errorMessage) + { + Succeeded = succeeded; + ResolvedPath = resolvedPath; + ErrorMessage = errorMessage; + } + + /// + /// Gets a value indicating whether validation succeeded. + /// + /// true if the content root is valid; otherwise false. + public bool Succeeded { get; } + + /// + /// Gets the fully resolved content root path. + /// + /// The fully resolved content root path. + public string ResolvedPath { get; } + + /// + /// Gets the error message describing why validation failed. + /// + /// The error message, or null when is true. + public string? ErrorMessage { get; } + + /// + /// Creates a successful . + /// + /// The fully resolved content root path. + /// A successful . + public static ContentRootValidationResult Success(string resolvedPath) => new(true, resolvedPath, null); + + /// + /// Creates a failed . + /// + /// The fully resolved content root path. + /// The error message describing why validation failed. + /// A failed . + public static ContentRootValidationResult Failure(string resolvedPath, string errorMessage) => new(false, resolvedPath, errorMessage); +} diff --git a/src/Codebelt.Cdn.Origin/Hosting/ContentRootValidator.cs b/src/Codebelt.Cdn.Origin/Hosting/ContentRootValidator.cs new file mode 100644 index 0000000..4e10667 --- /dev/null +++ b/src/Codebelt.Cdn.Origin/Hosting/ContentRootValidator.cs @@ -0,0 +1,144 @@ +namespace Codebelt.Cdn.Origin.Hosting; + +/// +/// Validates the configured static content root. The pure decision is separated from the +/// filesystem so the decision logic is fully deterministic and testable. +/// +public static class ContentRootValidator +{ + /// + /// Validates the configured against the specified application directory. + /// + /// The configured content root path. + /// The application base directory whose files must not be exposed. + /// A describing the outcome. + public static ContentRootValidationResult Validate(string? contentRoot, string applicationDirectory) + { + ContentRootProbe probe = Probe(contentRoot, applicationDirectory); + return Evaluate(contentRoot, probe); + } + + /// + /// Evaluates the validation decision for the specified . + /// + /// The configured content root path. + /// The observed content root snapshot. + /// A describing the outcome. + public static ContentRootValidationResult Evaluate(string? contentRoot, ContentRootProbe probe) + { + if (string.IsNullOrWhiteSpace(contentRoot)) + { + return ContentRootValidationResult.Failure(probe.ResolvedPath, "The content root must be configured."); + } + + if (!probe.Exists) + { + return ContentRootValidationResult.Failure(probe.ResolvedPath, $"The content root '{probe.ResolvedPath}' does not exist."); + } + + if (!probe.IsDirectory) + { + return ContentRootValidationResult.Failure(probe.ResolvedPath, $"The content root '{probe.ResolvedPath}' is not a directory."); + } + + if (!probe.IsReadable) + { + return ContentRootValidationResult.Failure(probe.ResolvedPath, $"The content root '{probe.ResolvedPath}' is not readable."); + } + + return probe.ExposesApplicationFiles + ? ContentRootValidationResult.Failure(probe.ResolvedPath, $"The content root '{probe.ResolvedPath}' would expose application files.") + : ContentRootValidationResult.Success(probe.ResolvedPath); + } + + /// + /// Observes the filesystem to build a for the specified . + /// + /// The configured content root path. + /// The application base directory whose files must not be exposed. + /// The observed . + public static ContentRootProbe Probe(string? contentRoot, string applicationDirectory) + { + if (string.IsNullOrWhiteSpace(contentRoot)) + { + return new ContentRootProbe(string.Empty, false, false, false, false); + } + + string resolved; + try + { + resolved = ResolveFinalDirectoryPath(contentRoot); + } + catch (ArgumentException) + { + return new ContentRootProbe(contentRoot, false, false, false, false); + } + + bool isDirectory = Directory.Exists(resolved); + bool exists = isDirectory || File.Exists(resolved); + bool isReadable = isDirectory && IsDirectoryReadable(resolved); + bool exposesApplicationFiles = isDirectory && ExposesApplicationFiles(resolved, applicationDirectory); + + return new ContentRootProbe(resolved, exists, isDirectory, isReadable, exposesApplicationFiles); + } + + /// + /// Determines whether the specified directory can be enumerated. + /// + /// The directory path to probe. + /// true if the directory can be enumerated; otherwise false. + public static bool IsDirectoryReadable(string path) + { + try + { + using IEnumerator enumerator = Directory.EnumerateFileSystemEntries(path).GetEnumerator(); + enumerator.MoveNext(); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } + + /// + /// Determines whether serving the specified would expose application files. + /// + /// The fully resolved content root path. + /// The application base directory. + /// true if the application directory is the content root or nested within it; otherwise false. + /// Symbolic links and junctions are resolved to their final directory targets before the case-insensitive containment check. + public static bool ExposesApplicationFiles(string contentRoot, string applicationDirectory) + { + string root = EnsureTrailingSeparator(ResolveFinalDirectoryPath(contentRoot)); + string application = EnsureTrailingSeparator(ResolveFinalDirectoryPath(applicationDirectory)); + return application.StartsWith(root, StringComparison.OrdinalIgnoreCase); + } + + private static string ResolveFinalDirectoryPath(string path) + { + return ResolveFinalDirectoryPath(new DirectoryInfo(Path.GetFullPath(path))); + } + + private static string ResolveFinalDirectoryPath(DirectoryInfo directory) + { + DirectoryInfo? parent = directory.Parent; + if (parent is null) + { + return directory.FullName; + } + + string candidate = Path.Combine(ResolveFinalDirectoryPath(parent), directory.Name); + + return Directory.Exists(candidate) + ? Directory.ResolveLinkTarget(candidate, returnFinalTarget: true)?.FullName ?? candidate + : candidate; + } + + private static string EnsureTrailingSeparator(string path) + { + return path.EndsWith(Path.DirectorySeparatorChar) + ? path + : path + Path.DirectorySeparatorChar; + } +} diff --git a/src/Codebelt.Cdn.Origin/Hosting/ContentTypeProviderFactory.cs b/src/Codebelt.Cdn.Origin/Hosting/ContentTypeProviderFactory.cs new file mode 100644 index 0000000..58a6014 --- /dev/null +++ b/src/Codebelt.Cdn.Origin/Hosting/ContentTypeProviderFactory.cs @@ -0,0 +1,38 @@ +using Codebelt.Cdn.Origin.Configuration; +using Microsoft.AspNetCore.StaticFiles; + +namespace Codebelt.Cdn.Origin.Hosting; + +/// +/// Creates the used to map file extensions to safe, explicit +/// content types, extended with any additional mappings from configuration. +/// +public static class ContentTypeProviderFactory +{ + /// + /// Creates a from the specified . + /// + /// The describing additional extension-to-MIME mappings. + /// A configured . + /// cannot be null. + public static FileExtensionContentTypeProvider Create(ContentTypeOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var provider = new FileExtensionContentTypeProvider(); + + foreach (KeyValuePair mapping in options.Mappings) + { + provider.Mappings[NormalizeExtension(mapping.Key)] = mapping.Value; + } + + return provider; + } + + private static string NormalizeExtension(string extension) + { + return extension.StartsWith('.') + ? extension + : "." + extension; + } +} diff --git a/src/Codebelt.Cdn.Origin/Program.cs b/src/Codebelt.Cdn.Origin/Program.cs index be400cf..fbb0a6c 100644 --- a/src/Codebelt.Cdn.Origin/Program.cs +++ b/src/Codebelt.Cdn.Origin/Program.cs @@ -1,20 +1,28 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Hosting; +using Codebelt.Bootstrapper.Web; +using Codebelt.Cdn.Origin.Hosting; -namespace Codebelt.Cdn.Origin +namespace Codebelt.Cdn.Origin; + +/// +/// The entry point of the Static Content Provider. +/// +public class Program : MinimalWebProgram { - public class Program + /// + /// The application entry point. + /// + /// The command line arguments. + /// A that represents the running application. + public static Task Main(string[] args) { - public static void Main(string[] args) - { - CreateHostBuilder(args).Build().Run(); - } + var builder = CreateHostBuilder(args); + + builder.Services.AddCdnOrigin(builder.Configuration); + + var app = builder.Build(); + + app.UseCdnOrigin(); - public static IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); + return app.RunAsync(); } } diff --git a/src/Codebelt.Cdn.Origin/Properties/launchSettings.json b/src/Codebelt.Cdn.Origin/Properties/launchSettings.json index e55324c..67443b0 100644 --- a/src/Codebelt.Cdn.Origin/Properties/launchSettings.json +++ b/src/Codebelt.Cdn.Origin/Properties/launchSettings.json @@ -1,25 +1,15 @@ { + "$schema": "https://json.schemastore.org/launchsettings.json", "profiles": { "Codebelt.Cdn.Origin": { "commandName": "Project", - "launchBrowser": true, + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5001", "environmentVariables": { - "CDNROOT": "C:\\Source\\Test\\cdncontent", - "ETAG_BYTESTOREAD": "1024", - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "dotnetRunMessages": "true", - "applicationUrl": "http://localhost:5001" - }, - "Docker": { - "commandName": "Docker", - "launchBrowser": true, - "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", - "environmentVariables": { - "CDNROOT": "C:\\Source\\Test\\cdncontent", - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "publishAllPorts": true + "ASPNETCORE_ENVIRONMENT": "Development", + "CdnOrigin__ContentRoot": "C:\\Source\\Test\\cdncontent" + } } } -} \ No newline at end of file +} diff --git a/src/Codebelt.Cdn.Origin/Startup.cs b/src/Codebelt.Cdn.Origin/Startup.cs deleted file mode 100644 index 5293bb5..0000000 --- a/src/Codebelt.Cdn.Origin/Startup.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System; -using System.IO; -using Cuemon; -using Cuemon.Data.Integrity; -using Cuemon.Extensions; -using Cuemon.Extensions.AspNetCore.Http; -using Cuemon.Extensions.Collections.Generic; -using Cuemon.Security.Cryptography; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Net.Http.Headers; - -namespace Codebelt.Cdn.Origin -{ - public class Startup - { - private readonly TimeSpan _maxAge; - private readonly TimeSpan _sharedMaxAge; - private readonly string _contentPath; - private readonly string[] _defaultFiles; - private readonly int _bytesToReadForEntityTagHeader; - - public Startup(IConfiguration configuration) - { - var maxAge = Convert.ToDouble(configuration["CACHECONTROL_MAXAGE"] ?? "12"); - var maxAgeTimeUnit = (configuration["CACHECONTROL_MAXAGE_TIMEUNIT"] ?? "Hours").ToEnum(); - var sharedMaxAge = Convert.ToDouble(configuration["CACHECONTROL_SHAREDMAXAGE"] ?? "168"); - var sharedMaxAgeTimeUnit = (configuration["CACHECONTROL_SHAREDMAXAGE_TIMEUNIT"] ?? "Hours").ToEnum(); - - _bytesToReadForEntityTagHeader = Convert.ToInt32(configuration["ETAG_BYTESTOREAD"] ?? $"{int.MaxValue}"); - _contentPath = configuration["CDNROOT"] ?? "/cdnroot"; - _defaultFiles = (configuration["CDNROOT_DEFAULTFILES"] ?? "default.htm;default.html;index.htm;index.html").Split(';'); - _maxAge = maxAge.ToTimeSpan(maxAgeTimeUnit); - _sharedMaxAge = sharedMaxAge.ToTimeSpan(sharedMaxAgeTimeUnit); - } - - public void ConfigureServices(IServiceCollection services) - { - services.AddResponseCaching(); - services.AddResponseCompression(); - } - - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - app.UseDefaultFiles(Patterns.CreateInstance(o => - { - o.DefaultFileNames.Clear(); - o.DefaultFileNames.AddRange(_defaultFiles); - o.FileProvider = new CaseInsensitivePhysicalFileProvider(_contentPath); - })); - - app.UseResponseCompression(); - - app.UseStaticFiles(Patterns.CreateInstance(o => - { - o.ServeUnknownFileTypes = true; - o.FileProvider = new CaseInsensitivePhysicalFileProvider(_contentPath); - o.OnPrepareResponse = fc => - { - fc.Context.Response.Headers["Access-Control-Allow-Origin"] = "*"; - fc.Context.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() - { - Public = true, - MustRevalidate = true, - NoTransform = true, - MaxAge = _maxAge, - SharedMaxAge = _sharedMaxAge - }; - fc.Context.Response.Headers[HeaderNames.Expires] = DateTime.UtcNow.Add(_maxAge).ToString("R"); - if (!fc.File.IsDirectory && fc.File.Exists) - { - var builder = new ChecksumBuilder(() => UnkeyedHashFactory.CreateCryptoMd5()).CombineWith(fc.File.CreateReadStream().ToByteArray(_bytesToReadForEntityTagHeader)); - fc.Context.Response.AddOrUpdateLastModifiedHeader(fc.Context.Request, fc.File.LastModified.UtcDateTime); - fc.Context.Response.AddOrUpdateEntityTagHeader(fc.Context.Request, builder, fc.File.Length > _bytesToReadForEntityTagHeader); - if (fc.Context.Response.StatusCode == StatusCodes.Status304NotModified) - { - fc.Context.Response.Body = new MemoryStream(); - fc.Context.Response.ContentLength = 0; - } - } - }; - })); - } - } -} \ No newline at end of file diff --git a/src/Codebelt.Cdn.Origin/StreamExtensions.cs b/src/Codebelt.Cdn.Origin/StreamExtensions.cs deleted file mode 100644 index c693153..0000000 --- a/src/Codebelt.Cdn.Origin/StreamExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.IO; - -namespace Codebelt.Cdn.Origin -{ - public static class StreamExtensions - { - public static byte[] ToByteArray(this Stream file, int bytesToRead) - { - long buffer = bytesToRead; - if (file.Length < buffer) { buffer = file.Length; } - - var checksumBytes = new byte[buffer]; - using (file) - { - file.Read(checksumBytes, 0, (int)buffer); - } - return checksumBytes; - } - } -} \ No newline at end of file diff --git a/src/Codebelt.Cdn.Origin/appsettings.Development.json b/src/Codebelt.Cdn.Origin/appsettings.Development.json new file mode 100644 index 0000000..3e1a225 --- /dev/null +++ b/src/Codebelt.Cdn.Origin/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Information" + } + } +} diff --git a/src/Codebelt.Cdn.Origin/appsettings.json b/src/Codebelt.Cdn.Origin/appsettings.json new file mode 100644 index 0000000..6d42716 --- /dev/null +++ b/src/Codebelt.Cdn.Origin/appsettings.json @@ -0,0 +1,48 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "CdnOrigin": { + "ContentRoot": "/cdnroot", + "Cache": { + "ImmutablePathPrefixes": [], + "Revalidate": { + "Public": true, + "MaxAge": "12:00:00", + "SharedMaxAge": "7.00:00:00", + "MustRevalidate": true + }, + "Immutable": { + "Public": true, + "MaxAge": "365.00:00:00", + "Immutable": true + } + }, + "Cors": { + "Enabled": true, + "AllowedOrigins": [], + "ExposedHeaders": [], + "AllowCredentials": false, + "CrossOriginResourcePolicy": "cross-origin", + "TimingAllowOrigin": false + }, + "Compression": { + "Enabled": false, + "EnableForHttps": true, + "AdditionalMimeTypes": [] + }, + "ContentTypes": { + "ServeUnknownFileTypes": false, + "Mappings": {} + }, + "Health": { + "Enabled": true, + "LivePath": "/health/live", + "ReadyPath": "/health/ready" + } + } +} diff --git a/test/Codebelt.Cdn.Origin.FunctionalTests/CaseInsensitivePathTest.cs b/test/Codebelt.Cdn.Origin.FunctionalTests/CaseInsensitivePathTest.cs new file mode 100644 index 0000000..42c8bda --- /dev/null +++ b/test/Codebelt.Cdn.Origin.FunctionalTests/CaseInsensitivePathTest.cs @@ -0,0 +1,107 @@ +using System.Net; +using Codebelt.Extensions.Xunit; +using Cuemon.Extensions.FileProviders; +using Microsoft.Extensions.FileProviders; +using Xunit; + +namespace Codebelt.Cdn.Origin; + +public class CaseInsensitivePathTest : Test +{ + private static readonly CaseVariant[] CaseVariants = + [ + new( + "PascalCase/AssetFile.txt", + [ + "/PascalCase/AssetFile.txt", + "/pascalcase/assetfile.txt", + "/PASCALCASE/ASSETFILE.TXT", + "/pAsCaLcAsE/aSsEtFiLe.TxT" + ], + "PascalCase asset"), + new( + "camelCase/assetFile.txt", + [ + "/camelCase/assetFile.txt", + "/camelcase/assetfile.txt", + "/CAMELCASE/ASSETFILE.TXT", + "/cAmElCaSe/aSsEtFiLe.TxT" + ], + "camelCase asset"), + new( + "mixedCase/MiXeDFile.txt", + [ + "/mixedCase/MiXeDFile.txt", + "/mixedcase/mixedfile.txt", + "/MIXEDCASE/MIXEDFILE.TXT", + "/mIxEdCaSe/mIxEdFiLe.TxT" + ], + "mixedCase asset"), + new( + "lowercase/assetfile.txt", + [ + "/lowercase/assetfile.txt", + "/LOWERCASE/ASSETFILE.TXT", + "/LowerCase/AssetFile.TxT", + "/lOwErCaSe/aSsEtFiLe.tXt" + ], + "lowercase asset"), + new( + "UPPERCASE/ASSETFILE.TXT", + [ + "/UPPERCASE/ASSETFILE.TXT", + "/uppercase/assetfile.txt", + "/UpperCase/AssetFile.TxT", + "/uPpErCaSe/AsSeTfIlE.tXt" + ], + "UPPERCASE asset") + ]; + + public CaseInsensitivePathTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task Get_ShouldServeEveryCasingVariant_WhenCanonicalFilesUseDifferentCasing() + { + await using var application = new CdnOriginTestApplication(TestOutput); + + foreach (CaseVariant variant in CaseVariants) + { + WriteFile(application.Content.Root, variant.ActualPath, variant.Content); + } + + using var client = application.CreateClient(); + using var legacy = new LegacyCaseInsensitivePhysicalFileProvider(application.Content.Root); + using var modern = new PortablePhysicalFileProvider(application.Content.Root); + + foreach (CaseVariant variant in CaseVariants) + { + foreach (string requestedPath in variant.RequestPaths) + { + IFileInfo legacyFile = legacy.GetFileInfo(requestedPath); + IFileInfo modernFile = modern.GetFileInfo(requestedPath); + + Assert.True(legacyFile.Exists, $"Legacy provider did not resolve '{requestedPath}'."); + Assert.True(modernFile.Exists, $"Replacement provider did not resolve '{requestedPath}'."); + Assert.Equal(legacyFile.Name, modernFile.Name); + Assert.Equal(legacyFile.Length, modernFile.Length); + Assert.Equal(legacyFile.PhysicalPath, modernFile.PhysicalPath); + + using var response = await client.GetAsync(requestedPath); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(variant.Content, await response.Content.ReadAsStringAsync()); + } + } + } + + private static void WriteFile(string root, string relativePath, string content) + { + string path = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + private sealed record CaseVariant(string ActualPath, string[] RequestPaths, string Content); +} diff --git a/test/Codebelt.Cdn.Origin.FunctionalTests/CdnOriginTestApplication.cs b/test/Codebelt.Cdn.Origin.FunctionalTests/CdnOriginTestApplication.cs new file mode 100644 index 0000000..b69346b --- /dev/null +++ b/test/Codebelt.Cdn.Origin.FunctionalTests/CdnOriginTestApplication.cs @@ -0,0 +1,72 @@ +using Codebelt.Extensions.Xunit; +using Codebelt.Extensions.Xunit.Hosting; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Codebelt.Cdn.Origin; + +/// +/// Hosts the real Static Content Provider pipeline over an isolated temporary content directory using +/// . +/// +public sealed class CdnOriginTestApplication : Test +{ + private readonly IHostTest _hostTest; + + public CdnOriginTestApplication(ITestOutputHelper output, IDictionary settings = null) : base(output) + { + Content = new TempContent(); + var defaultSettings = new Dictionary(StringComparer.Ordinal) + { + ["CdnOrigin:ContentRoot"] = Content.Root + }; + + if (settings is not null) + { + foreach (var setting in settings) + { + defaultSettings[setting.Key] = setting.Value; + } + } + + _hostTest = WebApplicationTestFactory.Create(builder => + { + builder.UseEnvironment(Environments.Production); + builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(defaultSettings)); + }, new ManagedWebApplicationFixture()); + } + + public HttpClient CreateClient() + { + return _hostTest.Host.GetTestClient(); + } + + public TempContent Content { get; } + + protected override void OnDisposeManagedResources() + { + _hostTest?.Dispose(); + Content.Dispose(); + base.OnDisposeManagedResources(); + } + + protected override async ValueTask OnDisposeManagedResourcesAsync() + { + if (_hostTest is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + } + else + { + _hostTest?.Dispose(); + } + + Content.Dispose(); + await base.OnDisposeManagedResourcesAsync().ConfigureAwait(false); + } +} diff --git a/test/Codebelt.Cdn.Origin.FunctionalTests/Codebelt.Cdn.Origin.FunctionalTests.csproj b/test/Codebelt.Cdn.Origin.FunctionalTests/Codebelt.Cdn.Origin.FunctionalTests.csproj new file mode 100644 index 0000000..b47c1ec --- /dev/null +++ b/test/Codebelt.Cdn.Origin.FunctionalTests/Codebelt.Cdn.Origin.FunctionalTests.csproj @@ -0,0 +1,11 @@ + + + + Codebelt.Cdn.Origin + + + + + + + diff --git a/test/Codebelt.Cdn.Origin.FunctionalTests/CompressionTest.cs b/test/Codebelt.Cdn.Origin.FunctionalTests/CompressionTest.cs new file mode 100644 index 0000000..8a0e538 --- /dev/null +++ b/test/Codebelt.Cdn.Origin.FunctionalTests/CompressionTest.cs @@ -0,0 +1,66 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Runtime; +using Codebelt.Extensions.Xunit; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace Codebelt.Cdn.Origin; + +public class CompressionTest : Test +{ + public CompressionTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task Get_ShouldNotCompress_WhenCompressionDisabled() + { + using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/styles/site.css"); + request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("br")); + request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("gzip")); + using var response = await client.SendAsync(request); + + Assert.Empty(response.Content.Headers.ContentEncoding); + } + + [Fact] + public async Task Get_ShouldCompressCompressibleContent_WhenEnabled() + { + await using var application = new CdnOriginTestApplication(TestOutput, new Dictionary() + { + ["CdnOrigin:Compression:Enabled"] = "true" + }); + + using var client = application.CreateClient(); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/styles/site.css"); + request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("br")); + using var response = await client.SendAsync(request); + + Assert.Contains("br", response.Content.Headers.ContentEncoding); + Assert.Contains("Accept-Encoding", response.Headers.Vary); + } + + [Fact] + public async Task Get_ShouldNotCompressPreCompressedContent_WhenEnabled() + { + await using var application = new CdnOriginTestApplication(TestOutput, new Dictionary() + { + ["CdnOrigin:Compression:Enabled"] = "true" + }); + + using var client = application.CreateClient(); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/logo.png"); + request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("br")); + using var response = await client.SendAsync(request); + + Assert.Empty(response.Content.Headers.ContentEncoding); + } +} diff --git a/test/Codebelt.Cdn.Origin.FunctionalTests/CorsTest.cs b/test/Codebelt.Cdn.Origin.FunctionalTests/CorsTest.cs new file mode 100644 index 0000000..d924329 --- /dev/null +++ b/test/Codebelt.Cdn.Origin.FunctionalTests/CorsTest.cs @@ -0,0 +1,145 @@ +using System.Net; +using System.Net.Http; +using Codebelt.Extensions.Xunit; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace Codebelt.Cdn.Origin; + +public class CorsTest : Test +{ + public CorsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task Get_ShouldAllowAnyOrigin_InPublicMode() + { + using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/styles/site.css"); + request.Headers.Add("Origin", "https://consumer.example"); + using var response = await client.SendAsync(request); + + Assert.True(response.Headers.TryGetValues("Access-Control-Allow-Origin", out var origin)); + Assert.Equal("*", string.Join(string.Empty, origin)); + Assert.True(response.Headers.TryGetValues("Cross-Origin-Resource-Policy", out var corp)); + Assert.Equal("cross-origin", string.Join(string.Empty, corp)); + } + + [Fact] + public async Task Get_ShouldEchoAllowedOrigin_InRestrictedMode() + { + await using var application = new CdnOriginTestApplication(TestOutput, new Dictionary() + { + ["CdnOrigin:Cors:AllowedOrigins:0"] = "https://allowed.example" + }); + + using var client = application.CreateClient(); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/styles/site.css"); + request.Headers.Add("Origin", "https://allowed.example"); + using var response = await client.SendAsync(request); + + Assert.True(response.Headers.TryGetValues("Access-Control-Allow-Origin", out var origin)); + Assert.Equal("https://allowed.example", string.Join(string.Empty, origin)); + } + + [Fact] + public async Task Get_ShouldNotAllowDisallowedOrigin_InRestrictedMode() + { + await using var application = new CdnOriginTestApplication(TestOutput, new Dictionary() + { + ["CdnOrigin:Cors:AllowedOrigins:0"] = "https://allowed.example" + }); + using var client = application.CreateClient(); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/styles/site.css"); + request.Headers.Add("Origin", "https://evil.example"); + using var response = await client.SendAsync(request); + + Assert.False(response.Headers.Contains("Access-Control-Allow-Origin")); + } + + [Fact] + public async Task Options_ShouldHandlePreflight() + { + await using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var request = new HttpRequestMessage(HttpMethod.Options, "/styles/site.css"); + request.Headers.Add("Origin", "https://consumer.example"); + request.Headers.Add("Access-Control-Request-Method", "GET"); + using var response = await client.SendAsync(request); + + Assert.True(response.Headers.Contains("Access-Control-Allow-Origin")); + } + + [Fact] + public async Task Get_ShouldEmitTimingAllowOrigin_WhenEnabled() + { + await using var application = new CdnOriginTestApplication(TestOutput, new Dictionary() + { + ["CdnOrigin:Cors:TimingAllowOrigin"] = "true" + }); + using var client = application.CreateClient(); + + using var response = await client.GetAsync("/styles/site.css"); + + Assert.True(response.Headers.TryGetValues("Timing-Allow-Origin", out var timing)); + Assert.Equal("*", string.Join(string.Empty, timing)); + } + + [Fact] + public async Task Get_ShouldEmitTimingAllowOrigin_WithRestrictedOrigins() + { + await using var application = new CdnOriginTestApplication(TestOutput, new Dictionary() + { + ["CdnOrigin:Cors:AllowedOrigins:0"] = "https://a.example", + ["CdnOrigin:Cors:TimingAllowOrigin"] = "true" + }); + + using var client = application.CreateClient(); + + using var response = await client.GetAsync("/styles/site.css"); + + Assert.True(response.Headers.TryGetValues("Timing-Allow-Origin", out var timing)); + Assert.Equal("https://a.example", string.Join(string.Empty, timing)); + } + + [Fact] + public async Task Get_ShouldOmitCorsHeaders_WhenDisabled() + { + await using var application = new CdnOriginTestApplication(TestOutput, new Dictionary() + { + ["CdnOrigin:Cors:Enabled"] = "false" + }); + + using var client = application.CreateClient(); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/styles/site.css"); + request.Headers.Add("Origin", "https://consumer.example"); + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("Access-Control-Allow-Origin")); + } + + [Fact] + public async Task Get_ShouldOmitCrossOriginResourcePolicy_WhenNotConfigured() + { + await using var application = new CdnOriginTestApplication(TestOutput, new Dictionary() + { + ["CdnOrigin:Cors:CrossOriginResourcePolicy"] = "" + }); + + using var client = application.CreateClient(); + + using var response = await client.GetAsync("/styles/site.css"); + + Assert.False(response.Headers.Contains("Cross-Origin-Resource-Policy")); + } +} diff --git a/test/Codebelt.Cdn.Origin.FunctionalTests/HealthTest.cs b/test/Codebelt.Cdn.Origin.FunctionalTests/HealthTest.cs new file mode 100644 index 0000000..84a06ff --- /dev/null +++ b/test/Codebelt.Cdn.Origin.FunctionalTests/HealthTest.cs @@ -0,0 +1,76 @@ +using System.Net; +using Codebelt.Extensions.Xunit; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace Codebelt.Cdn.Origin; + +public class HealthTest : Test +{ + public HealthTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task Live_ShouldReturnHealthy_WithNoStore() + { + await using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var response = await client.GetAsync("/health/live"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(response.Headers.CacheControl?.NoStore); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Ready_ShouldReturnHealthy_WhenContentRootAvailable() + { + await using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var response = await client.GetAsync("/health/ready"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(response.Headers.CacheControl?.NoStore); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Ready_ShouldReturnUnhealthy_WhenContentRootDisappears() + { + await using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using (var healthy = await client.GetAsync("/health/ready")) + { + Assert.Equal(HttpStatusCode.OK, healthy.StatusCode); + } + + Directory.Delete(application.Content.Root, recursive: true); + + using var response = await client.GetAsync("/health/ready"); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + Assert.True(response.Headers.CacheControl?.NoStore); + Assert.Equal("Unhealthy", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Health_ShouldBeUnmapped_WhenDisabled() + { + await using var application = new CdnOriginTestApplication(TestOutput, new Dictionary() + { + ["CdnOrigin:Health:Enabled"] = "false" + }); + + using var client = application.CreateClient(); + + using var response = await client.GetAsync("/health/live"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/test/Codebelt.Cdn.Origin.FunctionalTests/HttpCachingTest.cs b/test/Codebelt.Cdn.Origin.FunctionalTests/HttpCachingTest.cs new file mode 100644 index 0000000..b5f22e2 --- /dev/null +++ b/test/Codebelt.Cdn.Origin.FunctionalTests/HttpCachingTest.cs @@ -0,0 +1,177 @@ +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using Codebelt.Extensions.Xunit; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace Codebelt.Cdn.Origin; + +public class HttpCachingTest : Test +{ + public HttpCachingTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task Get_ShouldEmitRevalidateCacheControl() + { + await using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var response = await client.GetAsync("/book.txt"); + + Assert.True(response.Headers.TryGetValues("Cache-Control", out var values)); + var cacheControl = string.Join(", ", values); + Assert.Contains("public", cacheControl); + Assert.Contains("max-age=43200", cacheControl); + Assert.Contains("s-maxage=604800", cacheControl); + Assert.Contains("must-revalidate", cacheControl); + } + + [Fact] + public async Task Get_ShouldEmitImmutableCacheControl_ForConfiguredPrefix() + { + await using var application = new CdnOriginTestApplication(TestOutput, new Dictionary + { + ["CdnOrigin:Cache:ImmutablePathPrefixes:0"] = "/assets/" + }); + + using var client = application.CreateClient(); + + using var response = await client.GetAsync("/assets/app.4f2c.js"); + + Assert.True(response.Headers.TryGetValues("Cache-Control", out var values)); + var cacheControl = string.Join(", ", values); + Assert.Contains("immutable", cacheControl); + Assert.Contains("max-age=31536000", cacheControl); + } + + [Fact] + public async Task Get_ShouldEmitImmutableCacheControl_WhenPathUsesDifferentCasingThanConfiguredPrefix() + { + await using var application = new CdnOriginTestApplication(TestOutput, new Dictionary + { + ["CdnOrigin:Cache:ImmutablePathPrefixes:0"] = "/assets/" + }); + + using var client = application.CreateClient(); + + using var response = await client.GetAsync("/ASSETS/app.4f2c.js"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(response.Headers.TryGetValues("Cache-Control", out var values)); + var cacheControl = string.Join(", ", values); + Assert.Contains("immutable", cacheControl); + Assert.Contains("max-age=31536000", cacheControl); + } + + [Fact] + public async Task Get_ShouldEmitEntityTagAndLastModified() + { + await using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var response = await client.GetAsync("/book.txt"); + + Assert.NotNull(response.Headers.ETag); + Assert.NotNull(response.Content.Headers.LastModified); + } + + [Fact] + public async Task Get_ShouldReturnNotModified_ForMatchingIfNoneMatch() + { + await using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var first = await client.GetAsync("/book.txt"); + var etag = first.Headers.ETag!; + + using var request = new HttpRequestMessage(HttpMethod.Get, "/book.txt"); + request.Headers.IfNoneMatch.Add(etag); + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotModified, response.StatusCode); + Assert.Equal(string.Empty, await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Get_ShouldReturnNotModified_ForIfModifiedSince() + { + await using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var first = await client.GetAsync("/book.txt"); + var lastModified = first.Content.Headers.LastModified!.Value; + + using var request = new HttpRequestMessage(HttpMethod.Get, "/book.txt"); + request.Headers.IfModifiedSince = lastModified; + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotModified, response.StatusCode); + } + + [Fact] + public async Task Get_ShouldReturnPartialContent_ForRangeRequest() + { + await using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/book.txt"); + request.Headers.Range = new RangeHeaderValue(0, 99); + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); + Assert.Equal(100, response.Content.Headers.ContentLength); + Assert.Equal(1024, response.Content.Headers.ContentRange?.Length); + Assert.Equal(new string('A', 100), await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Get_ShouldReturnRangeNotSatisfiable_ForUnsatisfiableRange() + { + await using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/book.txt"); + request.Headers.Range = new RangeHeaderValue(100000, 200000); + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.RequestedRangeNotSatisfiable, response.StatusCode); + } + + [Fact] + public async Task Get_ShouldReturnPartialContent_ForIfRangeWithMatchingEtag() + { + await using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var first = await client.GetAsync("/book.txt"); + var etag = first.Headers.ETag!; + + using var request = new HttpRequestMessage(HttpMethod.Get, "/book.txt"); + request.Headers.Range = new RangeHeaderValue(0, 9); + request.Headers.IfRange = new RangeConditionHeaderValue(etag); + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); + Assert.Equal(10, response.Content.Headers.ContentLength); + } + + [Fact] + public async Task Get_ShouldReturnFullContent_ForIfRangeWithStaleEtag() + { + await using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/book.txt"); + request.Headers.Range = new RangeHeaderValue(0, 9); + request.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"stale\"")); + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(1024, response.Content.Headers.ContentLength); + } +} diff --git a/test/Codebelt.Cdn.Origin.FunctionalTests/LegacyCaseInsensitivePhysicalFileProvider.cs b/test/Codebelt.Cdn.Origin.FunctionalTests/LegacyCaseInsensitivePhysicalFileProvider.cs new file mode 100644 index 0000000..bda51ad --- /dev/null +++ b/test/Codebelt.Cdn.Origin.FunctionalTests/LegacyCaseInsensitivePhysicalFileProvider.cs @@ -0,0 +1,86 @@ +using System.Collections.Concurrent; +using System.Linq; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.FileProviders.Physical; +using Microsoft.Extensions.Primitives; + +namespace Codebelt.Cdn.Origin; + +/// +/// Test-only copy of the pre-2.0 path resolver used as the compatibility comparison baseline. +/// +internal sealed class LegacyCaseInsensitivePhysicalFileProvider : IFileProvider, IDisposable +{ + private readonly PhysicalFileProvider _provider; + private readonly ConcurrentDictionary _paths = new(StringComparer.OrdinalIgnoreCase); + + public LegacyCaseInsensitivePhysicalFileProvider(string root, ExclusionFilters filters = ExclusionFilters.Sensitive) + { + _provider = new PhysicalFileProvider(root, filters); + } + + public IFileInfo GetFileInfo(string subpath) + { + return _provider.GetFileInfo(GetActualFilePath(subpath)); + } + + public IDirectoryContents GetDirectoryContents(string subpath) + { + return _provider.GetDirectoryContents(GetActualFilePath(subpath)); + } + + public IChangeToken Watch(string filter) + { + return _provider.Watch(filter); + } + + public void Dispose() + { + _provider.Dispose(); + } + + private string GetActualFilePath(string path) + { + if (_paths.TryGetValue(path, out string? cachedPath)) + { + return cachedPath; + } + + string currentPath = _provider.Root; + string[] segments = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + + for (int i = 0; i < segments.Length; i++) + { + string part = segments[i]; + bool last = i == segments.Length - 1; + + if (part.Equals("~", StringComparison.Ordinal)) + { + continue; + } + + part = last ? GetFileName(part, currentPath) : GetDirectoryName(part, currentPath); + if (part is null) + { + return path; + } + + currentPath = Path.Combine(currentPath, part); + segments[i] = part; + } + + string actualPath = string.Join(Path.DirectorySeparatorChar, segments); + _paths.TryAdd(path, actualPath); + return actualPath; + } + + private static string? GetFileName(string part, string folder) + { + return new DirectoryInfo(folder).GetFiles().FirstOrDefault(file => file.Name.Equals(part, StringComparison.OrdinalIgnoreCase))?.Name; + } + + private static string? GetDirectoryName(string part, string folder) + { + return new DirectoryInfo(folder).GetDirectories().FirstOrDefault(directory => directory.Name.Equals(part, StringComparison.OrdinalIgnoreCase))?.Name; + } +} diff --git a/test/Codebelt.Cdn.Origin.FunctionalTests/StartupValidationTest.cs b/test/Codebelt.Cdn.Origin.FunctionalTests/StartupValidationTest.cs new file mode 100644 index 0000000..8b04573 --- /dev/null +++ b/test/Codebelt.Cdn.Origin.FunctionalTests/StartupValidationTest.cs @@ -0,0 +1,50 @@ +using Codebelt.Extensions.Xunit; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Xunit; + +namespace Codebelt.Cdn.Origin; + +public class StartupValidationTest : Test +{ + public StartupValidationTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task Startup_ShouldFail_WhenContentRootDoesNotExist() + { + var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + await using var application = new CdnOriginTestApplication(TestOutput, new Dictionary + { + ["CdnOrigin:ContentRoot"] = missing + }); + + + var exception = Assert.ThrowsAny(() => application.CreateClient()); + + Assert.Contains("content root", FlattenMessages(exception), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Startup_ShouldFail_WhenWildcardCombinedWithCredentials() + { + await using var application = new CdnOriginTestApplication(TestOutput, new Dictionary + { + ["CdnOrigin:Cors:AllowedOrigins:0"] = "*", + ["CdnOrigin:Cors:AllowCredentials"] = "true" + }); + + Assert.ThrowsAny(() => application.CreateClient()); + } + + private static string FlattenMessages(Exception exception) + { + var messages = new List(); + for (Exception? current = exception; current is not null; current = current.InnerException) + { + messages.Add(current.Message); + } + + return string.Join(" | ", messages); + } +} diff --git a/test/Codebelt.Cdn.Origin.FunctionalTests/StaticContentTest.cs b/test/Codebelt.Cdn.Origin.FunctionalTests/StaticContentTest.cs new file mode 100644 index 0000000..5b030e9 --- /dev/null +++ b/test/Codebelt.Cdn.Origin.FunctionalTests/StaticContentTest.cs @@ -0,0 +1,174 @@ +using System.Net; +using System.Net.Http; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Codebelt.Cdn.Origin; + +public class StaticContentTest : Test +{ + public StaticContentTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task Get_ShouldServeExistingFile() + { + await using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var response = await client.GetAsync("/styles/site.css"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/css", response.Content.Headers.ContentType?.MediaType); + Assert.Equal("body{color:red}", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Get_ShouldReturnNotFound_ForMissingFile() + { + await using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var response = await client.GetAsync("/does-not-exist.css"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Get_ShouldServeStandardDefaultDocument_ForRootRequest() + { + await using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var response = await client.GetAsync("/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("index", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Get_ShouldServeConfiguredDefaultDocument() + { + await using var application = new CdnOriginTestApplication(TestOutput, new Dictionary + { + ["CdnOrigin:DefaultDocuments:0"] = "index.html" + }); + using var client = application.CreateClient(); + + using var response = await client.GetAsync("/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("index", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Head_ShouldReturnHeadersWithoutBody() + { + await using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var request = new HttpRequestMessage(HttpMethod.Head, "/book.txt"); + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(1024, response.Content.Headers.ContentLength); + Assert.Equal(string.Empty, await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Get_ShouldRejectUnknownContentType_ByDefault() + { + await using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var response = await client.GetAsync("/notes.unknownext"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Get_ShouldServeUnknownContentType_WhenConfigured() + { + await using var application = new CdnOriginTestApplication(TestOutput, new Dictionary + { + ["CdnOrigin:ContentTypes:ServeUnknownFileTypes"] = "true", + ["CdnOrigin:ContentTypes:DefaultContentType"] = "application/octet-stream" + }); + using var client = application.CreateClient(); + + using var response = await client.GetAsync("/notes.unknownext"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/octet-stream", response.Content.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task Get_ShouldServeCustomMimeMapping() + { + await using var application = new CdnOriginTestApplication(TestOutput, new Dictionary + { + ["CdnOrigin:ContentTypes:Mappings:.unknownext"] = "application/x-note" + }); + using var client = application.CreateClient(); + + using var response = await client.GetAsync("/notes.unknownext"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/x-note", response.Content.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task Get_ShouldBlockPathTraversal() + { + await using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var response = await client.GetAsync("/%2e%2e/%2e%2e/appsettings.json"); + + Assert.True(response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.BadRequest); + Assert.DoesNotContain("CdnOrigin", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Get_ShouldServeCaseInsensitivePath_OnEveryOperatingSystem() + { + await using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var exact = await client.GetAsync("/styles/site.css"); + using var wrongCase = await client.GetAsync("/styles/SITE.CSS"); + + Assert.Equal(HttpStatusCode.OK, exact.StatusCode); + + Assert.Equal(HttpStatusCode.OK, wrongCase.StatusCode); + } + + [Theory] + [InlineData("POST")] + [InlineData("DELETE")] + public async Task UnsupportedMethod_ShouldReturnMethodNotAllowed_ForExistingFile(string method) + { + await using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var request = new HttpRequestMessage(new HttpMethod(method), "/styles/site.css"); + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + Assert.Contains("GET", response.Content.Headers.Allow); + Assert.Contains("HEAD", response.Content.Headers.Allow); + } + + [Fact] + public async Task UnsupportedMethod_ShouldReturnNotFound_ForMissingFile() + { + await using var application = new CdnOriginTestApplication(TestOutput); + using var client = application.CreateClient(); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/missing.css"); + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/test/Codebelt.Cdn.Origin.FunctionalTests/TempContent.cs b/test/Codebelt.Cdn.Origin.FunctionalTests/TempContent.cs new file mode 100644 index 0000000..1446636 --- /dev/null +++ b/test/Codebelt.Cdn.Origin.FunctionalTests/TempContent.cs @@ -0,0 +1,52 @@ +namespace Codebelt.Cdn.Origin; + +/// +/// Creates an isolated, deterministic temporary static content directory for functional tests. +/// +public sealed class TempContent : IDisposable +{ + public static readonly DateTimeOffset KnownLastModified = new(2024, 1, 2, 3, 4, 5, TimeSpan.Zero); + + public TempContent() + { + Root = Path.Combine(Path.GetTempPath(), "cdn-origin-func", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Root); + Directory.CreateDirectory(Path.Combine(Root, "styles")); + Directory.CreateDirectory(Path.Combine(Root, "assets")); + + WriteText("index.html", "index"); + WriteText("styles/site.css", "body{color:red}"); + WriteText("assets/app.4f2c.js", "console.log('immutable');"); + WriteText("notes.unknownext", "unknown content type payload"); + WriteText("book.txt", new string('A', 1024)); + File.WriteAllBytes(Path.Combine(Root, "logo.png"), new byte[512]); + + foreach (var file in Directory.EnumerateFiles(Root, "*", SearchOption.AllDirectories)) + { + File.SetLastWriteTimeUtc(file, KnownLastModified.UtcDateTime); + } + } + + public string Root { get; } + + private void WriteText(string relativePath, string content) + { + var path = Path.Combine(Root, relativePath.Replace('/', Path.DirectorySeparatorChar)); + File.WriteAllText(path, content); + } + + public void Dispose() + { + try + { + if (Directory.Exists(Root)) + { + Directory.Delete(Root, recursive: true); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best-effort cleanup. + } + } +} diff --git a/test/Codebelt.Cdn.Origin.Tests/Codebelt.Cdn.Origin.Tests.csproj b/test/Codebelt.Cdn.Origin.Tests/Codebelt.Cdn.Origin.Tests.csproj new file mode 100644 index 0000000..b47c1ec --- /dev/null +++ b/test/Codebelt.Cdn.Origin.Tests/Codebelt.Cdn.Origin.Tests.csproj @@ -0,0 +1,11 @@ + + + + Codebelt.Cdn.Origin + + + + + + + diff --git a/test/Codebelt.Cdn.Origin.Tests/Configuration/CdnOriginOptionsValidatorTest.cs b/test/Codebelt.Cdn.Origin.Tests/Configuration/CdnOriginOptionsValidatorTest.cs new file mode 100644 index 0000000..45cb5ab --- /dev/null +++ b/test/Codebelt.Cdn.Origin.Tests/Configuration/CdnOriginOptionsValidatorTest.cs @@ -0,0 +1,371 @@ +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Codebelt.Cdn.Origin.Configuration; + +public class CdnOriginOptionsValidatorTest : Test +{ + private readonly CdnOriginOptionsValidator _sut = new(); + + public CdnOriginOptionsValidatorTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Validate_ShouldThrow_WhenOptionsAreNull() + { + Assert.Throws(() => _sut.Validate(null, null!)); + } + + [Fact] + public void Validate_ShouldSucceed_ForDefaultOptions() + { + var result = _sut.Validate(null, new CdnOriginOptions()); + + Assert.True(result.Succeeded); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_ShouldFail_WhenContentRootIsBlank(string? contentRoot) + { + var options = new CdnOriginOptions { ContentRoot = contentRoot! }; + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("ContentRoot", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldFail_WhenDefaultDocumentIsBlank() + { + var options = new CdnOriginOptions { DefaultDocuments = ["index.html", " "] }; + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("DefaultDocuments", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldIgnoreCors_WhenDisabled() + { + var options = new CdnOriginOptions(); + options.Cors.Enabled = false; + options.Cors.AllowCredentials = true; + options.Cors.AllowedOrigins = [CorsPolicyOptions.AnyOrigin]; + + var result = _sut.Validate(null, options); + + Assert.True(result.Succeeded); + } + + [Fact] + public void Validate_ShouldSucceed_WithSpecificOrigins() + { + var options = new CdnOriginOptions(); + options.Cors.AllowedOrigins = ["https://example.com"]; + + var result = _sut.Validate(null, options); + + Assert.True(result.Succeeded); + } + + [Fact] + public void Validate_ShouldFail_WhenWildcardCombinedWithCredentials() + { + var options = new CdnOriginOptions(); + options.Cors.AllowedOrigins = [CorsPolicyOptions.AnyOrigin]; + options.Cors.AllowCredentials = true; + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("AllowCredentials", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldFail_WhenWildcardCombinedWithExplicitOrigins() + { + var options = new CdnOriginOptions(); + options.Cors.AllowedOrigins = [CorsPolicyOptions.AnyOrigin, "https://example.com"]; + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("explicit origins", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldFail_WhenImmutablePrefixDoesNotStartWithSlash() + { + var options = new CdnOriginOptions(); + options.Cache.ImmutablePathPrefixes.Add("assets/"); + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("ImmutablePathPrefixes", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldFail_WhenImmutablePrefixIsBlank() + { + var options = new CdnOriginOptions(); + options.Cache.ImmutablePathPrefixes.Add(" "); + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("ImmutablePathPrefixes", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldFail_WhenNoStoreCombinedWithSharedMaxAgeOnly() + { + var options = new CdnOriginOptions(); + options.Cache.Revalidate.NoStore = true; + options.Cache.Revalidate.MaxAge = null; + options.Cache.Revalidate.SharedMaxAge = TimeSpan.FromHours(1); + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("no-store", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldSucceed_WhenNoStoreWithoutDurations() + { + var options = new CdnOriginOptions(); + options.Cache.Revalidate.NoStore = true; + options.Cache.Revalidate.MaxAge = null; + options.Cache.Revalidate.SharedMaxAge = null; + options.Cache.Revalidate.MustRevalidate = false; + + var result = _sut.Validate(null, options); + + Assert.True(result.Succeeded); + } + + [Fact] + public void Validate_ShouldFail_WhenNoStoreCombinedWithMaxAge() + { + var options = new CdnOriginOptions(); + options.Cache.Revalidate.NoStore = true; + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("no-store", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldFail_WhenImmutableCombinedWithMustRevalidate() + { + var options = new CdnOriginOptions(); + options.Cache.Immutable.MustRevalidate = true; + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("must-revalidate", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldFail_WhenImmutableCombinedWithNoCache() + { + var options = new CdnOriginOptions(); + options.Cache.Immutable.NoCache = true; + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("no-cache", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldFail_WhenMaxAgeIsNegative() + { + var options = new CdnOriginOptions(); + options.Cache.Revalidate.MaxAge = TimeSpan.FromSeconds(-1); + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("MaxAge", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldFail_WhenSharedMaxAgeIsNegative() + { + var options = new CdnOriginOptions(); + options.Cache.Revalidate.SharedMaxAge = TimeSpan.FromSeconds(-1); + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("SharedMaxAge", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldFail_WhenStaleWhileRevalidateIsNegative() + { + var options = new CdnOriginOptions(); + options.Cache.Revalidate.StaleWhileRevalidate = TimeSpan.FromSeconds(-1); + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("StaleWhileRevalidate", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldFail_WhenStaleIfErrorIsNegative() + { + var options = new CdnOriginOptions(); + options.Cache.Revalidate.StaleIfError = TimeSpan.FromSeconds(-1); + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("StaleIfError", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldFail_WhenServeUnknownFileTypesWithoutDefaultContentType() + { + var options = new CdnOriginOptions(); + options.ContentTypes.ServeUnknownFileTypes = true; + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("DefaultContentType", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldSucceed_WhenServeUnknownFileTypesWithDefaultContentType() + { + var options = new CdnOriginOptions(); + options.ContentTypes.ServeUnknownFileTypes = true; + options.ContentTypes.DefaultContentType = "application/octet-stream"; + + var result = _sut.Validate(null, options); + + Assert.True(result.Succeeded); + } + + [Fact] + public void Validate_ShouldFail_WhenMappingKeyHasNoLeadingDot() + { + var options = new CdnOriginOptions(); + options.ContentTypes.Mappings["foo"] = "application/x-foo"; + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("must start with '.'", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldFail_WhenMappingKeyIsBlank() + { + var options = new CdnOriginOptions(); + options.ContentTypes.Mappings[" "] = "application/x-foo"; + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("must start with '.'", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldFail_WhenMappingValueIsBlank() + { + var options = new CdnOriginOptions(); + options.ContentTypes.Mappings[".foo"] = " "; + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("MIME value", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldIgnoreHealth_WhenDisabled() + { + var options = new CdnOriginOptions(); + options.Health.Enabled = false; + options.Health.LivePath = "live"; + options.Health.ReadyPath = "live"; + + var result = _sut.Validate(null, options); + + Assert.True(result.Succeeded); + } + + [Fact] + public void Validate_ShouldFail_WhenLivePathDoesNotStartWithSlash() + { + var options = new CdnOriginOptions(); + options.Health.LivePath = "live"; + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("LivePath", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldFail_WhenLivePathIsBlank() + { + var options = new CdnOriginOptions(); + options.Health.LivePath = " "; + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("LivePath", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldFail_WhenReadyPathDoesNotStartWithSlash() + { + var options = new CdnOriginOptions(); + options.Health.ReadyPath = "ready"; + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("ReadyPath", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldFail_WhenReadyPathIsBlank() + { + var options = new CdnOriginOptions(); + options.Health.ReadyPath = " "; + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("ReadyPath", result.FailureMessage); + } + + [Fact] + public void Validate_ShouldFail_WhenLiveAndReadyPathsAreEqual() + { + var options = new CdnOriginOptions(); + options.Health.LivePath = "/health"; + options.Health.ReadyPath = "/health"; + + var result = _sut.Validate(null, options); + + Assert.True(result.Failed); + Assert.Contains("must differ", result.FailureMessage); + } +} diff --git a/test/Codebelt.Cdn.Origin.Tests/Hosting/CachePolicyResolverTest.cs b/test/Codebelt.Cdn.Origin.Tests/Hosting/CachePolicyResolverTest.cs new file mode 100644 index 0000000..40faa28 --- /dev/null +++ b/test/Codebelt.Cdn.Origin.Tests/Hosting/CachePolicyResolverTest.cs @@ -0,0 +1,167 @@ +using Codebelt.Cdn.Origin.Configuration; +using Codebelt.Extensions.Xunit; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace Codebelt.Cdn.Origin.Hosting; + +public class CachePolicyResolverTest : Test +{ + public CachePolicyResolverTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void BuildCacheControl_ShouldThrow_WhenProfileIsNull() + { + Assert.Throws(() => CachePolicyResolver.BuildCacheControl(null!)); + } + + [Fact] + public void BuildCacheControl_ShouldRenderRevalidateDefaults() + { + var profile = new CacheProfileOptions + { + Public = true, + MaxAge = TimeSpan.FromHours(12), + SharedMaxAge = TimeSpan.FromDays(7), + MustRevalidate = true + }; + + var header = CachePolicyResolver.BuildCacheControl(profile); + + Assert.Equal("public, max-age=43200, s-maxage=604800, must-revalidate", header); + TestOutput.WriteLine(header); + } + + [Fact] + public void BuildCacheControl_ShouldRenderImmutableDefaults() + { + var profile = new CacheProfileOptions + { + Public = true, + MaxAge = TimeSpan.FromDays(365), + Immutable = true + }; + + var header = CachePolicyResolver.BuildCacheControl(profile); + + Assert.Equal("public, max-age=31536000, immutable", header); + } + + [Fact] + public void BuildCacheControl_ShouldRenderNoStore_AndIgnoreOtherDirectives() + { + var profile = new CacheProfileOptions + { + NoStore = true, + Public = true, + MaxAge = TimeSpan.FromHours(1) + }; + + var header = CachePolicyResolver.BuildCacheControl(profile); + + Assert.Equal("no-store", header); + } + + [Fact] + public void BuildCacheControl_ShouldRenderPrivate_WhenNotPublic() + { + var profile = new CacheProfileOptions { Public = false }; + + var header = CachePolicyResolver.BuildCacheControl(profile); + + Assert.Equal("private", header); + } + + [Fact] + public void BuildCacheControl_ShouldRenderAllOptionalDirectives() + { + var profile = new CacheProfileOptions + { + Public = false, + NoCache = true, + MaxAge = TimeSpan.FromMinutes(5), + SharedMaxAge = TimeSpan.FromMinutes(10), + MustRevalidate = true, + Immutable = true, + StaleWhileRevalidate = TimeSpan.FromSeconds(30), + StaleIfError = TimeSpan.FromSeconds(60), + NoTransform = true + }; + + var header = CachePolicyResolver.BuildCacheControl(profile); + + Assert.Equal("private, no-cache, max-age=300, s-maxage=600, must-revalidate, immutable, stale-while-revalidate=30, stale-if-error=60, no-transform", header); + } + + [Fact] + public void BuildCacheControl_ShouldRenderPublicOnly_WhenNoDurations() + { + var header = CachePolicyResolver.BuildCacheControl(new CacheProfileOptions()); + + Assert.Equal("public", header); + } + + [Fact] + public void Constructor_ShouldThrow_WhenOptionsAreNull() + { + Assert.Throws(() => new CachePolicyResolver(null!)); + } + + [Fact] + public void Resolve_ShouldReturnRevalidate_WhenNoImmutablePrefixConfigured() + { + var resolver = new CachePolicyResolver(new CdnOriginOptions()); + + var header = resolver.Resolve("/index.html"); + + Assert.Equal(CachePolicyResolver.BuildCacheControl(new CdnOriginOptions().Cache.Revalidate), header); + } + + [Fact] + public void Resolve_ShouldReturnImmutable_WhenPathMatchesImmutablePrefix() + { + var options = new CdnOriginOptions(); + options.Cache.ImmutablePathPrefixes.Add("/assets/"); + + var resolver = new CachePolicyResolver(options); + + Assert.Equal(CachePolicyResolver.BuildCacheControl(options.Cache.Immutable), resolver.Resolve("/assets/app.4f2c.js")); + Assert.Equal(CachePolicyResolver.BuildCacheControl(options.Cache.Revalidate), resolver.Resolve("/pages/home.html")); + } + + [Fact] + public void Resolve_ShouldReturnImmutable_WhenPathMatchesImmutablePrefixWithDifferentCasing() + { + var options = new CdnOriginOptions(); + options.Cache.ImmutablePathPrefixes.Add("/assets/"); + + var resolver = new CachePolicyResolver(options); + + Assert.Equal(CachePolicyResolver.BuildCacheControl(options.Cache.Immutable), resolver.Resolve("/ASSETS/app.4f2c.js")); + } + + [Fact] + public void Resolve_ShouldIgnoreWhitespacePrefixes() + { + var options = new CdnOriginOptions(); + options.Cache.ImmutablePathPrefixes.Add(" "); + options.Cache.ImmutablePathPrefixes.Add("/img/"); + + var resolver = new CachePolicyResolver(options); + + Assert.Equal(CachePolicyResolver.BuildCacheControl(options.Cache.Immutable), resolver.Resolve("/img/logo.png")); + } + + [Fact] + public void Resolve_ShouldReturnRevalidate_WhenPathHasNoValue() + { + var options = new CdnOriginOptions(); + options.Cache.ImmutablePathPrefixes.Add("/assets/"); + + var resolver = new CachePolicyResolver(options); + + Assert.Equal(CachePolicyResolver.BuildCacheControl(options.Cache.Revalidate), resolver.Resolve(new PathString())); + } +} diff --git a/test/Codebelt.Cdn.Origin.Tests/Hosting/CdnOriginServiceCollectionExtensionsTest.cs b/test/Codebelt.Cdn.Origin.Tests/Hosting/CdnOriginServiceCollectionExtensionsTest.cs new file mode 100644 index 0000000..ac0f4e2 --- /dev/null +++ b/test/Codebelt.Cdn.Origin.Tests/Hosting/CdnOriginServiceCollectionExtensionsTest.cs @@ -0,0 +1,108 @@ +using System.IO.Compression; +using Codebelt.Cdn.Origin.Configuration; +using Codebelt.Extensions.Xunit; +using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.AspNetCore.ResponseCompression; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Codebelt.Cdn.Origin.Hosting; + +public class CdnOriginServiceCollectionExtensionsTest : Test +{ + public CdnOriginServiceCollectionExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + private static IConfiguration BuildConfiguration(IDictionary values) + { + return new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + } + + [Fact] + public void AddCdnOrigin_ShouldThrow_WhenServicesAreNull() + { + Assert.Throws(() => ((IServiceCollection)null!).AddCdnOrigin(BuildConfiguration(new Dictionary()))); + } + + [Fact] + public void AddCdnOrigin_ShouldThrow_WhenConfigurationIsNull() + { + Assert.Throws(() => new ServiceCollection().AddCdnOrigin(null!)); + } + + [Fact] + public void AddCdnOrigin_ShouldRegisterServices() + { + var services = new ServiceCollection(); + + services.AddCdnOrigin(BuildConfiguration(new Dictionary())); + + Assert.Contains(services, d => d.ServiceType == typeof(IValidateOptions)); + Assert.Contains(services, d => d.ServiceType == typeof(CachePolicyResolver)); + Assert.Contains(services, d => d.ServiceType == typeof(ICorsService)); + Assert.Contains(services, d => d.ServiceType == typeof(IResponseCompressionProvider)); + Assert.Contains(services, d => d.ServiceType == typeof(HealthCheckService)); + } + + [Fact] + public void AddCdnOrigin_ShouldBuildWildcardCorsPolicy_ForPublicMode() + { + var services = new ServiceCollection(); + services.AddCdnOrigin(BuildConfiguration(new Dictionary())); + + using var provider = services.BuildServiceProvider(); + var policy = provider.GetRequiredService>().Value.GetPolicy(CorsPolicyOptions.PolicyName); + + Assert.NotNull(policy); + Assert.True(policy!.AllowAnyOrigin); + Assert.False(policy.SupportsCredentials); + Assert.Empty(policy.ExposedHeaders); + Assert.Contains("GET", policy.Methods); + } + + [Fact] + public void AddCdnOrigin_ShouldBuildRestrictedCorsPolicy_WithCredentialsAndExposedHeaders() + { + var services = new ServiceCollection(); + services.AddCdnOrigin(BuildConfiguration(new Dictionary + { + ["CdnOrigin:Cors:AllowedOrigins:0"] = "https://a.example", + ["CdnOrigin:Cors:AllowedOrigins:1"] = "https://b.example", + ["CdnOrigin:Cors:AllowCredentials"] = "true", + ["CdnOrigin:Cors:ExposedHeaders:0"] = "X-Custom" + })); + + using var provider = services.BuildServiceProvider(); + var policy = provider.GetRequiredService>().Value.GetPolicy(CorsPolicyOptions.PolicyName); + + Assert.NotNull(policy); + Assert.False(policy!.AllowAnyOrigin); + Assert.Contains("https://a.example", policy.Origins); + Assert.Contains("https://b.example", policy.Origins); + Assert.True(policy.SupportsCredentials); + Assert.Contains("X-Custom", policy.ExposedHeaders); + } + + [Fact] + public void AddCdnOrigin_ShouldConfigureCompression() + { + var services = new ServiceCollection(); + services.AddCdnOrigin(BuildConfiguration(new Dictionary + { + ["CdnOrigin:Compression:AdditionalMimeTypes:0"] = "application/x-custom" + })); + + using var provider = services.BuildServiceProvider(); + var compression = provider.GetRequiredService>().Value; + + Assert.True(compression.EnableForHttps); + Assert.Contains("application/json", compression.MimeTypes); + Assert.Contains("application/x-custom", compression.MimeTypes); + Assert.Equal(CompressionLevel.Fastest, provider.GetRequiredService>().Value.Level); + Assert.Equal(CompressionLevel.Fastest, provider.GetRequiredService>().Value.Level); + } +} diff --git a/test/Codebelt.Cdn.Origin.Tests/Hosting/ContentRootHealthCheckTest.cs b/test/Codebelt.Cdn.Origin.Tests/Hosting/ContentRootHealthCheckTest.cs new file mode 100644 index 0000000..3c26668 --- /dev/null +++ b/test/Codebelt.Cdn.Origin.Tests/Hosting/ContentRootHealthCheckTest.cs @@ -0,0 +1,62 @@ +using Codebelt.Cdn.Origin.Configuration; +using Codebelt.Extensions.Xunit; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Codebelt.Cdn.Origin.Hosting; + +public class ContentRootHealthCheckTest : Test +{ + public ContentRootHealthCheckTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Constructor_ShouldThrow_WhenOptionsAccessorIsNull() + { + Assert.Throws(() => new ContentRootHealthCheck((IOptions)null!)); + } + + [Fact] + public void Constructor_ShouldThrow_WhenOptionsAreNull() + { + Assert.Throws(() => new ContentRootHealthCheck((CdnOriginOptions)null!, AppContext.BaseDirectory)); + } + + [Fact] + public async Task CheckHealthAsync_ShouldReturnHealthy_WhenContentRootIsValid() + { + using var content = new TempDirectory(); + using var application = new TempDirectory(); + var check = new ContentRootHealthCheck(new CdnOriginOptions { ContentRoot = content.Path }, application.Path); + + var result = await check.CheckHealthAsync(new HealthCheckContext()); + + Assert.Equal(HealthStatus.Healthy, result.Status); + TestOutput.WriteLine(result.Description); + } + + [Fact] + public async Task CheckHealthAsync_ShouldReturnUnhealthy_WhenContentRootIsMissing() + { + var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var check = new ContentRootHealthCheck(new CdnOriginOptions { ContentRoot = missing }, AppContext.BaseDirectory); + + var result = await check.CheckHealthAsync(new HealthCheckContext()); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("does not exist", result.Description); + } + + [Fact] + public async Task CheckHealthAsync_ShouldUseOptionsAccessor() + { + using var content = new TempDirectory(); + var check = new ContentRootHealthCheck(Options.Create(new CdnOriginOptions { ContentRoot = content.Path })); + + var result = await check.CheckHealthAsync(new HealthCheckContext()); + + Assert.Equal(HealthStatus.Healthy, result.Status); + } +} diff --git a/test/Codebelt.Cdn.Origin.Tests/Hosting/ContentRootValidatorTest.cs b/test/Codebelt.Cdn.Origin.Tests/Hosting/ContentRootValidatorTest.cs new file mode 100644 index 0000000..d033b20 --- /dev/null +++ b/test/Codebelt.Cdn.Origin.Tests/Hosting/ContentRootValidatorTest.cs @@ -0,0 +1,290 @@ +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Codebelt.Cdn.Origin.Hosting; + +public class ContentRootValidatorTest : Test +{ + public ContentRootValidatorTest(ITestOutputHelper output) : base(output) + { + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Evaluate_ShouldFail_WhenContentRootIsMissing(string? contentRoot) + { + var result = ContentRootValidator.Evaluate(contentRoot, new ContentRootProbe(string.Empty, false, false, false, false)); + + Assert.False(result.Succeeded); + Assert.Contains("must be configured", result.ErrorMessage); + } + + [Fact] + public void Evaluate_ShouldFail_WhenContentRootDoesNotExist() + { + var result = ContentRootValidator.Evaluate("/cdnroot", new ContentRootProbe("/cdnroot", false, false, false, false)); + + Assert.False(result.Succeeded); + Assert.Contains("does not exist", result.ErrorMessage); + } + + [Fact] + public void Evaluate_ShouldFail_WhenContentRootIsNotADirectory() + { + var result = ContentRootValidator.Evaluate("/cdnroot", new ContentRootProbe("/cdnroot", true, false, false, false)); + + Assert.False(result.Succeeded); + Assert.Contains("is not a directory", result.ErrorMessage); + } + + [Fact] + public void Evaluate_ShouldFail_WhenContentRootIsNotReadable() + { + var result = ContentRootValidator.Evaluate("/cdnroot", new ContentRootProbe("/cdnroot", true, true, false, false)); + + Assert.False(result.Succeeded); + Assert.Contains("is not readable", result.ErrorMessage); + } + + [Fact] + public void Evaluate_ShouldFail_WhenContentRootExposesApplicationFiles() + { + var result = ContentRootValidator.Evaluate("/cdnroot", new ContentRootProbe("/cdnroot", true, true, true, true)); + + Assert.False(result.Succeeded); + Assert.Contains("would expose application files", result.ErrorMessage); + } + + [Fact] + public void Evaluate_ShouldSucceed_WhenProbeIsValid() + { + var result = ContentRootValidator.Evaluate("/cdnroot", new ContentRootProbe("/cdnroot", true, true, true, false)); + + Assert.True(result.Succeeded); + Assert.Null(result.ErrorMessage); + Assert.Equal("/cdnroot", result.ResolvedPath); + } + + [Fact] + public void IsDirectoryReadable_ShouldReturnTrue_ForExistingDirectory() + { + using var temp = new TempDirectory(); + + Assert.True(ContentRootValidator.IsDirectoryReadable(temp.Path)); + } + + [Fact] + public void IsDirectoryReadable_ShouldReturnFalse_ForMissingDirectory() + { + var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + + Assert.False(ContentRootValidator.IsDirectoryReadable(missing)); + } + + [Fact] + public void IsDirectoryReadable_ShouldThrow_ForNullPath() + { + Assert.Throws(() => ContentRootValidator.IsDirectoryReadable(null!)); + } + + [Fact] + public void ExposesApplicationFiles_ShouldReturnTrue_WhenApplicationIsInsideContentRoot() + { + using var temp = new TempDirectory(); + var applicationDirectory = Path.Combine(temp.Path, "app"); + + Assert.True(ContentRootValidator.ExposesApplicationFiles(temp.Path, applicationDirectory)); + } + + [Fact] + public void ExposesApplicationFiles_ShouldReturnTrue_WhenPathsAreEqual() + { + using var temp = new TempDirectory(); + + Assert.True(ContentRootValidator.ExposesApplicationFiles(temp.Path, temp.Path)); + } + + [Fact] + public void ExposesApplicationFiles_ShouldReturnFalse_WhenDirectoriesAreSeparate() + { + using var content = new TempDirectory(); + using var application = new TempDirectory(); + + Assert.False(ContentRootValidator.ExposesApplicationFiles(content.Path, application.Path)); + } + + [Fact] + public void ExposesApplicationFiles_ShouldReturnTrue_WhenContentRootIsSymbolicLinkToApplicationDirectory() + { + using var application = new TempDirectory(); + using var linkHost = new TempDirectory(); + var contentRoot = Path.Combine(linkHost.Path, "content"); + CreateDirectorySymbolicLinkOrSkip(contentRoot, application.Path); + + try + { + Assert.True(ContentRootValidator.ExposesApplicationFiles(contentRoot, application.Path)); + + var probe = ContentRootValidator.Probe(contentRoot, application.Path); + Assert.Equal(Path.GetFullPath(application.Path), probe.ResolvedPath); + } + finally + { + Directory.Delete(contentRoot); + } + } + + [Fact] + public void Validate_ShouldFail_WhenContentRootIsSymbolicLinkToApplicationParent() + { + using var applicationParent = new TempDirectory(); + var applicationDirectory = Directory.CreateDirectory(Path.Combine(applicationParent.Path, "app")).FullName; + using var linkHost = new TempDirectory(); + var contentRoot = Path.Combine(linkHost.Path, "content"); + CreateDirectorySymbolicLinkOrSkip(contentRoot, applicationParent.Path); + + try + { + var result = ContentRootValidator.Validate(contentRoot, applicationDirectory); + + Assert.False(result.Succeeded); + Assert.Contains("would expose application files", result.ErrorMessage); + } + finally + { + Directory.Delete(contentRoot); + } + } + + [Fact] + public void Validate_ShouldFail_WhenContentRootTraversesSymbolicLinkToApplicationParent() + { + using var applicationParent = new TempDirectory(); + var applicationDirectory = Directory.CreateDirectory(Path.Combine(applicationParent.Path, "app")).FullName; + using var linkHost = new TempDirectory(); + var linkedParent = Path.Combine(linkHost.Path, "parent"); + var contentRoot = Path.Combine(linkedParent, "app"); + CreateDirectorySymbolicLinkOrSkip(linkedParent, applicationParent.Path); + + try + { + var result = ContentRootValidator.Validate(contentRoot, applicationDirectory); + + Assert.False(result.Succeeded); + Assert.Contains("would expose application files", result.ErrorMessage); + } + finally + { + Directory.Delete(linkedParent); + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Probe_ShouldReportMissing_WhenContentRootIsBlank(string? contentRoot) + { + var probe = ContentRootValidator.Probe(contentRoot, AppContext.BaseDirectory); + + Assert.False(probe.Exists); + Assert.False(probe.IsDirectory); + } + + [Fact] + public void Probe_ShouldReportMissing_WhenContentRootIsInvalid() + { + var probe = ContentRootValidator.Probe("invalid\0path", AppContext.BaseDirectory); + + Assert.False(probe.Exists); + Assert.False(probe.IsDirectory); + } + + [Fact] + public void Probe_ShouldReportDirectory_ForExistingReadableDirectory() + { + using var temp = new TempDirectory(); + using var application = new TempDirectory(); + + var probe = ContentRootValidator.Probe(temp.Path, application.Path); + + Assert.True(probe.Exists); + Assert.True(probe.IsDirectory); + Assert.True(probe.IsReadable); + Assert.False(probe.ExposesApplicationFiles); + } + + [Fact] + public void Probe_ShouldReportFile_ForExistingFile() + { + using var temp = new TempDirectory(); + var file = Path.Combine(temp.Path, "content.txt"); + File.WriteAllText(file, "hello"); + + var probe = ContentRootValidator.Probe(file, AppContext.BaseDirectory); + + Assert.True(probe.Exists); + Assert.False(probe.IsDirectory); + Assert.False(probe.IsReadable); + } + + [Fact] + public void Probe_ShouldReportMissing_ForNonexistentPath() + { + var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + + var probe = ContentRootValidator.Probe(missing, AppContext.BaseDirectory); + + Assert.False(probe.Exists); + Assert.False(probe.IsDirectory); + } + + [Fact] + public void Validate_ShouldSucceed_ForValidContentRoot() + { + using var content = new TempDirectory(); + using var application = new TempDirectory(); + + var result = ContentRootValidator.Validate(content.Path, application.Path); + + Assert.True(result.Succeeded); + TestOutput.WriteLine(result.ResolvedPath); + } + + [Fact] + public void Validate_ShouldFail_ForMissingContentRoot() + { + var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + + var result = ContentRootValidator.Validate(missing, AppContext.BaseDirectory); + + Assert.False(result.Succeeded); + Assert.Contains("does not exist", result.ErrorMessage); + } + + [Fact] + public void Validate_ShouldFail_WhenContentRootExposesApplicationFiles() + { + using var content = new TempDirectory(); + var application = Directory.CreateDirectory(Path.Combine(content.Path, "app")).FullName; + + var result = ContentRootValidator.Validate(content.Path, application); + + Assert.False(result.Succeeded); + Assert.Contains("would expose application files", result.ErrorMessage); + } + + private static void CreateDirectorySymbolicLinkOrSkip(string linkPath, string targetPath) + { + try + { + Directory.CreateSymbolicLink(linkPath, targetPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + Assert.Skip($"Directory symbolic links are unavailable: {ex.Message}"); + } + } +} diff --git a/test/Codebelt.Cdn.Origin.Tests/Hosting/ContentTypeProviderFactoryTest.cs b/test/Codebelt.Cdn.Origin.Tests/Hosting/ContentTypeProviderFactoryTest.cs new file mode 100644 index 0000000..4f3f484 --- /dev/null +++ b/test/Codebelt.Cdn.Origin.Tests/Hosting/ContentTypeProviderFactoryTest.cs @@ -0,0 +1,52 @@ +using Codebelt.Cdn.Origin.Configuration; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Codebelt.Cdn.Origin.Hosting; + +public class ContentTypeProviderFactoryTest : Test +{ + public ContentTypeProviderFactoryTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Create_ShouldThrow_WhenOptionsAreNull() + { + Assert.Throws(() => ContentTypeProviderFactory.Create(null!)); + } + + [Fact] + public void Create_ShouldMapKnownExtensions_AndRejectUnknown() + { + var provider = ContentTypeProviderFactory.Create(new ContentTypeOptions()); + + Assert.True(provider.TryGetContentType("index.html", out var html)); + Assert.Equal("text/html", html); + Assert.False(provider.TryGetContentType("data.unknownext", out _)); + } + + [Fact] + public void Create_ShouldAddMapping_WithLeadingDot() + { + var options = new ContentTypeOptions(); + options.Mappings[".foo"] = "application/x-foo"; + + var provider = ContentTypeProviderFactory.Create(options); + + Assert.True(provider.TryGetContentType("file.foo", out var contentType)); + Assert.Equal("application/x-foo", contentType); + } + + [Fact] + public void Create_ShouldNormalizeMapping_WithoutLeadingDot() + { + var options = new ContentTypeOptions(); + options.Mappings["bar"] = "application/x-bar"; + + var provider = ContentTypeProviderFactory.Create(options); + + Assert.True(provider.TryGetContentType("file.bar", out var contentType)); + Assert.Equal("application/x-bar", contentType); + } +} diff --git a/test/Codebelt.Cdn.Origin.Tests/PortablePhysicalFileProviderTest.cs b/test/Codebelt.Cdn.Origin.Tests/PortablePhysicalFileProviderTest.cs new file mode 100644 index 0000000..03f81b0 --- /dev/null +++ b/test/Codebelt.Cdn.Origin.Tests/PortablePhysicalFileProviderTest.cs @@ -0,0 +1,100 @@ +using Codebelt.Extensions.Xunit; +using Cuemon.Extensions.FileProviders; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace Codebelt.Cdn.Origin; + +public class PortablePhysicalFileProviderTest : Test +{ + public PortablePhysicalFileProviderTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void GetFileInfo_ShouldResolveMixedCaseFile_AndReuseResolvedPath() + { + using var content = CreateContent(); + using var provider = new PortablePhysicalFileProvider(content.Path); + + IFileInfo first = provider.GetFileInfo("/styles/SITE.CSS"); + IFileInfo second = provider.GetFileInfo("/styles/site.css"); + + Assert.True(first.Exists); + Assert.True(second.Exists); + Assert.Equal(first.PhysicalPath, second.PhysicalPath); + } + + [Fact] + public void GetDirectoryContents_ShouldResolveMixedCaseDirectory() + { + using var content = CreateContent(); + using var provider = new PortablePhysicalFileProvider(content.Path); + + IDirectoryContents contents = provider.GetDirectoryContents("/StYlEs"); + + Assert.True(contents.Exists); + Assert.Contains(contents, file => file.Name == "site.css"); + } + + [Fact] + public void GetFileInfo_ShouldReturnNotFound_ForEmptyOrMissingPath() + { + using var content = CreateContent(); + using var provider = new PortablePhysicalFileProvider(content.Path); + + Assert.False(provider.GetFileInfo(string.Empty).Exists); + Assert.False(provider.GetFileInfo("/").Exists); + Assert.False(provider.GetFileInfo("/missing.css").Exists); + } + + [Fact] + public void Constructor_ShouldThrow_WhenRootDoesNotExist() + { + var missingRoot = Path.Combine(Path.GetTempPath(), "cdn-origin-missing", Guid.NewGuid().ToString("N")); + + Assert.Throws(() => new PortablePhysicalFileProvider(missingRoot)); + } + + [Fact] + public void Watch_ShouldResolveMixedCaseLiteralPath() + { + using var content = CreateContent(); + using var provider = new PortablePhysicalFileProvider(content.Path); + + IChangeToken token = provider.Watch("STYLES/SITE.CSS"); + + File.AppendAllText(Path.Combine(content.Path, "styles", "site.css"), "\nbody{}"); + + Assert.True(SpinWait.SpinUntil(() => token.HasChanged, TimeSpan.FromSeconds(5))); + } + + [Fact] + public void Watch_ShouldPreserveGlobFilter() + { + using var content = CreateContent(); + using var provider = new PortablePhysicalFileProvider(content.Path); + + IChangeToken token = provider.Watch("STYLES/*.CSS"); + + Assert.NotNull(token); + } + + [Fact] + public void Watch_ShouldReturnToken_ForEmptyFilter() + { + using var content = CreateContent(); + using var provider = new PortablePhysicalFileProvider(content.Path); + + Assert.NotNull(provider.Watch(string.Empty)); + } + + private static TempDirectory CreateContent() + { + var content = new TempDirectory(); + string styles = Directory.CreateDirectory(Path.Combine(content.Path, "styles")).FullName; + File.WriteAllText(Path.Combine(styles, "site.css"), "body{}"); + return content; + } +} diff --git a/test/Codebelt.Cdn.Origin.Tests/TempDirectory.cs b/test/Codebelt.Cdn.Origin.Tests/TempDirectory.cs new file mode 100644 index 0000000..80c1846 --- /dev/null +++ b/test/Codebelt.Cdn.Origin.Tests/TempDirectory.cs @@ -0,0 +1,30 @@ +namespace Codebelt.Cdn.Origin; + +/// +/// A disposable, isolated temporary directory for deterministic filesystem tests. +/// +internal sealed class TempDirectory : IDisposable +{ + public TempDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "cdn-origin-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + try + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best-effort cleanup of a temporary directory. + } + } +} diff --git a/testEnvironments.json b/testEnvironments.json new file mode 100644 index 0000000..a371fce --- /dev/null +++ b/testEnvironments.json @@ -0,0 +1,10 @@ +{ + "version": "1", + "environments": [ + { + "name": "Docker dotnet/sdk", + "type": "docker", + "dockerImage": "mcr.microsoft.com/dotnet/sdk" + } + ] +}