diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml
index baeea42..0d9ceea 100644
--- a/.github/workflows/ci-cd.yml
+++ b/.github/workflows/ci-cd.yml
@@ -17,7 +17,7 @@ on:
jobs:
build-and-test:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
if: |
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[ci skip]') &&
@@ -27,15 +27,15 @@ jobs:
env:
NUGET_PACKAGES: ${{ format('{0}/.nuget/packages', github.workspace) }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Setup .NET
- uses: actions/setup-dotnet@v4
+ uses: actions/setup-dotnet@v6
with:
- dotnet-version: '10.0.x'
+ global-json-file: global.json
- name: "Cache NuGet packages"
- uses: actions/cache@v4
+ uses: actions/cache@v6
with:
path: ${{ format('{0}/.nuget/packages', github.workspace) }}
key: nuget-${{ hashFiles('**/Directory.Packages.props') }}-${{ hashFiles('**/*.csproj') }}-${{ hashFiles('**/packages.lock.json') }}
@@ -57,7 +57,7 @@ jobs:
- name: Publish Test Results
id: test-results
- uses: dorny/test-reporter@v1
+ uses: dorny/test-reporter@v3
if: success() || failure()
with:
name: 'Test Results (Linux)'
@@ -81,7 +81,7 @@ jobs:
run_id: '${{ github.run_id }}'
repository: '${{ github.repository }}'
server_url: '${{ github.server_url }}'
- api_domain: 'api.localstackfor.net'
+ api_base_url: 'https://api.localstackfor.net'
hmac_secret: '${{ secrets.TESTDATASECRET }}'
continuous-deployment:
@@ -97,19 +97,26 @@ jobs:
!contains(github.event.head_commit.message, '***NO_CD***')
runs-on: ubuntu-24.04-arm
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v6
+ with:
+ global-json-file: global.json
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
- name: Build Lambda ZIP for ARM64
+ env:
+ BADGESMITH_TOOL_PATH: ${{ github.workspace }}/tools/badgesmith.cs
run: |
- ./scripts/build-lambda.sh --target zip --rid linux-arm64 --clean --verbose
+ "$BADGESMITH_TOOL_PATH" lambda build --target zip --rid linux-arm64 --clean --verbose
- name: Upload Lambda artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
- name: ${{ github.ref == 'refs/heads/master' && 'lambda-zip-latest' || format('lambda-zip-{0}', github.head_ref || github.ref_name) }}
+ name: ${{ github.ref == 'refs/heads/master' && 'lambda-zip-latest' || format('lambda-zip-pr-{0}', github.event.pull_request.number) }}
path: artifacts/badge-lambda-linux-arm64.zip
retention-days: 30
overwrite: true
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index b8c4569..59219b1 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -36,7 +36,7 @@ env:
jobs:
deploy:
- runs-on: ${{ inputs.build_lambda && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
+ runs-on: ${{ inputs.build_lambda && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
environment: ${{ inputs.environment }}
permissions:
id-token: write # Required for OIDC authentication
@@ -45,27 +45,27 @@ jobs:
env:
NUGET_PACKAGES: ${{ format('{0}/.nuget/packages', github.workspace) }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Configure AWS credentials
- uses: aws-actions/configure-aws-credentials@v4
+ uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
role-session-name: github-actions-badge-smith-deploy
aws-region: ${{ env.AWS_REGION }}
- name: Setup .NET
- uses: actions/setup-dotnet@v4
+ uses: actions/setup-dotnet@v6
with:
- dotnet-version: '10.0.x'
+ global-json-file: global.json
- name: Setup Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v7
with:
- node-version: '22'
+ node-version: '24'
- name: "Cache NuGet packages"
- uses: actions/cache@v4
+ uses: actions/cache@v6
with:
path: ${{ format('{0}/.nuget/packages', github.workspace) }}
key: nuget-${{ hashFiles('**/Directory.Packages.props') }}-${{ hashFiles('**/*.csproj') }}-${{ hashFiles('**/packages.lock.json') }}
@@ -73,20 +73,20 @@ jobs:
nuget-
- name: Install AWS CDK
- run: npm install -g aws-cdk
+ run: npm install --global aws-cdk@2.1135.1
- name: Set up Docker Buildx
if: inputs.build_lambda
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
- name: Build Lambda ZIP for ARM64
if: inputs.build_lambda
run: |
- ./scripts/build-lambda.sh --target zip --rid linux-arm64 --clean --verbose
+ "${{ github.workspace }}/tools/badgesmith.cs" lambda build --target zip --rid linux-arm64 --clean --verbose
- name: Download Lambda artifact
if: ${{ !inputs.build_lambda }}
- uses: dawidd6/action-download-artifact@v6
+ uses: dawidd6/action-download-artifact@v21
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
workflow: ci-cd.yml
@@ -102,21 +102,21 @@ jobs:
- name: CDK synth
working-directory: build
- run: cdk synth --all
+ run: cdk synth BadgeSmithStack
- name: CDK diff
if: inputs.show_diff
working-directory: build
run: |
echo "π CDK Diff - Infrastructure changes:"
- cdk diff --all || true
+ cdk diff BadgeSmithStack || true
continue-on-error: true
- name: CDK deploy
working-directory: build
run: |
echo "π Deploying BadgeSmith to ${{ inputs.environment }}..."
- cdk deploy --all --require-approval never
+ cdk deploy BadgeSmithStack --require-approval never
- name: Get deployment outputs
working-directory: build
diff --git a/.github/workflows/run-dotnet-tests/action.yml b/.github/workflows/run-dotnet-tests/action.yml
index f6f0f0e..8b5aa42 100644
--- a/.github/workflows/run-dotnet-tests/action.yml
+++ b/.github/workflows/run-dotnet-tests/action.yml
@@ -13,20 +13,28 @@ inputs:
runs:
using: "composite"
steps:
- # Windows step -----------------------------------------------------------
- if: runner.os == 'Windows'
shell: pwsh
+ env:
+ BADGESMITH_TOOL_PATH: ${{ github.workspace }}/tools/badgesmith.cs
+ BADGESMITH_PROJECT_PATH: ${{ inputs.project-path }}
+ BADGESMITH_RESULTS_DIR: ${{ inputs.results-dir }}
+ BADGESMITH_CONFIGURATION: ${{ inputs.configuration }}
run: |
- & "${{ github.action_path }}\run-win.ps1" `
- -ProjectPath "${{ inputs.project-path }}" `
- -ResultsDir "${{ inputs.results-dir }}" `
- -Configuration "${{ inputs.configuration }}"
+ dotnet run --file "$env:BADGESMITH_TOOL_PATH" -- tests run `
+ --project-path "$env:BADGESMITH_PROJECT_PATH" `
+ --results-dir "$env:BADGESMITH_RESULTS_DIR" `
+ --configuration "$env:BADGESMITH_CONFIGURATION"
- # Linux/macOS step -------------------------------------------------------
- if: runner.os != 'Windows'
shell: bash
+ env:
+ BADGESMITH_TOOL_PATH: ${{ github.workspace }}/tools/badgesmith.cs
+ BADGESMITH_PROJECT_PATH: ${{ inputs.project-path }}
+ BADGESMITH_RESULTS_DIR: ${{ inputs.results-dir }}
+ BADGESMITH_CONFIGURATION: ${{ inputs.configuration }}
run: |
- "${{ github.action_path }}/run-unix.sh" \
- "${{ inputs.project-path }}" \
- "${{ inputs.results-dir }}" \
- "${{ inputs.configuration }}"
+ "$BADGESMITH_TOOL_PATH" tests run \
+ --project-path "$BADGESMITH_PROJECT_PATH" \
+ --results-dir "$BADGESMITH_RESULTS_DIR" \
+ --configuration "$BADGESMITH_CONFIGURATION"
diff --git a/.github/workflows/run-dotnet-tests/run-unix.sh b/.github/workflows/run-dotnet-tests/run-unix.sh
deleted file mode 100755
index 751ba54..0000000
--- a/.github/workflows/run-dotnet-tests/run-unix.sh
+++ /dev/null
@@ -1,36 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-PROJECT_PATH="$1"
-RESULTS_DIR="$2"
-CONFIGURATION="${3:-Release}"
-
-# 1οΈβ£ Get the multi-TFM list first β¦
-TFM_RAW=$(dotnet msbuild "$PROJECT_PATH" \
- -getProperty:TargetFrameworks -nologo -v:q)
-
-# 2οΈβ£ β¦ fallback to single-TFM if empty
-if [[ -z "$TFM_RAW" ]]; then
- TFM_RAW=$(dotnet msbuild "$PROJECT_PATH" \
- -getProperty:TargetFramework -nologo -v:q)
-fi
-
-if [[ -z "$TFM_RAW" ]]; then
- echo "Unable to determine target frameworks for $PROJECT_PATH" >&2
- exit 1
-fi
-
-# Normalise newlines β semicolons, then explode into an array
-IFS=';' read -ra TFMS <<< "$(echo "$TFM_RAW" | tr -d '\r\n')"
-
-echo "π Target frameworks: ${TFMS[*]}"
-
-for tfm in "${TFMS[@]}"; do
- tfm="$(echo "$tfm" | xargs)" # trim
- [[ -z "$tfm" ]] && continue
-
- echo "π§ͺ $tfm ..."
- dotnet test "$PROJECT_PATH" -c "$CONFIGURATION" -f "$tfm" --no-build \
- --logger "trx;LogFileName=testResults-$tfm.trx" \
- --results-directory "$RESULTS_DIR"
-done
diff --git a/.github/workflows/run-dotnet-tests/run-win.ps1 b/.github/workflows/run-dotnet-tests/run-win.ps1
deleted file mode 100644
index a0bf555..0000000
--- a/.github/workflows/run-dotnet-tests/run-win.ps1
+++ /dev/null
@@ -1,35 +0,0 @@
-Param(
- [string]$ProjectPath,
- [string]$ResultsDir,
- [string]$Configuration = "Release"
-)
-
-$ErrorActionPreference = 'Stop'
-
-# 1οΈβ£ Multi-target first
-$tfmRaw = dotnet msbuild $ProjectPath `
- -getProperty:TargetFrameworks -nologo -v:q
-
-# 2οΈβ£ Fallback to single-target
-if ([string]::IsNullOrWhiteSpace($tfmRaw)) {
- $tfmRaw = dotnet msbuild $ProjectPath `
- -getProperty:TargetFramework -nologo -v:q
-}
-
-if ([string]::IsNullOrWhiteSpace($tfmRaw)) {
- throw "Unable to determine target frameworks for $ProjectPath"
-}
-
-$tfms = $tfmRaw -split ';' |
- ForEach-Object { $_.Trim() } |
- Where-Object { $_ } |
- Select-Object -Unique
-
-Write-Host "π Target frameworks: $($tfms -join ', ')"
-
-foreach ($tfm in $tfms) {
- Write-Host "π§ͺ $tfm ..."
- dotnet test $ProjectPath -c $Configuration -f $tfm --no-build `
- --logger "trx;LogFileName=testResults-$tfm.trx" `
- --results-directory $ResultsDir
-}
diff --git a/.github/workflows/update-test-badge/README.md b/.github/workflows/update-test-badge/README.md
index e69de29..bc82825 100644
--- a/.github/workflows/update-test-badge/README.md
+++ b/.github/workflows/update-test-badge/README.md
@@ -0,0 +1,45 @@
+# Update Test Results Badge
+
+Reusable composite action that posts CI test results to BadgeSmith with HMAC
+authentication and writes badge markdown to the GitHub Actions step summary.
+
+## Inputs
+
+See [`action.yml`](./action.yml) for the canonical input list. Required inputs are
+`platform`, `test_passed`, `test_failed`, `test_skipped`, `commit_sha`, `run_id`,
+`repository`, `server_url`, `api_base_url`, and `hmac_secret`.
+`test_url_html` is optional. When supplied, the badge redirect targets that HTTPS
+test-report URL, such as `dorny/test-reporter`'s `url_html` output or a report hosted by
+another provider. When omitted, the action falls back to the current GitHub workflow-run
+URL. `api_base_url` must be an absolute HTTPS URL for public deployments and may include
+a port or path prefix. Plain HTTP is accepted only for loopback hosts (`localhost`,
+`127.0.0.0/8`, or `::1`) used by local development.
+
+## Usage
+
+External consumers use the maintained major action tag:
+
+```yaml
+- name: Update test badge
+ uses: localstack-dotnet/badge-smith/.github/workflows/update-test-badge@v1
+ with:
+ platform: 'Linux'
+ test_passed: '${{ steps.test-results.outputs.passed }}'
+ test_failed: '${{ steps.test-results.outputs.failed }}'
+ test_skipped: '${{ steps.test-results.outputs.skipped }}'
+ test_url_html: '${{ steps.test-results.outputs.url_html }}'
+ commit_sha: '${{ github.sha }}'
+ run_id: '${{ github.run_id }}'
+ repository: '${{ github.repository }}'
+ server_url: '${{ github.server_url }}'
+ api_base_url: 'https://api.localstackfor.net'
+ hmac_secret: '${{ secrets.TESTDATASECRET }}'
+```
+
+Consumers pin the supported major action tag. The action installs the SDK pinned by
+BadgeSmith's `global.json` and runs `tools/badgesmith.cs` from the downloaded action
+repository via `github.action_path`; the caller does not need to contain BadgeSmith's
+tool sources.
+
+The `TESTDATASECRET` repository secret must hold the HMAC shared secret
+configured for the organization through `badgesmith secrets seed`.
diff --git a/.github/workflows/update-test-badge/action.yml b/.github/workflows/update-test-badge/action.yml
index 119a1cc..f1ac217 100644
--- a/.github/workflows/update-test-badge/action.yml
+++ b/.github/workflows/update-test-badge/action.yml
@@ -1,5 +1,5 @@
name: 'Update Test Results Badge'
-description: 'Posts test results to BadgeSmith API with HMAC authentication'
+description: 'Posts test results to a BadgeSmith API with HMAC authentication'
author: 'LocalStack .NET Team'
inputs:
@@ -16,7 +16,7 @@ inputs:
description: 'Number of skipped tests'
required: true
test_url_html:
- description: 'URL to test results page'
+ description: 'Optional HTTPS test-report URL; defaults to the current workflow run'
required: false
default: ''
commit_sha:
@@ -31,10 +31,9 @@ inputs:
server_url:
description: 'GitHub server URL'
required: true
- api_domain:
- description: 'BadgeSmith API domain'
- required: false
- default: 'api.localstackfor.net'
+ api_base_url:
+ description: 'Absolute BadgeSmith API base URL'
+ required: true
hmac_secret:
description: 'HMAC secret for BadgeSmith authentication'
required: true
@@ -42,107 +41,69 @@ inputs:
runs:
using: 'composite'
steps:
- - name: 'Post Test Results to BadgeSmith API'
- shell: bash
+ - name: 'Setup .NET'
+ uses: actions/setup-dotnet@v6
+ with:
+ global-json-file: ${{ github.action_path }}/../../../global.json
+
+ - name: 'Post Test Results to BadgeSmith API on Windows'
+ if: runner.os == 'Windows'
+ shell: pwsh
+ env:
+ BADGESMITH_ACTION_PATH: ${{ github.action_path }}
+ BADGESMITH_HMAC_SECRET: ${{ inputs.hmac_secret }}
+ BADGESMITH_PLATFORM: ${{ inputs.platform }}
+ BADGESMITH_TEST_PASSED: ${{ inputs.test_passed }}
+ BADGESMITH_TEST_FAILED: ${{ inputs.test_failed }}
+ BADGESMITH_TEST_SKIPPED: ${{ inputs.test_skipped }}
+ BADGESMITH_TEST_URL_HTML: ${{ inputs.test_url_html }}
+ BADGESMITH_COMMIT_SHA: ${{ inputs.commit_sha }}
+ BADGESMITH_RUN_ID: ${{ inputs.run_id }}
+ BADGESMITH_REPOSITORY: ${{ inputs.repository }}
+ BADGESMITH_SERVER_URL: ${{ inputs.server_url }}
+ BADGESMITH_API_BASE_URL: ${{ inputs.api_base_url }}
+ BADGESMITH_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
- # Extract owner and repo from repository input
- IFS='/' read -ra REPO_PARTS <<< "${{ inputs.repository }}"
- OWNER="${REPO_PARTS[0]}"
- REPO="${REPO_PARTS[1]}"
-
- # Normalize platform name
- PLATFORM_LOWER=$(echo "${{ inputs.platform }}" | tr '[:upper:]' '[:lower:]')
-
- # Extract branch from GitHub context
- if [[ "${{ github.event_name }}" == "pull_request" ]]; then
- BRANCH="${{ github.head_ref }}"
- else
- BRANCH="${{ github.ref_name }}"
- fi
-
- # Calculate totals
- TOTAL=$((${{ inputs.test_passed }} + ${{ inputs.test_failed }} + ${{ inputs.test_skipped }}))
-
- # Create JSON payload for BadgeSmith API
- cat > test-results.json << EOF
- {
- "platform": "${{ inputs.platform }}",
- "passed": ${{ inputs.test_passed }},
- "failed": ${{ inputs.test_failed }},
- "skipped": ${{ inputs.test_skipped }},
- "total": ${TOTAL},
- "url_html": "${{ inputs.test_url_html }}",
- "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
- "commit": "${{ inputs.commit_sha }}",
- "run_id": "${{ inputs.run_id }}",
- "workflow_run_url": "${{ inputs.server_url }}/${{ inputs.repository }}/actions/runs/${{ inputs.run_id }}"
- }
- EOF
-
- echo "π Generated test results JSON for ${{ inputs.platform }}:"
- cat test-results.json | jq '.' 2>/dev/null || cat test-results.json
-
- # Prepare HMAC authentication
- PAYLOAD_JSON=$(cat test-results.json)
- TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ")
- NONCE=$(uuidgen | tr -d '-' | tr '[:upper:]' '[:lower:]')
-
- # Compute HMAC-SHA256 signature
- SIGNATURE="sha256=$(echo -n "$PAYLOAD_JSON" | openssl dgst -sha256 -hmac "${{ inputs.hmac_secret }}" -binary | xxd -p -c 256)"
-
- # Build BadgeSmith API URL
- API_URL="https://${{ inputs.api_domain }}/tests/results/${PLATFORM_LOWER}/${OWNER}/${REPO}/${BRANCH}"
-
- echo "π Posting to BadgeSmith API: ${API_URL}"
-
- # Send request to BadgeSmith API
- HTTP_CODE=$(curl -s -w "%{http_code}" -o response.tmp \
- -X POST "${API_URL}" \
- -H "Content-Type: application/json" \
- -H "X-Signature: ${SIGNATURE}" \
- -H "X-Timestamp: ${TIMESTAMP}" \
- -H "X-Nonce: ${NONCE}" \
- -d "$PAYLOAD_JSON")
-
- RESPONSE_BODY=$(cat response.tmp)
- rm -f response.tmp
-
- if [[ "$HTTP_CODE" -ge 200 && "$HTTP_CODE" -lt 300 ]]; then
- echo "β
Successfully posted test results to BadgeSmith API (HTTP $HTTP_CODE)"
- echo "Response:"
- echo "$RESPONSE_BODY" | jq . 2>/dev/null || echo "$RESPONSE_BODY"
- else
- echo "β οΈ Failed to post test results to BadgeSmith API (HTTP $HTTP_CODE)"
- echo "Response:"
- echo "$RESPONSE_BODY" | jq . 2>/dev/null || echo "$RESPONSE_BODY"
- # Don't fail the build for badge update failures
- fi
-
- - name: 'Display Badge URLs'
+ dotnet run --file "$env:BADGESMITH_ACTION_PATH/../../../tools/badgesmith.cs" -- badge update `
+ --platform "$env:BADGESMITH_PLATFORM" `
+ --test-passed "$env:BADGESMITH_TEST_PASSED" `
+ --test-failed "$env:BADGESMITH_TEST_FAILED" `
+ --test-skipped "$env:BADGESMITH_TEST_SKIPPED" `
+ --test-url-html "$env:BADGESMITH_TEST_URL_HTML" `
+ --commit-sha "$env:BADGESMITH_COMMIT_SHA" `
+ --run-id "$env:BADGESMITH_RUN_ID" `
+ --repository "$env:BADGESMITH_REPOSITORY" `
+ --server-url "$env:BADGESMITH_SERVER_URL" `
+ --base-url "$env:BADGESMITH_API_BASE_URL" `
+ --branch "$env:BADGESMITH_BRANCH"
+
+ - name: 'Post Test Results to BadgeSmith API on Unix'
+ if: runner.os != 'Windows'
shell: bash
+ env:
+ BADGESMITH_ACTION_PATH: ${{ github.action_path }}
+ BADGESMITH_HMAC_SECRET: ${{ inputs.hmac_secret }}
+ BADGESMITH_PLATFORM: ${{ inputs.platform }}
+ BADGESMITH_TEST_PASSED: ${{ inputs.test_passed }}
+ BADGESMITH_TEST_FAILED: ${{ inputs.test_failed }}
+ BADGESMITH_TEST_SKIPPED: ${{ inputs.test_skipped }}
+ BADGESMITH_TEST_URL_HTML: ${{ inputs.test_url_html }}
+ BADGESMITH_COMMIT_SHA: ${{ inputs.commit_sha }}
+ BADGESMITH_RUN_ID: ${{ inputs.run_id }}
+ BADGESMITH_REPOSITORY: ${{ inputs.repository }}
+ BADGESMITH_SERVER_URL: ${{ inputs.server_url }}
+ BADGESMITH_API_BASE_URL: ${{ inputs.api_base_url }}
+ BADGESMITH_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
- # Extract owner and repo from repository input
- IFS='/' read -ra REPO_PARTS <<< "${{ inputs.repository }}"
- OWNER="${REPO_PARTS[0]}"
- REPO="${REPO_PARTS[1]}"
-
- PLATFORM_LOWER=$(echo "${{ inputs.platform }}" | tr '[:upper:]' '[:lower:]')
-
- # Extract branch from GitHub context
- if [[ "${{ github.event_name }}" == "pull_request" ]]; then
- BRANCH="${{ github.head_ref }}"
- else
- BRANCH="${{ github.ref_name }}"
- fi
-
- echo "π― BadgeSmith URLs for ${{ inputs.platform }}:"
- echo ""
- echo "**${{ inputs.platform }} Badge:**"
- echo "[](https://${{ inputs.api_domain }}/redirect/test-results/${PLATFORM_LOWER}/${OWNER}/${REPO}/${BRANCH})"
- echo ""
- echo "**Raw URLs:**"
- echo "- Badge: https://${{ inputs.api_domain }}/badges/tests/${PLATFORM_LOWER}/${OWNER}/${REPO}/${BRANCH}"
- echo "- Redirect: https://${{ inputs.api_domain }}/redirect/test-results/${PLATFORM_LOWER}/${OWNER}/${REPO}/${BRANCH}"
- echo ""
- echo "**API Test:**"
- echo "curl \"https://${{ inputs.api_domain }}/badges/tests/${PLATFORM_LOWER}/${OWNER}/${REPO}/${BRANCH}\""
+ "$BADGESMITH_ACTION_PATH/../../../tools/badgesmith.cs" badge update \
+ --platform "$BADGESMITH_PLATFORM" \
+ --test-passed "$BADGESMITH_TEST_PASSED" \
+ --test-failed "$BADGESMITH_TEST_FAILED" \
+ --test-skipped "$BADGESMITH_TEST_SKIPPED" \
+ --test-url-html "$BADGESMITH_TEST_URL_HTML" \
+ --commit-sha "$BADGESMITH_COMMIT_SHA" \
+ --run-id "$BADGESMITH_RUN_ID" \
+ --repository "$BADGESMITH_REPOSITORY" \
+ --server-url "$BADGESMITH_SERVER_URL" \
+ --base-url "$BADGESMITH_API_BASE_URL" \
+ --branch "$BADGESMITH_BRANCH"
diff --git a/.gitignore b/.gitignore
index 29456b9..52a990b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,6 +54,9 @@ Generated\ Files/
tests/**/[Tt]est[Rr]esults/
**/bin/**/[Tt]est[Rr]esults/
**/obj/**/[Tt]est[Rr]esults/
+# Exception: source code for the TestResults feature tests is not test output
+!tests/BadgeSmith.Api.Tests/Features/TestResults/
+!tests/BadgeSmith.Api.Tests/Features/TestResults/**
[Bb]uild[Ll]og.*
*.trx
@@ -438,8 +441,17 @@ cdk.out
.mcp.json
opencode.jsonc
+.opencode/.gitignore
.opencode/agents/
+.opencode/bun.lock
+.opencode/node_modules/
+.opencode/package-lock.json
+.opencode/package.json
+.opencode/skill/
.opencode/skills/subagent-model-routing/
# Editor / local harness config (machine-specific; may contain local MCP ports and personal settings)
.vscode/
+external/
+
+.playwright-mcp
diff --git a/.opencode/skills/aspire-source-navigation/SKILL.md b/.opencode/skills/aspire-source-navigation/SKILL.md
new file mode 100644
index 0000000..1d0365a
--- /dev/null
+++ b/.opencode/skills/aspire-source-navigation/SKILL.md
@@ -0,0 +1,12 @@
+---
+name: aspire-source-navigation
+description: Use when BadgeSmith's compatibility-sensitive Aspire, AWS, or LocalStack consumer work depends on upstream source, package-version alignment, AddLocalStack/UseLocalStack/WithReference behavior, endpoint/configuration flow, or AWS SDK wiring.
+---
+
+# Aspire Source Navigation
+
+Canonical skill content lives in [docs/agents/skills/aspire-source-navigation.md](../../../docs/agents/skills/aspire-source-navigation.md).
+
+Read that file and follow it. This file is a native OpenCode discovery relay, not the source of truth.
+
+OpenCode loads project skills at session start. Restart OpenCode after changing this file if the running UI needs the updated skill.
diff --git a/.slopwatch/baseline.json b/.slopwatch/baseline.json
new file mode 100644
index 0000000..3560207
--- /dev/null
+++ b/.slopwatch/baseline.json
@@ -0,0 +1,62 @@
+{
+ "version": 1,
+ "createdAt": "2026-08-07T14:05:15.2436264+00:00",
+ "updatedAt": "2026-08-07T14:05:15.2493236+00:00",
+ "description": "Initial baseline created by 'slopwatch init' on 2026-08-07 14:05:15 UTC",
+ "entries": [
+ {
+ "hash": "3a24d697f1f7100e",
+ "ruleId": "SW002",
+ "filePath": "src/BadgeSmith.Api/Core/Security/HmacAuthenticationService.cs",
+ "lineNumber": 83,
+ "codeSnippet": "SuppressMessage(\n \"Usage\",\n \"MA0015:Specify the parameter name in ArgumentException\",\n Justification = \"The validated values are nested request properties rather than method parameters.\")",
+ "message": "SuppressMessage attribute suppressing Usage:MA0015:Specify the parameter name in ArgumentException",
+ "baselinedAt": "2026-08-07T14:05:15.2491771+00:00"
+ },
+ {
+ "hash": "8e76f6c9847c0e34",
+ "ruleId": "SW003",
+ "filePath": "src/BadgeSmith.Api/Core/Http/ResilienceRetryHandler.cs",
+ "lineNumber": 33,
+ "codeSnippet": "catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && attempt < _maxRetries)\n {\n // timeout: retry\n }",
+ "message": "Empty catch block swallows exceptions without handling",
+ "baselinedAt": "2026-08-07T14:05:15.2492917+00:00"
+ },
+ {
+ "hash": "1dbe6b723437a0c6",
+ "ruleId": "SW003",
+ "filePath": "src/BadgeSmith.Api/Core/Http/ResilienceRetryHandler.cs",
+ "lineNumber": 37,
+ "codeSnippet": "catch (HttpRequestException) when (attempt < _maxRetries)\n {\n // transient network error: retry\n }",
+ "message": "Empty catch block swallows exceptions without handling",
+ "baselinedAt": "2026-08-07T14:05:15.2492993+00:00"
+ },
+ {
+ "hash": "39c21ee99e0a788c",
+ "ruleId": "SW002",
+ "filePath": "tests/BadgeSmith.Api.Tests/Routing/RouteResolverTests.cs",
+ "lineNumber": 330,
+ "codeSnippet": "SuppressMessage(\n \"Design\",\n \"MA0051:Method is too long\",\n Justification = \"Keeping the routing scenarios in one MemberData source makes the route matrix auditable.\")",
+ "message": "SuppressMessage attribute suppressing Design:MA0051:Method is too long",
+ "baselinedAt": "2026-08-07T14:05:15.2493077+00:00"
+ },
+ {
+ "hash": "f6808c24c886f1f2",
+ "ruleId": "SW002",
+ "filePath": "tests/BadgeSmith.Api.Tests/Routing/Patterns/TemplatePatternTests.cs",
+ "lineNumber": 251,
+ "codeSnippet": "SuppressMessage(\n \"Design\",\n \"MA0051:Method is too long\",\n Justification = \"Keeping the template scenarios in one MemberData source makes the route matrix auditable.\")",
+ "message": "SuppressMessage attribute suppressing Design:MA0051:Method is too long",
+ "baselinedAt": "2026-08-07T14:05:15.2493165+00:00"
+ },
+ {
+ "hash": "c4d2c16a0642f5af",
+ "ruleId": "SW002",
+ "filePath": "tests/BadgeSmith.Api.Tests/Routing/Patterns/RegexPatternTests.cs",
+ "lineNumber": 276,
+ "codeSnippet": "SuppressMessage(\n \"Design\",\n \"CA1024:Use properties where appropriate\",\n Justification = \"This iterator is an xUnit MemberData source and is clearer as a method.\")",
+ "message": "SuppressMessage attribute suppressing Design:CA1024:Use properties where appropriate",
+ "baselinedAt": "2026-08-07T14:05:15.2493234+00:00"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/.slopwatch/config.json.example b/.slopwatch/config.json.example
new file mode 100644
index 0000000..79850ff
--- /dev/null
+++ b/.slopwatch/config.json.example
@@ -0,0 +1,10 @@
+{
+ "suppressions": [
+ {
+ "ruleId": "SW002",
+ "pattern": "**/Generated/**",
+ "justification": "Generated code from protobuf/gRPC compiler - cannot be modified"
+ }
+ ],
+ "globalSuppressions": []
+}
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index 5eb5702..4679d5f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -36,13 +36,14 @@ adapters, not policy sources.
- Fix production-code bugs unless Deniz has explicitly asked you to fix, apply,
proceed, or equivalent.
- Modify build system behavior (`Directory.Build.props`, `Directory.Packages.props`,
- MSBuild, `Dockerfile`, `scripts/build-lambda.*`).
+ MSBuild, `Dockerfile`, `tools/badgesmith.cs`).
- Change CI/CD pipelines (`.github/workflows/**`).
- Change agent policy, approval gates, capability routing, skill triggers, or harness
adapter behavior.
- Weaken, skip, delete, or substantially rewrite tests to change what behavior is
verified.
-- Run CDK deploy, Lambda publish, `scripts/build-lambda` release, or any AWS mutation.
+- Run CDK deploy, Lambda publish, `tools/badgesmith.cs lambda build` release, or any
+ AWS mutation.
- Commit, amend, push, or create a PR.
Approval phrases include `go`, `apply`, `proceed`, `baΕla`, and `yap`.
@@ -108,12 +109,15 @@ Repository layout:
observability)
- `src/BadgeSmith.Host`: .NET Aspire AppHost for local development (LocalStack, Lambda
and API Gateway emulation, DynamoDB seeding)
-- `src/shared`: constants and ActivitySources shared via linked compilation
-- `build/`: AWS CDK infrastructure (shared constructs + production stack)
+- `src/shared`: constants, ActivitySources, and canonical security helpers shared via
+ linked compilation
+- `build/`: AWS CDK shared constructs plus separate production and local-performance
+ apps; see `build/BadgeSmith.CDK/README.md`
- `tests/BadgeSmith.Api.Tests`: xUnit v3 unit tests
- `tests/BadgeSmith.Api.Performance.Tests`: BenchmarkDotNet benchmarks
-- `tests/seeders`: DynamoDB seeding utility
-- `scripts/`: build, ingestion, and load-testing tooling
+- `tools/`: file-based `badgesmith` CLI (Lambda build, test run/ingest, badge update,
+ secrets seed); see `tools/README.md`
+- `scripts/`: remaining k6 load-test scenario and sample ingestion payload
- `docs/`: project documentation
- `docs/agents/`: harness adapter guide, capability mapping, and known agent notes
@@ -130,7 +134,9 @@ Repository layout:
## Harness Independence
- `AGENTS.md` is the canonical repository contract.
-- Harness-specific instructions and skill files are adapters, not policy sources.
+- Harness-specific instructions and discovery relays are adapters, not policy sources.
+ Canonical capability guides may live under `docs/agents/skills/`, but they cannot
+ override this contract.
- `CLAUDE.md` and `.github/copilot-instructions.md` are relay-only; OpenCode reads
`AGENTS.md` natively.
- Harness-native invocation names, LSP wiring, local-only setup notes, and skill
@@ -168,7 +174,12 @@ these constraints on every code change:
`xunit.runner.visualstudio`). Plain `dotnet test --project
tests/BadgeSmith.Api.Tests/BadgeSmith.Api.Tests.csproj` and standard `--filter` are
correct. This is NOT TUnit β ignore any `--treenode-filter` guidance.
-- Native AOT publishing goes through `scripts/build-lambda.{sh,ps1}` (multi-arch ZIP /
+- Test and benchmark method names use `Subject_Should_Expected_Behavior_When_Condition`.
+ Keep real code identifiers such as method, property, type, header, and route names
+ intact; separate all other human-readable words with underscores. `Should` belongs
+ immediately after the subject, and scenario/input conditions belong at the end with a
+ `When...` suffix.
+- Native AOT publishing goes through `tools/badgesmith.cs lambda build` (multi-arch ZIP /
container targets); it is not part of the ordinary `dotnet build` loop.
- Strict analyzers and warnings-as-errors are enabled through shared project
configuration; keep the zero-warning bar.
@@ -178,7 +189,9 @@ these constraints on every code change:
- Documentation-only changes do not require build/test unless they add or change
commands that should be validated.
- If Slopwatch is available after LLM-authored code, project, or test changes, run
- `slopwatch analyze --fail-on warning --exclude "artifacts/**,**/bin/**,**/obj/**"`.
+ `slopwatch analyze --fail-on warning --exclude "artifacts/**,external/**,**/bin/**,**/obj/**"`.
+ The existing baseline lives under `.slopwatch/`; `external/**` is excluded because it
+ contains ignored upstream source checkouts for source navigation, not BadgeSmith-owned code.
## Capability Routing
@@ -221,6 +234,14 @@ its trigger applies, and do not invent an ID.
| Performance work / benchmarks | Benchmark and performance-diagnostics capabilities; require measured data |
| Package version changes (`Directory.Packages.props`) | Package-management capability (CPM) |
+## Aspire Source Compatibility
+
+For read-only explanation questions, inspect this repository's docs/code first. Invoke `aspire-source-navigation` only when the answer depends on upstream internals, version-specific API shape, or a compatibility conclusion.
+
+## Aspire MCP Server
+
+Utilize Aspire MCP server for runtime resource state/logs/traces of CLI-launched AppHosts and LocalStack. And context7 for Aspire related documentation.
+
## Semantic Code Navigation
When Rider MCP tools are available, prefer semantic tools for C# symbol questions:
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 91dcd90..6d7ac8b 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -66,7 +66,7 @@ BadgeSmith prioritizes **cold start performance** and **deployment efficiency**:
- Reduce final binary size
- Improve cold start performance
-Controlled via build arguments in `Dockerfile` and `build-lambda.sh` scripts.
+Controlled via build arguments in `Dockerfile` and the `tools/badgesmith.cs lambda build` command.
## π **Data Architecture**
@@ -105,13 +105,16 @@ BadgeSmith uses **three DynamoDB tables** with optimized access patterns:
### **Database Seeding**
-The **`BadgeSmith.DynamoDb.Seeders`** project provides:
+The **`badgesmith secrets seed`** command (in `tools/badgesmith.cs`) provides:
- **Local development setup**: Seeds test data for LocalStack
- **Production deployment**: Can seed real AWS resources (with appropriate credentials)
- **Configuration-driven**: JSON-based organization and secret management
- **Idempotent operations**: Safe to run multiple times
+See `tools/README.md` for secret mapping format and the org-scoped secret name
+`badgesmith/github/{org}/{key}`.
+
## π¦ **Routing Infrastructure**
### **High-Performance Routing**
@@ -144,15 +147,70 @@ BadgeSmith implements **custom routing** optimized for Lambda environments:
## π **Security Architecture**
-### **HMAC Authentication Flow**
+### **Canonical HMAC Authentication**
+
+`POST /tests/results/{platform}/{owner}/{repo}/{branch}` accepts only canonical-request
+HMAC-SHA256 signatures. This contract is a hard cut: clients and the server must use the
+same newline-delimited UTF-8 message in this exact field order:
-**For test result ingestion endpoints:**
+```text
+BADGESMITH-HMAC
+POST
+/tests/results/{platform}/{owner}/{repo}/{branch}
+{timestamp}
+{nonce}
+{sha256-body}
+```
-1. **Organization Lookup**: Extract organization from route parameters
-2. **Secret Retrieval**: Query organization secrets from DynamoDB β Secrets Manager
-3. **Signature Validation**: HMAC-SHA256 verification with constant-time comparison
-4. **Replay Protection**: Nonce validation with DynamoDB conditional writes
-5. **Timestamp Validation**: 5-minute window with clock skew protection
+There is no trailing newline. Canonical fields follow these rules:
+
+- The path is the logical BadgeSmith ingestion route, without a deployment host, stage,
+ custom base path, or query string.
+- The decoded logical `platform`, `owner`, and `repo` values use `ToLowerInvariant()`.
+- The decoded `branch` case and value are preserved.
+- Every logical route segment is escaped independently with `Uri.EscapeDataString`.
+- `timestamp` and `nonce` are the trimmed `X-Timestamp` and `X-Nonce` header values.
+- `sha256-body` is lowercase hexadecimal SHA-256 over the exact UTF-8 request body.
+
+Clients must emit `X-Signature` as `sha256=` followed by exactly 64 lowercase
+HMAC-SHA256 hexadecimal characters. The verifier accepts case-insensitive scheme and
+digest casing, but producer output remains canonical. The HMAC key is the organization's
+`TestData` secret, separate from package-access credentials.
+
+Authentication validates that the timestamp is no more than five minutes old and no
+more than one minute in the future, resolves the organization-scoped secret, and
+compares the exact-length signature digest in fixed time. Only after that comparison
+succeeds is the trimmed nonce atomically marked in DynamoDB. A failed signature does not
+consume the nonce.
+
+Both `badgesmith tests ingest` and `badgesmith badge update` sign this canonical
+request. Their dry-run output may include the URL, payload, timestamp, and nonce, but
+never the signature or digest. Both commands require HTTPS; HTTP is accepted only for
+loopback hosts (`localhost`, `127.0.0.0/8`, or `::1`).
+
+### Upstream And Transport Modes
+
+`BADGESMITH_UPSTREAM_MODE` is an explicit `Live` or `Mock` contract. Missing values
+default to `Live`.
+
+- `Live` requires HTTPS for configured NuGet and GitHub upstream URLs. The Aspire
+ AppHost also requires `tools/organization-pat-mapping.json` and fails before startup
+ when it is missing.
+- `Mock` is accepted only by builds compiled with `ENABLE_LOCALSTACK`. Both
+ `HTTP_NUGET_BASE_URL` and `HTTP_GITHUB_BASE_URL` are required and may use HTTP for
+ test-owned WireMock endpoints. The contract fixture owns deterministic secret seeding.
+- Production CDK sets `Live` explicitly, and production builds reject `Mock` even if an
+ environment variable is misconfigured.
+
+Client commands that upload HMAC-authenticated test data do not inherit upstream mode.
+Their BadgeSmith API base URL always requires HTTPS, with HTTP allowed only for loopback
+development endpoints.
+
+The stored `url_html` value is the click target behind the public test-result redirect.
+It may point to dorny, Allure, ReportPortal, or another white-label HTTPS report host;
+it is not restricted to the GitHub workflow origin. Choosing that target is an explicit
+capability of an organization-authorized HMAC ingester. Both stored result URLs must be
+absolute HTTPS URLs without embedded credentials.
**Security Features:**
@@ -191,14 +249,25 @@ Package badge endpoints are **unauthenticated** but include:
## π οΈ **Development Tooling**
-### **Scripts Directory**
+### **`badgesmith` CLI**
+
+**`tools/badgesmith.cs`** is the file-based .NET CLI that owns BadgeSmith-specific
+build, test, ingestion, badge-update, and secret-seed workflows:
+
+- **`lambda build`**: Multi-arch Docker builds for Lambda deployment (ZIP and container)
+- **`tests run`**: Per-target-framework `dotnet test` execution with TRX output
+- **`tests ingest`**: HMAC-authenticated test result ingestion against a running API
+- **`badge update`**: GitHub Actions test result posting used by the `update-test-badge` workflow
+- **`secrets seed`**: Seeds GitHub org secret mappings into DynamoDB and Secrets Manager
-**`scripts/`** contains development and testing tooling:
+See `tools/README.md` for full option reference and secret mapping setup.
-- **`build-lambda.sh/.ps1`**: Multi-platform Docker builds for Lambda deployment
-- **`test-ingestion.sh/.ps1`**: HMAC authentication testing with real API calls
-- **`k6-perf-test.js`**: Load testing with realistic traffic patterns
-- **`sample-test-payload.json`**: Example test result payload
+### **`scripts/`**
+
+**`scripts/`** holds the remaining load-testing fixtures:
+
+- **`k6-perf-test.js`**: HTTP load testing with realistic traffic patterns
+- **`sample-test-payload.json`**: Example test result payload for `tests ingest`
## ποΈ **Code Organization**
@@ -238,12 +307,22 @@ BadgeSmith uses **OneOf result types** instead of exceptions for predictable err
### **AWS CDK Integration**
-**`build/`** directory contains **CDK infrastructure**:
+**`build/`** contains shared constructs and two separate .NET CDK app entrypoints:
+
+| Purpose | App project | CDK working directory | Native stack ID |
+| --- | --- | --- | --- |
+| Production | `build/BadgeSmith.CDK/BadgeSmith.CDK.csproj` | `build` | `BadgeSmithStack` |
+| Local performance | `build/BadgeSmith.CDK.LocalPerformance/BadgeSmith.CDK.LocalPerformance.csproj` | `build/BadgeSmith.CDK.LocalPerformance` | `BadgeSmithPerformanceStack` |
-- **Shared constructs**: Common infrastructure patterns
-- **Environment-agnostic**: Same code for local and production
-- **Type-safe**: .NET CDK with compile-time validation
-- **Aspire integration**: CDK stacks can be deployed from Aspire host
+The production app constructs only the production stack. Production deployment remains
+approval-gated, must target `BadgeSmithStack` explicitly, and must never use `--all`.
+The local-performance app constructs only LocalStack benchmarking infrastructure and is
+never deployed to AWS.
+
+The deferred `badgesmith perf baseline` command will consume the local-performance app
+as its infrastructure boundary when that command is implemented. See the
+[BadgeSmith CDK app guide](build/BadgeSmith.CDK/README.md) for the exact build and safe
+synthesis commands for each app.
### **Local Development**
@@ -251,6 +330,9 @@ BadgeSmith uses **OneOf result types** instead of exceptions for predictable err
- **LocalStack integration**: AWS service emulation
- **Lambda emulation**: Local function execution
+- **Contract tests**: Aspire Testing starts `src/BadgeSmith.Host` and calls `APIGatewayEmulator` over HTTP; the test suite does not use Lambda RIE.
+
+**Local benchmark execution** uses Docker, LocalStack, CDK, and k6. Production keeps API Gateway HTTP v2, but the local performance stack exposes a Lambda Function URL fallback because LocalStack Community 4.6 does not deploy API Gateway v2 CloudFormation resources in this workflow.
## π **Deployment Strategy**
@@ -262,9 +344,9 @@ BadgeSmith uses **OneOf result types** instead of exceptions for predictable err
2. **Lambda image**: Minimal runtime for container deployment
3. **Zip export**: Artifact generation for .zip deployment
-### **Build Scripts**
+### **Build Tooling**
-**`build-lambda.sh/.ps1`** provide **cross-platform build automation**:
+**`tools/badgesmith.cs lambda build`** provides **cross-platform build automation**:
- **Multi-architecture**: x64 and ARM64 support
- **Build targets**: ZIP artifacts and container images
@@ -288,21 +370,24 @@ BadgeSmith uses **OneOf result types** instead of exceptions for predictable err
## π **CI/CD Integration**
-### **Reusable Workflows**
+### **CI Composite Actions**
-**`.github/workflows/`** contains **reusable GitHub Actions**:
+**`.github/workflows/`** contains two composite actions with different scopes:
-- **`run-dotnet-tests/`**: Multi-framework test execution
-- **`update-test-badge/`**: HMAC-authenticated badge updates
+- **`run-dotnet-tests/`**: Repository-local multi-framework test execution
+- **`update-test-badge/`**: Remotely reusable HMAC-authenticated badge updates
- **Cross-platform support**: Windows, Linux, macOS
-### **Self-Hosting Validation**
+### **Hosted Validation**
-BadgeSmith **validates itself** through CI/CD integration:
+Eligible pull requests run the Release build, the full test suite, and the hosted ARM64
+Lambda ZIP build. Live test-result publication is intentionally narrower:
-- **Real authentication**: HMAC signatures generated and validated
-- **Live API calls**: Test results posted to production API
-- **End-to-end verification**: Complete pipeline tested on every commit
+- **Pull requests**: Build, tests, and ARM64 artifact validation without production
+ mutation
+- **Master pushes**: The same checks plus a best-effort authenticated badge update
+- **Production CDK synth/deploy**: Separate approval-gated deployment workflow, not part
+ of the ordinary PR CI pipeline
---
diff --git a/BadgeSmith.sln b/BadgeSmith.sln
index c383c34..fd6e2ab 100644
--- a/BadgeSmith.sln
+++ b/BadgeSmith.sln
@@ -21,28 +21,13 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BadgeSmith.Api", "src\Badge
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BadgeSmith.Host", "src\BadgeSmith.Host\BadgeSmith.Host.csproj", "{100AC8DB-2F97-4AAA-836C-8450E303178F}"
EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "seeders", "seeders", "{33D32002-657D-4B16-AD01-798A155FCE74}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BadgeSmith.DynamoDb.Seeders", "tests\seeders\BadgeSmith.DynamoDb.Seeders\BadgeSmith.DynamoDb.Seeders.csproj", "{98740979-F93E-42E6-B980-4B9A25C6D091}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BadgeSmith.CDK.LocalPerformance", "build\BadgeSmith.CDK.LocalPerformance\BadgeSmith.CDK.LocalPerformance.csproj", "{42734231-9C92-42C8-A482-B08FA2C7E987}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
- GlobalSection(NestedProjects) = preSolution
- {3BCA21AD-AF9D-4109-BBC6-95D359BB5C4B} = {A170FF5C-846B-451D-BBD1-F0CDECAA193A}
- {2DD5358F-35AF-485C-8402-3470D30A8098} = {A170FF5C-846B-451D-BBD1-F0CDECAA193A}
- {FBB319BF-EEA0-426E-B725-059278D8BAAA} = {AE134941-6C89-40C3-92DD-7F43C2469DC8}
- {E80E098F-A280-466B-B579-C7BAA20CD3E0} = {AE134941-6C89-40C3-92DD-7F43C2469DC8}
- {BE430EDD-22E5-4F95-A80D-089E1327735D} = {8D4B8CC2-BC23-41FA-84E7-ECACDC79E9F4}
- {100AC8DB-2F97-4AAA-836C-8450E303178F} = {8D4B8CC2-BC23-41FA-84E7-ECACDC79E9F4}
- {33D32002-657D-4B16-AD01-798A155FCE74} = {A170FF5C-846B-451D-BBD1-F0CDECAA193A}
- {98740979-F93E-42E6-B980-4B9A25C6D091} = {33D32002-657D-4B16-AD01-798A155FCE74}
- EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{3BCA21AD-AF9D-4109-BBC6-95D359BB5C4B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3BCA21AD-AF9D-4109-BBC6-95D359BB5C4B}.Debug|Any CPU.Build.0 = Debug|Any CPU
@@ -68,9 +53,21 @@ Global
{100AC8DB-2F97-4AAA-836C-8450E303178F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{100AC8DB-2F97-4AAA-836C-8450E303178F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{100AC8DB-2F97-4AAA-836C-8450E303178F}.Release|Any CPU.Build.0 = Release|Any CPU
- {98740979-F93E-42E6-B980-4B9A25C6D091}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {98740979-F93E-42E6-B980-4B9A25C6D091}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {98740979-F93E-42E6-B980-4B9A25C6D091}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {98740979-F93E-42E6-B980-4B9A25C6D091}.Release|Any CPU.Build.0 = Release|Any CPU
+ {42734231-9C92-42C8-A482-B08FA2C7E987}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {42734231-9C92-42C8-A482-B08FA2C7E987}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {42734231-9C92-42C8-A482-B08FA2C7E987}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {42734231-9C92-42C8-A482-B08FA2C7E987}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {3BCA21AD-AF9D-4109-BBC6-95D359BB5C4B} = {A170FF5C-846B-451D-BBD1-F0CDECAA193A}
+ {2DD5358F-35AF-485C-8402-3470D30A8098} = {A170FF5C-846B-451D-BBD1-F0CDECAA193A}
+ {FBB319BF-EEA0-426E-B725-059278D8BAAA} = {AE134941-6C89-40C3-92DD-7F43C2469DC8}
+ {E80E098F-A280-466B-B579-C7BAA20CD3E0} = {AE134941-6C89-40C3-92DD-7F43C2469DC8}
+ {BE430EDD-22E5-4F95-A80D-089E1327735D} = {8D4B8CC2-BC23-41FA-84E7-ECACDC79E9F4}
+ {100AC8DB-2F97-4AAA-836C-8450E303178F} = {8D4B8CC2-BC23-41FA-84E7-ECACDC79E9F4}
+ {42734231-9C92-42C8-A482-B08FA2C7E987} = {AE134941-6C89-40C3-92DD-7F43C2469DC8}
EndGlobalSection
EndGlobal
diff --git a/Directory.Packages.props b/Directory.Packages.props
index aa162e7..327c73d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -1,63 +1,72 @@
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
-
-
-
-
+
+
+
+
+
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index 5af5e6c..3c6692f 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,6 @@
[](https://dotnet.microsoft.com/)
[](https://aws.amazon.com/lambda/)
[](https://docs.microsoft.com/en-us/dotnet/core/deploying/native-aot/)
-[](https://api.localstackfor.net/redirect/test-results/linux/localstack-dotnet/badge-smith/master)
> **Badge service** for .NET packages and CI/CD test results with secure authentication and performance optimizations.
@@ -14,12 +13,6 @@
## π **Live Examples**
-### **This Repository**
-
-BadgeSmith badges itself using its own API:
-
-[](https://api.localstackfor.net/redirect/test-results/linux/localstack-dotnet/badge-smith/master)
-
### **LocalStack.NET Client Examples**
π¦ **LocalStack.NET Client v1.x**
@@ -32,10 +25,17 @@ BadgeSmith badges itself using its own API:
### **π Secure Authentication**
-- **HMAC-SHA256 authentication** with replay protection for test ingestion
-- **AWS Secrets Manager** integration for credential management
-- **Nonce-based replay prevention** using DynamoDB
-- **Organization-level access control** with token type separation
+- **Canonical HMAC-SHA256 authentication** binds the method, logical ingestion route,
+ timestamp, nonce, and exact request body
+- **Organization-scoped `TestData` secrets** are isolated from package credentials
+- **Timestamp validation** accepts requests up to five minutes old with at most one
+ minute of future clock skew
+- **Nonce-based replay prevention** atomically marks the nonce only after fixed-time
+ signature verification succeeds
+
+Canonical request construction is a hard-cut contract. See
+**[ARCHITECTURE.md](ARCHITECTURE.md#canonical-hmac-authentication)** for the exact field
+order, normalization, escaping, and signature envelope.
### **β‘ Performance Optimizations**
@@ -84,9 +84,6 @@ https://api.localstackfor.net/badges/packages/nuget/Newtonsoft.Json
# GitHub package with version filtering
https://api.localstackfor.net/badges/packages/github/localstack-dotnet/localstack.client?version=(1.0,2.0)
-
-# Test results for this repository
-https://api.localstackfor.net/badges/tests/linux/localstack-dotnet/badge-smith/master
```
## ποΈ **Architecture**
@@ -115,50 +112,63 @@ For detailed architectural decisions, performance considerations, data design, a
```markdown

-
+[](https://api.localstackfor.net/redirect/test-results/linux/your-org/your-repo/main)
```
### **Self-Hosting**
```bash
-# Clone and deploy
+# Clone and compile the production CDK app (does not deploy)
git clone https://github.com/localstack-dotnet/badge-smith.git
-cd badge-smith/build
-dotnet run --project BadgeSmith.CDK
+cd badge-smith
+dotnet build build/BadgeSmith.CDK/BadgeSmith.CDK.csproj -c Release
```
+BadgeSmith has separate production and LocalStack-only performance CDK apps. Production
+CDK commands run from `build` and target `BadgeSmithStack`; local-performance commands
+run from `build/BadgeSmith.CDK.LocalPerformance` and target
+`BadgeSmithPerformanceStack`. See the [CDK app guide](build/BadgeSmith.CDK/README.md)
+for the required Lambda artifacts and safe synthesis commands. Production deployment is
+approval-gated and must never use `--all`; the local-performance app is not deployed to
+AWS.
+
### **Local Development**
```bash
-# Start with .NET Aspire + LocalStack
-dotnet run --project src/BadgeSmith.Host
+# Live upstream mode requires local Package and TestData secrets.
+cp tools/organization-pat-mapping.json.dist tools/organization-pat-mapping.json
+# Edit the copied file, then start .NET Aspire + LocalStack.
+aspire start --apphost src/BadgeSmith.Host/BadgeSmith.Host.csproj --non-interactive
```
-## π **CI/CD Integration**
+The AppHost defaults to `BADGESMITH_UPSTREAM_MODE=Live`. Contract tests explicitly use
+`Mock`, route both package upstreams to WireMock, and own their fake secret seeding.
-### **GitHub Actions**
+### **Tooling**
-Copy the reusable workflows to your repository:
+The `badgesmith` file-based CLI (`tools/badgesmith.cs`) owns Lambda builds,
+test runs, test-result ingestion, badge updates, and secret seeding. See
+[`tools/README.md`](tools/README.md) for the full command reference.
```bash
-cp -r .github/workflows/run-dotnet-tests/ your-repo/.github/workflows/
-cp -r .github/workflows/update-test-badge/ your-repo/.github/workflows/
-```
+# Local AOT/LocalStack validation
+./tools/badgesmith.cs lambda build --target zip --rid linux-x64 --clean
-Then use in your workflow:
-
-```yaml
-- name: Update test badge
- uses: ./.github/workflows/update-test-badge
- with:
- platform: 'Linux'
- test_passed: '${{ steps.test-results.outputs.passed }}'
- test_failed: '${{ steps.test-results.outputs.failed }}'
- test_skipped: '${{ steps.test-results.outputs.skipped }}'
- hmac_secret: '${{ secrets.TESTDATASECRET }}'
- api_domain: 'api.localstackfor.net'
+# Production artifact; requires an ARM64-capable builder and is validated in hosted CI
+./tools/badgesmith.cs lambda build --target zip --rid linux-arm64 --clean
```
+## π **CI/CD Integration**
+
+### **GitHub Actions**
+
+The remotely reusable badge action posts test results to a BadgeSmith deployment. See the
+[action guide](.github/workflows/update-test-badge/README.md) for the canonical input
+list and supported major action tag.
+
+The repository-local `run-dotnet-tests` action is an internal BadgeSmith workflow
+helper, not a portable test-runner contract.
+
## π’ **LocalStack.NET Organization**
While designed as a **white-label solution**, BadgeSmith was created to serve the [LocalStack.NET organization](https://github.com/localstack-dotnet) badge requirements:
@@ -191,8 +201,9 @@ BadgeSmith demonstrates current .NET development practices:
### **Reusable CDK Patterns**
-- Environment-agnostic infrastructure design
-- Shared constructs between local and production for deployment consistency
+- Separate production and LocalStack-only performance app entrypoints
+- Native stack selection with `BadgeSmithStack` and `BadgeSmithPerformanceStack`
+- Shared constructs across the two app boundaries
- Type-safe infrastructure with .NET CDK
## π€ **Contributing**
diff --git a/build/BadgeSmith.CDK.LocalPerformance/BadgeSmith.CDK.LocalPerformance.csproj b/build/BadgeSmith.CDK.LocalPerformance/BadgeSmith.CDK.LocalPerformance.csproj
new file mode 100644
index 0000000..0fc3f2f
--- /dev/null
+++ b/build/BadgeSmith.CDK.LocalPerformance/BadgeSmith.CDK.LocalPerformance.csproj
@@ -0,0 +1,23 @@
+
+
+
+ Exe
+ $(DefaultTargetFramework)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build/BadgeSmith.CDK.LocalPerformance/Program.cs b/build/BadgeSmith.CDK.LocalPerformance/Program.cs
new file mode 100644
index 0000000..75e5c28
--- /dev/null
+++ b/build/BadgeSmith.CDK.LocalPerformance/Program.cs
@@ -0,0 +1,92 @@
+using Amazon.CDK;
+using BadgeSmith.CDK.Shared;
+using static BadgeSmith.Constants;
+
+var app = new App();
+
+var env = CreateLocalPerformanceEnvironment(app);
+var localPerformanceSettings = CreateLocalPerformanceSettings(app);
+
+_ = new LocalPerformanceStack(app, LocalPerformanceStackId, localPerformanceSettings, new StackProps
+{
+ Env = env,
+ Description = "BadgeSmith local performance infrastructure for LocalStack benchmarking",
+});
+
+app.Synth();
+
+static Amazon.CDK.Environment CreateLocalPerformanceEnvironment(App app)
+{
+ return new Amazon.CDK.Environment
+ {
+ Account = GetRequiredEnvironmentValue(app, "account", "CDK_DEFAULT_ACCOUNT"),
+ Region = GetRequiredEnvironmentValue(app, "region", "CDK_DEFAULT_REGION"),
+ };
+}
+
+static string GetRequiredEnvironmentValue(App app, string contextKey, string environmentVariable)
+{
+ var value = app.Node.TryGetContext(contextKey) as string
+ ?? System.Environment.GetEnvironmentVariable(environmentVariable);
+
+ return !string.IsNullOrWhiteSpace(value)
+ ? value
+ : throw new InvalidOperationException(
+ $"CDK environment value '{contextKey}' is required. Set CDK context '{contextKey}' or {environmentVariable}.");
+}
+
+static LocalPerformanceStackSettings CreateLocalPerformanceSettings(App app)
+{
+ var lambdaAssetPath = GetContextValue(app, "lambdaZipPath", "../../artifacts/badge-lambda-linux-x64.zip");
+ var lambdaArchitecture = GetLambdaArchitecture(GetContextValue(app, "lambdaArchitecture", "x86_64"));
+ var httpNuGetBaseUrl = GetLiveUpstreamUrl(app, "httpNuGetBaseUrl", "https://api.nuget.org/");
+ var httpGitHubBaseUrl = GetLiveUpstreamUrl(app, "httpGitHubBaseUrl", "https://api.github.com/");
+#pragma warning disable S5332 // LocalStack container endpoint is HTTP-only inside the Docker network.
+ var localStackEndpoint = GetContextValue(app, "localStackEndpoint", "http://localstack:4566");
+#pragma warning restore S5332
+
+ return new LocalPerformanceStackSettings(
+ lambdaAssetPath,
+ lambdaArchitecture,
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["AWS_ENDPOINT_URL"] = localStackEndpoint,
+ ["AWS_ENDPOINT_URL_DYNAMODB"] = localStackEndpoint,
+ ["AWS_ENDPOINT_URL_SECRETS_MANAGER"] = localStackEndpoint,
+ ["AWS_ENDPOINT_URL_SECRETSMANAGER"] = localStackEndpoint,
+ ["HTTP_NUGET_BASE_URL"] = httpNuGetBaseUrl,
+ ["HTTP_GITHUB_BASE_URL"] = httpGitHubBaseUrl,
+ });
+}
+
+static string GetContextValue(App app, string key, string defaultValue)
+{
+ return app.Node.TryGetContext(key) as string ?? defaultValue;
+}
+
+static string GetLiveUpstreamUrl(App app, string key, string defaultValue)
+{
+ var value = GetContextValue(app, key, defaultValue);
+ if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)
+ || string.IsNullOrWhiteSpace(uri.Host)
+ || !string.IsNullOrEmpty(uri.UserInfo)
+ || !string.IsNullOrEmpty(uri.Query)
+ || !string.IsNullOrEmpty(uri.Fragment)
+ || uri.Scheme != Uri.UriSchemeHttps)
+ {
+ throw new InvalidOperationException(
+ $"CDK context '{key}' must be an absolute HTTPS URL without credentials, query, or fragment in Live mode.");
+ }
+
+ return value;
+}
+
+static Amazon.CDK.AWS.Lambda.Architecture GetLambdaArchitecture(string value)
+{
+ return value switch
+ {
+ "x86_64" => Amazon.CDK.AWS.Lambda.Architecture.X86_64,
+ "arm64" => Amazon.CDK.AWS.Lambda.Architecture.ARM_64,
+ _ => throw new ArgumentException("lambdaArchitecture must be either 'x86_64' or 'arm64'.", nameof(value)),
+ };
+}
diff --git a/build/BadgeSmith.CDK.LocalPerformance/README.md b/build/BadgeSmith.CDK.LocalPerformance/README.md
new file mode 100644
index 0000000..d47a7af
--- /dev/null
+++ b/build/BadgeSmith.CDK.LocalPerformance/README.md
@@ -0,0 +1,41 @@
+# BadgeSmith local-performance CDK app
+
+This project owns the LocalStack-only infrastructure used for repeatable local
+performance measurements. It is separate from the [production CDK app](../BadgeSmith.CDK/README.md)
+because the apps use different environments and Lambda ZIP architectures.
+
+- Project: `build/BadgeSmith.CDK.LocalPerformance/BadgeSmith.CDK.LocalPerformance.csproj`
+- CDK working directory: `build/BadgeSmith.CDK.LocalPerformance`
+- CDK config: `build/BadgeSmith.CDK.LocalPerformance/cdk.json`
+- Native stack ID: `BadgeSmithPerformanceStack`
+- Lambda ZIP default: `../../artifacts/badge-lambda-linux-x64.zip`
+- Upstream mode: explicit `Live`
+
+The deployment workflow is the source of truth for pinned Node.js and AWS CDK CLI
+versions. Use matching local CLI versions instead of duplicating them here.
+
+Build and synthesize the local infrastructure:
+
+```bash
+dotnet build build/BadgeSmith.CDK.LocalPerformance/BadgeSmith.CDK.LocalPerformance.csproj -c Release
+tools/badgesmith.cs lambda build --target zip --rid linux-x64 --verbose
+cd build/BadgeSmith.CDK.LocalPerformance
+cdklocal synth BadgeSmithPerformanceStack \
+ --context account=000000000000 \
+ --context region=us-east-1
+```
+
+Account and region are required from CDK context or the `CDK_DEFAULT_ACCOUNT` and
+`CDK_DEFAULT_REGION` environment variables. The app fails before synthesis when either
+value is missing.
+
+Additional context values:
+
+- `lambdaZipPath` (default `../../artifacts/badge-lambda-linux-x64.zip`)
+- `lambdaArchitecture` (`x86_64` or `arm64`, default `x86_64`)
+- `localStackEndpoint` (default `http://localstack:4566`)
+- `httpNuGetBaseUrl` (default `https://api.nuget.org/`)
+- `httpGitHubBaseUrl` (default `https://api.github.com/`)
+
+Never deploy this app to AWS. Its x64 ZIP build and `cdklocal synth` are the normal
+local AOT and infrastructure checks.
diff --git a/build/BadgeSmith.CDK.LocalPerformance/cdk.json b/build/BadgeSmith.CDK.LocalPerformance/cdk.json
new file mode 100644
index 0000000..6bbc432
--- /dev/null
+++ b/build/BadgeSmith.CDK.LocalPerformance/cdk.json
@@ -0,0 +1,96 @@
+{
+ "app": "dotnet run --project ./BadgeSmith.CDK.LocalPerformance.csproj",
+ "context": {
+ "@aws-cdk/aws-lambda:recognizeLayerVersion": true,
+ "@aws-cdk/core:checkSecretUsage": true,
+ "@aws-cdk/core:target-partitions": [
+ "aws",
+ "aws-cn"
+ ],
+ "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true,
+ "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true,
+ "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true,
+ "@aws-cdk/aws-iam:minimizePolicies": true,
+ "@aws-cdk/core:validateSnapshotRemovalPolicy": true,
+ "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true,
+ "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true,
+ "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true,
+ "@aws-cdk/aws-apigateway:disableCloudWatchRole": true,
+ "@aws-cdk/core:enablePartitionLiterals": true,
+ "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true,
+ "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true,
+ "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true,
+ "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
+ "@aws-cdk/aws-route53-patters:useCertificate": true,
+ "@aws-cdk/customresources:installLatestAwsSdkDefault": false,
+ "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true,
+ "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true,
+ "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true,
+ "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true,
+ "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true,
+ "@aws-cdk/aws-redshift:columnId": true,
+ "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true,
+ "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true,
+ "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true,
+ "@aws-cdk/aws-kms:aliasNameRef": true,
+ "@aws-cdk/aws-kms:applyImportedAliasPermissionsToPrincipal": true,
+ "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true,
+ "@aws-cdk/core:includePrefixInUniqueNameGeneration": true,
+ "@aws-cdk/aws-efs:denyAnonymousAccess": true,
+ "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true,
+ "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true,
+ "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true,
+ "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true,
+ "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true,
+ "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true,
+ "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true,
+ "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true,
+ "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true,
+ "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true,
+ "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true,
+ "@aws-cdk/aws-eks:nodegroupNameAttribute": true,
+ "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true,
+ "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true,
+ "@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false,
+ "@aws-cdk/aws-s3:keepNotificationInImportedBucket": false,
+ "@aws-cdk/core:explicitStackTags": true,
+ "@aws-cdk/aws-ecs:enableImdsBlockingDeprecatedFeature": false,
+ "@aws-cdk/aws-ecs:disableEcsImdsBlocking": true,
+ "@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true,
+ "@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": true,
+ "@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true,
+ "@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true,
+ "@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true,
+ "@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true,
+ "@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true,
+ "@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": true,
+ "@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault": true,
+ "@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource": true,
+ "@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault": true,
+ "@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections": true,
+ "@aws-cdk/core:enableAdditionalMetadataCollection": true,
+ "@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy": false,
+ "@aws-cdk/aws-s3:setUniqueReplicationRoleName": true,
+ "@aws-cdk/aws-events:requireEventBusPolicySid": true,
+ "@aws-cdk/core:aspectPrioritiesMutating": true,
+ "@aws-cdk/aws-dynamodb:retainTableReplica": true,
+ "@aws-cdk/aws-stepfunctions:useDistributedMapResultWriterV2": true,
+ "@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions": true,
+ "@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway": true,
+ "@aws-cdk/aws-s3:publicAccessBlockedByDefault": true,
+ "@aws-cdk/aws-lambda:useCdkManagedLogGroup": true,
+ "@aws-cdk/aws-batch:defaultToAL2023": true,
+ "@aws-cdk/aws-cloudfront:defaultFunctionRuntimeV2_0": true,
+ "@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener": true,
+ "@aws-cdk/aws-ecs-patterns:uniqueTargetGroupId": true,
+ "@aws-cdk/aws-eks:defaultToAL2023": true,
+ "@aws-cdk/aws-eks:useNativeOidcProvider": true,
+ "@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault": true,
+ "@aws-cdk/aws-elasticloadbalancingv2:usePostQuantumTlsPolicy": true,
+ "@aws-cdk/aws-route53-patterns:useDistribution": true,
+ "@aws-cdk/aws-signer:signingProfileNamePassedToCfn": true,
+ "@aws-cdk/core:annotationsInValidationReport": true,
+ "@aws-cdk/core:defaultCrossStackReferences": "weak",
+ "@aws-cdk/core:validateAgainstDefaultRules": true
+ }
+}
diff --git a/build/BadgeSmith.CDK.Shared/Constructs/BadgeSmithFunctionConstruct.cs b/build/BadgeSmith.CDK.Shared/Constructs/BadgeSmithFunctionConstruct.cs
index 4452209..b9c9500 100644
--- a/build/BadgeSmith.CDK.Shared/Constructs/BadgeSmithFunctionConstruct.cs
+++ b/build/BadgeSmith.CDK.Shared/Constructs/BadgeSmithFunctionConstruct.cs
@@ -20,34 +20,65 @@ public BadgeSmithFunctionConstruct(
ITable nonceTable,
ITable orgSecretTable,
IRole lambdaExecutionRole,
- string id) : base(scope, id)
+ string id)
+ : this(
+ scope,
+ testResultsTable,
+ nonceTable,
+ orgSecretTable,
+ lambdaExecutionRole,
+ id,
+ BadgeSmithFunctionConfiguration.Production)
+ {
+ }
+
+ public BadgeSmithFunctionConstruct(
+ Construct scope,
+ ITable testResultsTable,
+ ITable nonceTable,
+ ITable orgSecretTable,
+ IRole lambdaExecutionRole,
+ string id,
+ BadgeSmithFunctionConfiguration configuration) : base(scope, id)
{
ArgumentNullException.ThrowIfNull(scope);
ArgumentNullException.ThrowIfNull(testResultsTable);
ArgumentNullException.ThrowIfNull(nonceTable);
ArgumentNullException.ThrowIfNull(orgSecretTable);
ArgumentNullException.ThrowIfNull(lambdaExecutionRole);
+ ArgumentNullException.ThrowIfNull(configuration);
+
+ var environment = new Dictionary(StringComparer.Ordinal)
+ {
+ ["DOTNET_ENVIRONMENT"] = "Production",
+ ["APP_NAME"] = LambdaName,
+ ["APP_ENABLE_TELEMETRY_FACTORY_PERF_LOGS"] = "true",
+ [UpstreamModeEnvironmentVariable] = UpstreamModeLive,
+ ["AWS_RESOURCE_TEST_RESULTS_TABLE"] = testResultsTable.TableName,
+ ["AWS_RESOURCE_NONCE_TABLE"] = nonceTable.TableName,
+ ["AWS_RESOURCE_ORG_SECRETS_TABLE"] = orgSecretTable.TableName,
+ // ["AWS_LAMBDA_EXEC_WRAPPER"] = "/opt/otel-instrument", // For future OpenTelemetry support
+ };
+
+ if (configuration.ExtraEnvironment is not null)
+ {
+ foreach (var (key, value) in configuration.ExtraEnvironment)
+ {
+ environment[key] = value;
+ }
+ }
BadgeSmithFunction = new Function(this, LambdaId, new FunctionProps
{
FunctionName = LambdaName,
Runtime = Runtime.PROVIDED_AL2023,
- Code = Code.FromAsset("../artifacts/badge-lambda-linux-arm64.zip"),
+ Code = Code.FromAsset(configuration.AssetPath),
Handler = "bootstrap",
Role = lambdaExecutionRole,
Timeout = Duration.Seconds(LambdaTimeoutInSeconds),
MemorySize = 512,
- Architecture = Architecture.ARM_64,
- Environment = new Dictionary(StringComparer.Ordinal)
- {
- ["DOTNET_ENVIRONMENT"] = "Production",
- ["APP_NAME"] = LambdaName,
- ["APP_ENABLE_TELEMETRY_FACTORY_PERF_LOGS"] = "true",
- ["AWS_RESOURCE_TEST_RESULTS_TABLE"] = testResultsTable.TableName,
- ["AWS_RESOURCE_NONCE_TABLE"] = nonceTable.TableName,
- ["AWS_RESOURCE_ORG_SECRETS_TABLE"] = orgSecretTable.TableName,
- // ["AWS_LAMBDA_EXEC_WRAPPER"] = "/opt/otel-instrument", // For future OpenTelemetry support
- },
+ Architecture = configuration.Architecture,
+ Environment = environment,
Description = "BadgeSmith Native AOT Lambda function for badge generation",
});
@@ -60,3 +91,13 @@ public BadgeSmithFunctionConstruct(
public Function BadgeSmithFunction { get; }
}
+
+public sealed record BadgeSmithFunctionConfiguration(
+ string AssetPath,
+ Architecture Architecture,
+ IReadOnlyDictionary? ExtraEnvironment = null)
+{
+ public static BadgeSmithFunctionConfiguration Production { get; } = new(
+ "../artifacts/badge-lambda-linux-arm64.zip",
+ Architecture.ARM_64);
+}
diff --git a/build/BadgeSmith.CDK.Shared/Constructs/BadgeSmithHttpApiConstruct.cs b/build/BadgeSmith.CDK.Shared/Constructs/BadgeSmithHttpApiConstruct.cs
new file mode 100644
index 0000000..0b759ca
--- /dev/null
+++ b/build/BadgeSmith.CDK.Shared/Constructs/BadgeSmithHttpApiConstruct.cs
@@ -0,0 +1,31 @@
+using Amazon.CDK.AWS.Apigatewayv2;
+using Amazon.CDK.AWS.Lambda;
+using Amazon.CDK.AwsApigatewayv2Integrations;
+using Constructs;
+using static BadgeSmith.Constants;
+
+namespace BadgeSmith.CDK.Shared.Constructs;
+
+///
+/// HTTP API Gateway configured with BadgeSmith's Lambda proxy integration.
+///
+public sealed class BadgeSmithHttpApiConstruct : HttpApi
+{
+ public BadgeSmithHttpApiConstruct(Construct scope, string id, IFunction badgeSmithFunction)
+ : base(scope, id, new HttpApiProps
+ {
+ ApiName = ApiGatewayName,
+ Description = "BadgeSmith API Gateway for badge endpoints",
+ DefaultIntegration = CreateLambdaIntegration(badgeSmithFunction),
+ })
+ {
+ }
+
+ private static HttpLambdaIntegration CreateLambdaIntegration(IFunction badgeSmithFunction)
+ {
+ ArgumentNullException.ThrowIfNull(badgeSmithFunction);
+ return new HttpLambdaIntegration(HttpLambdaIntegrationId, badgeSmithFunction);
+ }
+
+ public HttpApi ApiGateway => this;
+}
diff --git a/build/BadgeSmith.CDK.Shared/Constructs/DynamoDbTablesConstruct.cs b/build/BadgeSmith.CDK.Shared/Constructs/DynamoDbTablesConstruct.cs
index 89676c6..c0b2c25 100644
--- a/build/BadgeSmith.CDK.Shared/Constructs/DynamoDbTablesConstruct.cs
+++ b/build/BadgeSmith.CDK.Shared/Constructs/DynamoDbTablesConstruct.cs
@@ -1,4 +1,4 @@
-#pragma warning disable CA1711, MA0051
+#pragma warning disable MA0051 // Keeping related table definitions together makes their shared policy visible.
using Amazon.CDK;
using Amazon.CDK.AWS.DynamoDB;
@@ -123,3 +123,5 @@ public DynamoDbTablesConstruct(Construct scope, IRole lambdaExecutionRole, strin
public Table OrgSecretsTable { get; }
}
+
+#pragma warning restore MA0051
diff --git a/build/BadgeSmith.CDK.Shared/Constructs/SharedInfrastructureConstruct.cs b/build/BadgeSmith.CDK.Shared/Constructs/SharedInfrastructureConstruct.cs
index b5674d8..a234460 100644
--- a/build/BadgeSmith.CDK.Shared/Constructs/SharedInfrastructureConstruct.cs
+++ b/build/BadgeSmith.CDK.Shared/Constructs/SharedInfrastructureConstruct.cs
@@ -1,5 +1,3 @@
-#pragma warning disable CA1711, MA0051, MA0056
-
using Amazon.CDK.AWS.DynamoDB;
using Amazon.CDK.AWS.IAM;
using Constructs;
diff --git a/build/BadgeSmith.CDK.Shared/LocalPerformanceStack.cs b/build/BadgeSmith.CDK.Shared/LocalPerformanceStack.cs
new file mode 100644
index 0000000..46e09e5
--- /dev/null
+++ b/build/BadgeSmith.CDK.Shared/LocalPerformanceStack.cs
@@ -0,0 +1,117 @@
+#pragma warning disable CA1711 // AWS CDK stack types intentionally use the Stack suffix.
+
+using Amazon.CDK;
+using Amazon.CDK.AWS.Apigatewayv2;
+using Amazon.CDK.AWS.DynamoDB;
+using Amazon.CDK.AWS.Lambda;
+using BadgeSmith.CDK.Shared.Constructs;
+using Constructs;
+using Function = Amazon.CDK.AWS.Lambda.Function;
+using static BadgeSmith.Constants;
+
+namespace BadgeSmith.CDK.Shared;
+
+///
+/// LocalStack-only stack for running local performance baselines without production edge resources.
+///
+public sealed class LocalPerformanceStack : Stack
+{
+ public LocalPerformanceStack(
+ Construct scope,
+ string id,
+ LocalPerformanceStackSettings settings,
+ IStackProps? props = null) : base(scope, id, props)
+ {
+ ArgumentNullException.ThrowIfNull(settings);
+
+ SharedInfrastructureConstruct = new SharedInfrastructureConstruct(this, SharedInfrastructureConstructId);
+
+ TestResultsTable = SharedInfrastructureConstruct.TestResultsTable;
+ NonceTable = SharedInfrastructureConstruct.NonceTable;
+ OrgSecretsTable = SharedInfrastructureConstruct.OrgSecretsTable;
+
+ BadgeSmithFunctionConstruct = new BadgeSmithFunctionConstruct(
+ this,
+ TestResultsTable,
+ NonceTable,
+ OrgSecretsTable,
+ SharedInfrastructureConstruct.LambdaExecutionRole,
+ LambdaConstructId,
+ new BadgeSmithFunctionConfiguration(
+ settings.LambdaAssetPath,
+ settings.LambdaArchitecture,
+ settings.LambdaEnvironment));
+
+ BadgeSmithFunction = BadgeSmithFunctionConstruct.BadgeSmithFunction;
+ BadgeSmithFunctionUrl = BadgeSmithFunction.AddFunctionUrl(new FunctionUrlOptions
+ {
+ AuthType = FunctionUrlAuthType.NONE,
+ });
+
+ var httpApiConstruct = new BadgeSmithHttpApiConstruct(this, ApiGatewayRoleId, BadgeSmithFunction);
+ ApiGateway = httpApiConstruct.ApiGateway;
+ Amazon.CDK.Tags.Of(httpApiConstruct).Add("_custom_id_", ApiGatewayName);
+
+ CreateOutputs();
+
+ Tags.SetTag("environment", "LocalPerformance");
+ Tags.SetTag("stack", "badge-smith-local-performance");
+ Tags.SetTag("managed-by", "perf-baseline");
+ }
+
+ private void CreateOutputs()
+ {
+ _ = new CfnOutput(this, ApiGatewayOutputUrl, new CfnOutputProps
+ {
+ Value = ApiGateway.ApiEndpoint,
+ Description = "API Gateway endpoint URL",
+ });
+
+ _ = new CfnOutput(this, LambdaOutputFunctionUrl, new CfnOutputProps
+ {
+ Value = BadgeSmithFunctionUrl.Url,
+ Description = "Lambda Function URL for local performance fallback",
+ });
+
+ _ = new CfnOutput(this, TestResultsOutputTableName, new CfnOutputProps
+ {
+ Value = TestResultsTable.TableName,
+ Description = "DynamoDB table name for test results",
+ });
+
+ _ = new CfnOutput(this, NonceTableOutputTableName, new CfnOutputProps
+ {
+ Value = NonceTable.TableName,
+ Description = "DynamoDB table name for nonce",
+ });
+
+ _ = new CfnOutput(this, OrgSecretsOutputTableName, new CfnOutputProps
+ {
+ Value = OrgSecretsTable.TableName,
+ Description = "DynamoDB table name for GitHub org secrets",
+ });
+ }
+
+ public SharedInfrastructureConstruct SharedInfrastructureConstruct { get; }
+
+ public BadgeSmithFunctionConstruct BadgeSmithFunctionConstruct { get; }
+
+ public Function BadgeSmithFunction { get; }
+
+ public IFunctionUrl BadgeSmithFunctionUrl { get; }
+
+ public Table TestResultsTable { get; }
+
+ public Table NonceTable { get; }
+
+ public Table OrgSecretsTable { get; }
+
+ public HttpApi ApiGateway { get; }
+}
+
+#pragma warning restore CA1711
+
+public sealed record LocalPerformanceStackSettings(
+ string LambdaAssetPath,
+ Architecture LambdaArchitecture,
+ IReadOnlyDictionary LambdaEnvironment);
diff --git a/build/BadgeSmith.CDK.Shared/ProductionStack.cs b/build/BadgeSmith.CDK.Shared/ProductionStack.cs
index ece9d9c..c2dcdbb 100644
--- a/build/BadgeSmith.CDK.Shared/ProductionStack.cs
+++ b/build/BadgeSmith.CDK.Shared/ProductionStack.cs
@@ -1,4 +1,4 @@
-#pragma warning disable CA1711, MA0051
+#pragma warning disable CA1711 // AWS CDK stack types intentionally use the Stack suffix.
using Amazon.CDK;
using Amazon.CDK.AWS.Apigatewayv2;
@@ -9,7 +9,6 @@
using Amazon.CDK.AWS.Logs;
using Amazon.CDK.AWS.Route53;
using Amazon.CDK.AWS.Route53.Targets;
-using Amazon.CDK.AwsApigatewayv2Integrations;
using BadgeSmith.CDK.Shared.Constructs;
using Constructs;
using Function = Amazon.CDK.AWS.Lambda.Function;
@@ -42,7 +41,8 @@ public ProductionStack(Construct scope, string id, IStackProps? props = null) :
BadgeSmithFunction = BadgeSmithFunctionConstruct.BadgeSmithFunction;
- ApiGateway = CreateApiGateway();
+ var httpApiConstruct = new BadgeSmithHttpApiConstruct(this, ApiGatewayRoleId, BadgeSmithFunction);
+ ApiGateway = httpApiConstruct.ApiGateway;
var logGroup = new LogGroup(this, "HttpApiAccessLogs", new LogGroupProps
{
@@ -86,18 +86,6 @@ public ProductionStack(Construct scope, string id, IStackProps? props = null) :
private ICertificate ApiLocalStackCertificate =>
Certificate.FromCertificateArn(this, ApiCertificateId, "arn:aws:acm:us-east-1:377140207735:certificate/227f14fe-92b1-442c-bb80-ae4032e742fe");
- private HttpApi CreateApiGateway()
- {
- var lambdaIntegration = new HttpLambdaIntegration(HttpLambdaIntegrationId, BadgeSmithFunction);
-
- return new HttpApi(this, ApiGatewayRoleId, new HttpApiProps
- {
- ApiName = ApiGatewayName,
- Description = "BadgeSmith API Gateway for badge endpoints",
- DefaultIntegration = lambdaIntegration,
- });
- }
-
private Distribution CreateCloudFrontDistribution()
{
var apiGatewayDomain = Fn.Select(2, Fn.Split("/", ApiGateway.ApiEndpoint));
@@ -215,3 +203,5 @@ private void CreateOutputs()
public Distribution CloudFrontDistribution { get; }
}
+
+#pragma warning restore CA1711
diff --git a/build/BadgeSmith.CDK/GlobalSuppressions.cs b/build/BadgeSmith.CDK/GlobalSuppressions.cs
deleted file mode 100644
index 26233fc..0000000
--- a/build/BadgeSmith.CDK/GlobalSuppressions.cs
+++ /dev/null
@@ -1 +0,0 @@
-[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Potential Code Quality Issues", "RECS0026:Possible unassigned object created by 'new'", Justification = "Constructs add themselves to the scope in which they are created")]
diff --git a/build/BadgeSmith.CDK/Program.cs b/build/BadgeSmith.CDK/Program.cs
index b6b192a..83741e5 100644
--- a/build/BadgeSmith.CDK/Program.cs
+++ b/build/BadgeSmith.CDK/Program.cs
@@ -4,12 +4,7 @@
var app = new App();
-// Get environment from CDK context or CLI
-var env = new Amazon.CDK.Environment
-{
- Account = app.Node.TryGetContext("account") as string ?? System.Environment.GetEnvironmentVariable("CDK_DEFAULT_ACCOUNT"),
- Region = app.Node.TryGetContext("region") as string ?? System.Environment.GetEnvironmentVariable("CDK_DEFAULT_REGION"),
-};
+var env = CreateEnvironment(app);
_ = new ProductionStack(app, ProductionStackId, new StackProps
{
@@ -18,3 +13,23 @@
});
app.Synth();
+
+static Amazon.CDK.Environment CreateEnvironment(App app)
+{
+ return new Amazon.CDK.Environment
+ {
+ Account = GetRequiredEnvironmentValue(app, "account", "CDK_DEFAULT_ACCOUNT"),
+ Region = GetRequiredEnvironmentValue(app, "region", "CDK_DEFAULT_REGION"),
+ };
+}
+
+static string GetRequiredEnvironmentValue(App app, string contextKey, string environmentVariable)
+{
+ var value = app.Node.TryGetContext(contextKey) as string
+ ?? System.Environment.GetEnvironmentVariable(environmentVariable);
+
+ return !string.IsNullOrWhiteSpace(value)
+ ? value
+ : throw new InvalidOperationException(
+ $"CDK environment value '{contextKey}' is required. Set CDK context '{contextKey}' or {environmentVariable}.");
+}
diff --git a/build/BadgeSmith.CDK/README.md b/build/BadgeSmith.CDK/README.md
index f28e4d5..e2157fe 100644
--- a/build/BadgeSmith.CDK/README.md
+++ b/build/BadgeSmith.CDK/README.md
@@ -1,14 +1,44 @@
-# Welcome to your CDK C# project!
+# BadgeSmith production CDK app
-This is a blank project for CDK development with C#.
+This project owns only the production stack. The separate LocalStack benchmark app is
+documented in [../BadgeSmith.CDK.LocalPerformance/README.md](../BadgeSmith.CDK.LocalPerformance/README.md).
+Do not add topology-selection context back to either app; CDK constructs the whole app
+tree before CLI stack selectors are applied.
-The `cdk.json` file tells the CDK Toolkit how to execute your app.
+The deployment workflow is the source of truth for pinned Node.js and AWS CDK CLI
+versions. Use the same versions for local synth and diff checks instead of duplicating
+version numbers in documentation.
-It uses the [.NET CLI](https://docs.microsoft.com/dotnet/articles/core/) to compile and execute your project.
+- Project: `build/BadgeSmith.CDK/BadgeSmith.CDK.csproj`
+- CDK working directory: `build`
+- CDK config: `build/cdk.json`
+- Native stack ID: `BadgeSmithStack`
+- Lambda ZIP default: `../artifacts/badge-lambda-linux-arm64.zip`
+- Upstream mode: explicit `Live`
-## Useful commands
+Build and synthesize production infrastructure:
-* `dotnet build src` compile this app
-* `cdk deploy` deploy this stack to your default AWS account/region
-* `cdk diff` compare deployed stack with current state
-* `cdk synth` emits the synthesized CloudFormation template
\ No newline at end of file
+```bash
+dotnet build build/BadgeSmith.CDK/BadgeSmith.CDK.csproj -c Release
+tools/badgesmith.cs lambda build --target zip --rid linux-arm64 --clean --verbose
+cd build
+cdk ls
+cdk synth BadgeSmithStack \
+ --context account= \
+ --context region=eu-central-1
+```
+
+The ARM64 ZIP is not part of the ordinary local test loop. Building it locally requires
+an ARM64 host or a buildx builder with ARM64 execution support; hosted CI owns the
+required production artifact check. The PR CI workflow builds the ZIP but does not run
+production CDK synth, so `cdk synth BadgeSmithStack` remains a separate infrastructure
+gate.
+
+Production deploy remains approval-gated and must target the single production stack:
+
+```bash
+cd build
+cdk deploy BadgeSmithStack --require-approval never
+```
+
+Do not use `--all` for production synth, diff, or deploy commands.
diff --git a/build/cdk.json b/build/cdk.json
index 6fc7adb..a09bc38 100644
--- a/build/cdk.json
+++ b/build/cdk.json
@@ -92,6 +92,19 @@
"@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions": true,
"@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway": true,
"@aws-cdk/aws-s3:publicAccessBlockedByDefault": true,
- "@aws-cdk/aws-lambda:useCdkManagedLogGroup": true
+ "@aws-cdk/aws-lambda:useCdkManagedLogGroup": true,
+ "@aws-cdk/aws-batch:defaultToAL2023": true,
+ "@aws-cdk/aws-cloudfront:defaultFunctionRuntimeV2_0": true,
+ "@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener": true,
+ "@aws-cdk/aws-ecs-patterns:uniqueTargetGroupId": true,
+ "@aws-cdk/aws-eks:defaultToAL2023": true,
+ "@aws-cdk/aws-eks:useNativeOidcProvider": true,
+ "@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault": true,
+ "@aws-cdk/aws-elasticloadbalancingv2:usePostQuantumTlsPolicy": true,
+ "@aws-cdk/aws-route53-patterns:useDistribution": true,
+ "@aws-cdk/aws-signer:signingProfileNamePassedToCfn": true,
+ "@aws-cdk/core:annotationsInValidationReport": true,
+ "@aws-cdk/core:defaultCrossStackReferences": "weak",
+ "@aws-cdk/core:validateAgainstDefaultRules": true
}
}
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index d361b6e..19afa05 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -1,26 +1,63 @@
# BadgeSmith Roadmap
-Date: 2026-07-01
+Date: 2026-08-07
-Backlog and progress source of truth for BadgeSmith. Keep this current: the Status &
-Plan Mapping table is the permanent index, detailed plans live in `docs/plans/`, and
-new ideas land in Inbox / Untriaged until they are scoped.
+Backlog and progress source of truth for BadgeSmith. Keep this current: the status table
+is the permanent history, active workstreams may link a temporary detailed plan, and new
+ideas land in Inbox / Untriaged until they are scoped.
## Status & Plan Mapping
| Workstream | Status | Plan | Notes |
| --- | --- | --- | --- |
| Agent contract adoption | done | β (landed as a single `docs:` commit) | Re-authored `AGENTS.md`, harness relays, and `docs/agents/` for BadgeSmith after they were copied from another repo |
+| Iteration 0 β RIE-free contract coverage and local benchmark harness | done | β | Completed on 2026-07-05 on `feature/iteration0-aot-contract-tier` and squashed into `991769e`: RIE was removed from active contract and benchmark paths; Aspire Testing + `APIGatewayEmulator` covers HTTP contracts; LocalStack ZIP Lambda benchmark execution uses a CDK-created Lambda Function URL fallback because LocalStack Community 4.6 blocks API Gateway v2 CloudFormation resources. Baselines: [final local smoke](research/baselines/2026-07-04-final-localstack-smoke.json), [live direct Gateway smoke](research/baselines/2026-07-04-live-gateway-smoke.json), and [live CloudFront comparison smoke](research/baselines/2026-07-04-live-cloudfront-smoke.json). |
+| Wave 1 β correctness and hygiene fixes | done | β | Closed in W1.7 on `feature/iteration0-aot-contract-tier`. HMAC `repoIdentifier` (`845440f`); GSI1PK case normalization; nonce-after-signature; client error-message hygiene; PAT rotation docs; naming hygiene (`5cbf87b`). |
+| W1.5 β file-based tooling migration | done | β | Foundation `52d038a`; finished in W1.7: workflows call `tools/badgesmith.cs`, tracked `.sh`/`.ps1` retired, script-facing docs moved to `tools/README.md`, `perf baseline` C# command deferred (see Inbox). |
+| W1.7 β closeout and platform refresh | done | β | Packages: Aspire 13.4.6, LocalStack.Aspire.Hosting 13.4.0, explicit Aspire.Hosting.AWS 13.3.1, full CPM stable bump, MessagePack removed. Tooling finish + remaining Wave 1 correctness. |
+| PR #5 merge-readiness remediation | superseded by second pass | β | Implemented in `34fe5f7`: restored Native AOT serializer compatibility, hardened malformed HMAC handling and white-label URLs, secured reusable workflow inputs, and corrected Aspire source ownership. Hosted checks passed, but the subsequent whole-PR review found merge-blocking HMAC and CDK issues tracked by the second-pass workstream below. |
+| PR #5 second-pass review remediation | review follow-ups in progress; infrastructure gates passed | β | Implemented on 2026-08-07 in `4d9c699`, `9e1344c`, `eae8df3`, and `621f2ce`: hard-cut canonical HMAC authentication, secure badge transport, LocalStack.Client.Extensions 2.0.1, and separate production/local-performance CDK apps. Local evidence: zero-warning Release build, 403 tests, file-based CLI build, actionlint, Slopwatch, package graphs, local-performance CDK synth, and two independent security reviews with no Medium-or-higher findings. Hosted [CI run 31159120605](https://github.com/localstack-dotnet/badge-smith/actions/runs/31159120605) passed `build-and-test` and the ARM64 Native AOT ZIP build/upload on head `fba5c8d`. Production `cdk synth BadgeSmithStack` passed on 2026-08-07 using that hosted ARM64 artifact, pinned AWS CDK CLI `2.1135.1`, and Amazon.CDK.Lib `2.263.0`; no deployment was performed. PR #5 has review comments pending discussion and closure. |
+
+## Process Notes
+
+- For live Lambda/API performance, use the direct API Gateway baseline; CloudFront runs are comparison data because edge caching can reduce Lambda invocations and hide API behavior.
+- For Lambda duration, memory, and cold starts, use CloudWatch Lambda `REPORT` lines as the source of truth. k6 client-side cold-start heuristics are only smoke-test hints.
## Backlog
-Scoped work waiting to start. Promote an item into Status & Plan Mapping (and write a
-plan under `docs/plans/`) when it becomes active.
+Scoped work waiting to start. Promote an item into Status & Plan Mapping and link its
+detailed plan when it becomes active.
-- _(none yet)_
+- **Wave 2 β test safety net** (next after PR #5): ResponseHelper / real RouteTable /
+ NuGetVersionService tests; align resolver tests with production routes. The initial
+ HMAC suite landed during PR #5 remediation. Details:
+ [research/2026-07-02-code-review-findings.md](research/2026-07-02-code-review-findings.md) Β§4.
+- **Wave 3 β hygiene**: DRY refactors (bootstrap, route-param extraction, package
+ services), dead-code removal, script/docs drift, DynamoDB PITR/removal policy.
+ Details: findings doc Β§3, Β§5.
+- **Logging hygiene β source-generated logging migration** (2026-07-02): Replace
+ temporary `CA1873` pragmas with `LoggerMessageAttribute` source-generated logging,
+ then remove the suppressions and keep the zero-warning build contract.
## Inbox / Untriaged
Raw capture spot for ideas and requests before they are scoped into the backlog.
-- _(empty)_
+- Performance pass (cold start + memory footprint) β measured and decided, ready to
+ implement:
+ [research/2026-07-02-performance-opportunities.md](research/2026-07-02-performance-opportunities.md).
+ Agreed levers: edge-side 404 caching + badge TTL increase, eager INIT warm-up,
+ `TrimMode=full` + ILC knobs, result caching in package services. Rejected:
+ provisioned concurrency, keep-warm pings, SnapStart (N/A on provided.al2023).
+ Folds in GitHub issue #1 (RouteValues buffer guard).
+- **GitHub Packages prerelease-channel filtering:** support selecting the latest `ci`
+ prerelease without pinning the base version; tracked in
+ [GitHub issue #4](https://github.com/localstack-dotnet/badge-smith/issues/4).
+- **Deferred from W1.7:** `perf baseline` C# command β the previous
+ `scripts/perf-baseline.{sh,ps1}` + `perf-baseline-seed.sh` (~16KB of orchestration)
+ was retired in the W1.7 closeout rather than half-ported. Keep the k6 scenario at
+ `scripts/k6-perf-test.js`; re-home the LocalStack seed + k6 invocation orchestration
+ under `tools/Commands/PerfBaselineCommand.cs` (registered as `perf baseline` in
+ `BadgeSmithTool.CreateCommandApp`) after Wave 2 or as part of the performance pass.
+ Consume the dedicated local-performance CDK app from the PR #5 second-pass
+ remediation; do not restore stack-selection context in the production app.
diff --git a/docs/agents/KNOWN_ISSUES.md b/docs/agents/KNOWN_ISSUES.md
index f1bbf11..545f1fa 100644
--- a/docs/agents/KNOWN_ISSUES.md
+++ b/docs/agents/KNOWN_ISSUES.md
@@ -6,7 +6,7 @@ These notes are hints for agents during triage and review. They are not permissi
refactor unrelated code.
- **AOT/trim warnings are blocking.** Trim or AOT warnings emitted during `PublishAot`
- (via `scripts/build-lambda.*`) can turn into runtime failures in the deployed Lambda.
+ (via `tools/badgesmith.cs lambda build`) can turn into runtime failures in the deployed Lambda.
Do not suppress them to get a green build.
- **JSON must be registered for source generation.** Every serialized type must be part
of `LambdaFunctionJsonSerializerContext`. A missing registration compiles fine but
diff --git a/docs/agents/README.md b/docs/agents/README.md
index f08e37f..5b2e443 100644
--- a/docs/agents/README.md
+++ b/docs/agents/README.md
@@ -1,6 +1,6 @@
# Agent Harness Guide
-Date: 2026-07-01
+Date: 2026-07-24
This directory contains repository-specific guidance for AI coding agents.
@@ -24,17 +24,20 @@ when the edit is Markdown-only.
| `.github/copilot-instructions.md` | GitHub Copilot relay to `AGENTS.md` |
| `docs/agents/README.md` | Harness adapter guide and capability mapping (this file) |
| `docs/agents/KNOWN_ISSUES.md` | Agent-facing known notes and triage hints |
-| `docs/agents/handover-prompts/` | Session-pickup templates for stateful handovers |
+| `docs/agents/handover-prompts/session-pickup-template.md` | Session-pickup template for stateful handovers |
+| `docs/agents/skills/aspire-source-navigation.md` | Canonical project guidance for compatibility-sensitive Aspire source navigation |
+| `.opencode/skills/aspire-source-navigation/SKILL.md` | OpenCode discovery relay for the project skill |
-**No project skill is shipped.** BadgeSmith does not author a custom skill; it curates
-the installed marketplace skills below. If a project skill is ever added, it should live
-at `docs/agents/skills/.md` (canonical body) with thin native relays under
-`.claude/skills//SKILL.md`, `.opencode/skills//SKILL.md`, and
-`.github/skills//SKILL.md`. Those folders do not exist today, by design.
+BadgeSmith ships exactly one project skill: `aspire-source-navigation`. Its canonical
+body lives in `docs/agents/skills/aspire-source-navigation.md`, and OpenCode discovers it
+through `.opencode/skills/aspire-source-navigation/SKILL.md`. The repository does not
+ship Claude Code or GitHub Copilot relays for this project skill.
## Harness Notes
Claude Code discovers project skills under `.claude/skills/{skill-name}/SKILL.md`.
+BadgeSmith does not currently expose `aspire-source-navigation` through that location;
+Claude Code agents can read the canonical guide directly when the capability is needed.
OpenCode discovers project skills under `.opencode/skills/{skill-name}/SKILL.md`.
OpenCode loads skill files at session start, so restart OpenCode after changing
@@ -43,8 +46,12 @@ project skills.
GitHub Copilot in VS Code supports repository instructions through
`.github/copilot-instructions.md` and Agent Skills under `.github/skills/`.
+BadgeSmith does not currently expose `aspire-source-navigation` as a Copilot Agent Skill;
+Copilot agents can read the canonical guide directly when the capability is needed.
-These project-skill folders are currently absent because no project skill is shipped.
+Only the OpenCode project-skill relay is shipped. Adding another harness relay is a
+deliberate agent-infrastructure change, not an automatic consequence of adding a
+canonical guide.
Do not create `.vscode` skill folders β that is not a canonical Agent Skills location
for this repository.
@@ -94,6 +101,7 @@ manually.
| Capability | Claude Code | Copilot CLI | OpenCode |
| --- | --- | --- | --- |
+| Aspire source compatibility for upstream Aspire/AWS/LocalStack.Client internals | Canonical guide only (no relay) | Canonical guide only (no relay) | `aspire-source-navigation` |
| System.Text.Json AOT source-generation / serialization contracts | `dotnet-skills:serialization` | `serialization` | `serialization` |
| Modern C# coding standards | `dotnet-skills:csharp-coding-standards` | `modern-csharp-coding-standards` | `modern-csharp-coding-standards` |
| Type design and performance (seal, readonly struct, static pure) | `dotnet-skills:csharp-type-design-performance` | `type-design-performance` | `type-design-performance` |
@@ -163,6 +171,22 @@ BadgeSmith still uses VSTest with `dotnet test`.
| Working-diff code review (findings-first, severity-ordered) | `code-review` (harness built-in) | `code-review` via `task` | `codex-review` via `task` when present |
| Security review of pending changes (HMAC, nonce, secrets, replay protection) | `security-review` (harness built-in) | `security-review` via `task` | N/A |
+### Tier 2 β Official Aspire skills and Aspire MCP server
+
+Official Microsoft Aspire skills and MCP server are local harness setup, not committed project infrastructure. They require Aspire CLI 13.3+ (`aspire agent mcp`).
+
+| Capability | Claude Code | Copilot CLI | OpenCode |
+| --- | --- | --- | --- |
+| AppHost lifecycle routing + safety guardrails (`aspire start`, never `dotnet run` on AppHosts) | `aspire:aspire` | `aspire` | `aspire` |
+| Start/stop/restart/wait/inspect playground AppHost resources | `aspire:aspire-orchestration` | `aspire-orchestration` | `aspire-orchestration` |
+| Resource logs, traces, metrics, dashboard telemetry | `aspire:aspire-monitoring` | `aspire-monitoring` | `aspire-monitoring` |
+| Runtime resource state/logs/traces/commands over MCP | `aspire` MCP server (`aspire agent mcp`, stdio; tools surface as `mcp__aspire__*`) | `aspire` MCP server (`aspire agent mcp`, stdio; user `~/.copilot/mcp-config.json`) | `aspire` MCP server (`aspire agent mcp`, stdio; local `opencode.jsonc`) |
+
+- The MCP server only discovers AppHosts launched with `aspire start` from the workspace directory. In-process `DistributedApplicationTestingBuilder` AppHosts used by integration tests are invisible to it β test debugging stays log/debugger-based.
+- These skills/tools are for *consuming* Aspire (running and debugging playground AppHosts). They do not replace the canonical `aspire-source-navigation` guidance for upstream source-compatibility work; on conflict, verified package source wins.
+- The bundle also ships `aspire-init` and `aspireify` (not for this repo β AppHosts already exist) and `aspire-deployment` (approval-gated and real-AWS targeted; LocalStack playgrounds do not deploy).
+- Set up each harness locally and update only that harness's cells after verifying the native skill IDs and MCP status.
+
### Tier 3 β Local-only
| Capability | Claude Code | Copilot CLI | OpenCode |
@@ -272,10 +296,14 @@ checkout exposes the same `subagent_type` names.
## Skill Maintenance
-Because no project skill is shipped, skill maintenance reduces to routing maintenance:
+BadgeSmith's only project skill is `aspire-source-navigation`, exposed through OpenCode:
- Update `AGENTS.md` only for mandatory cross-harness policy.
- Update this file for adapter mechanics and the capability mapping table.
+- Keep `docs/agents/skills/aspire-source-navigation.md` canonical and the OpenCode relay
+ thin; update both when discovery metadata changes.
+- Do not add Claude Code or GitHub Copilot relays unless BadgeSmith deliberately expands
+ the skill beyond OpenCode.
- Use the `skills-index-snippets` capability to keep the capability index consistent
when skills are added, retired, or re-tiered.
- Change the roster deliberately: re-tier or drop a skill only when the repo's actual
diff --git a/docs/agents/handover-prompts/session-pickup-template.md b/docs/agents/handover-prompts/session-pickup-template.md
index 02de2e3..39dfb65 100644
--- a/docs/agents/handover-prompts/session-pickup-template.md
+++ b/docs/agents/handover-prompts/session-pickup-template.md
@@ -146,8 +146,9 @@ full mirror of `AGENTS.md`.
source-gen context, treat trim/AOT warnings as blocking, `DateTime.UtcNow` only.
- Tests are xUnit v3 on VSTest β plain `dotnet test` / `--filter`, not TUnit.
- `AGENTS.md` is canonical; `CLAUDE.md` and `.github/copilot-instructions.md` stay
- relay-only. No custom project skill is shipped; the curated skill roster lives in
- `docs/agents/README.md`.
+ relay-only. `aspire-source-navigation` is the only custom project skill and is exposed
+ through OpenCode only; its canonical guide and the curated roster live under
+ `docs/agents/`.
### `## Final Steering Note`
diff --git a/docs/agents/skills/aspire-source-navigation.md b/docs/agents/skills/aspire-source-navigation.md
new file mode 100644
index 0000000..792dd5f
--- /dev/null
+++ b/docs/agents/skills/aspire-source-navigation.md
@@ -0,0 +1,180 @@
+---
+name: aspire-source-navigation
+description: Use when BadgeSmith's compatibility-sensitive Aspire, AWS, or LocalStack consumer work depends on upstream source, package-version alignment, AddLocalStack/UseLocalStack/WithReference behavior, endpoint/configuration flow, or AWS SDK wiring.
+---
+
+# Aspire Source Navigation
+
+## Overview
+
+BadgeSmith consumes Aspire hosting integrations for local development; it does not
+build or publish those packages. Compatibility-sensitive work depends on this repo's
+package versions and on matching upstream source checkouts, not on memory or upstream
+default branches.
+
+Use source evidence before editing compatibility-sensitive code. Keep this skill version-light: package versions and concrete refs belong in `Directory.Packages.props`, local `external/` checkouts, and the upstream repositories.
+
+The expected outcome is a short evidence trail: exact package versions, verified upstream refs or an explicit missing-source note, source locations checked, and the compatibility conclusion that drives the change.
+
+## When To Use
+
+Use this skill for work involving:
+
+- `Aspire.Hosting` or `Aspire.Hosting.AWS` internals
+- LocalStack.Client behavior
+- `Directory.Packages.props` Aspire, AWS integration, LocalStack hosting, or LocalStack client versions
+- `AddLocalStack`, `UseLocalStack`, `.WithReference(localstack)`, endpoint/configuration flow, manifest behavior, CloudFormation/CDK, Lambda, or AWS SDK wiring
+- String-based references to upstream AWS Aspire integration types
+- Reviews of Aspire hosting compatibility or package-version drift
+
+For read-only explanation questions, use this skill only when the answer depends on upstream source, version-specific API shape, or a compatibility conclusion. Otherwise inspect this repository's docs/code directly.
+
+Do not use this skill for ordinary Markdown edits, general C# cleanup, or playground-only work that does not depend on Aspire/AWS/LocalStack internals.
+
+## Required Workflow
+
+1. Read `Directory.Packages.props` and identify the exact package versions involved.
+2. Map the packages to their upstream repositories: Aspire packages to `dotnet/aspire`,
+ `Aspire.Hosting.AWS` to `aws/integrations-on-dotnet-aspire-for-aws`,
+ `LocalStack.Aspire.Hosting` to `localstack-dotnet/dotnet-aspire-for-localstack`, and
+ `LocalStack.Client` packages to `localstack-dotnet/localstack-dotnet-client` only
+ when SDK/client configuration behavior is involved.
+3. Check whether a matching local checkout exists under `external/`. Because `external/` is gitignored, use an ignored-file-aware check such as `Test-Path external`, `git ls-files --others --ignored --exclude-standard external/`, or a direct directory listing. Do not rely on workspace glob/search tools that skip ignored paths.
+4. Verify the local checkout's branch/tag/commit against the package version and upstream tags/releases before trusting it.
+5. If local source is missing or stale, report that explicitly. Use GitHub MCP only for tag/ref discovery, release verification, or targeted fallback reads.
+6. Search upstream source for the exact symbols, annotations, extension methods, and behavior involved in the task. Do not rely on pre-baked search terms.
+7. Cross-check this repository's implementation and tests against the verified upstream source.
+8. Report evidence with file paths and refs before recommending or making changes.
+
+## Package-To-Source Map
+
+Resolve package versions from `Directory.Packages.props` each time. Do not copy versions into this skill.
+
+| Package or behavior | Upstream source | Local checkout root |
+| --- | --- | --- |
+| `Aspire.Hosting`, `Aspire.Hosting.AppHost`, `Aspire.Hosting.Testing` | `dotnet/aspire` | `external/aspire/{ref}/` |
+| `Aspire.Hosting.AWS`, CloudFormation, CDK, Lambda emulator integration | `aws/integrations-on-dotnet-aspire-for-aws` | `external/aws-integrations/{ref}/` |
+| `LocalStack.Aspire.Hosting`, `AddLocalStack`, `UseLocalStack`, LocalStack resource and endpoint wiring | `localstack-dotnet/dotnet-aspire-for-localstack` | `external/dotnet-aspire-for-localstack/{ref}/` |
+| `LocalStack.Client`, `LocalStack.Client.Extensions`, `ILocalStackOptions`, session/config options | `localstack-dotnet/localstack-dotnet-client` | `external/localstack-dotnet-client/{ref}/` |
+
+When a task spans multiple packages, verify every involved source. Example: `UseLocalStack()` with Lambda SQS event sources usually involves this repository, `Aspire.Hosting.AWS`, and possibly LocalStack client configuration behavior.
+
+## Local Checkout Layout
+
+Use this layout when local source is available:
+
+```text
+external/aspire/{ref}/
+external/aws-integrations/{ref}/
+external/dotnet-aspire-for-localstack/{ref}/
+external/localstack-dotnet-client/{ref}/
+```
+
+`{ref}` should be derived from the package version and verified against upstream tags/releases. The `external/` tree is ignored by git. Do not commit upstream source checkouts.
+
+## Ref Verification
+
+Before trusting local upstream source:
+
+1. Read the package version from `Directory.Packages.props`.
+2. Inspect the local checkout's current ref using git metadata.
+3. Verify that ref against upstream tags, release branches, or commits for the package version.
+4. If the mapping is not obvious, say so and use a targeted upstream lookup to establish the mapping.
+
+Acceptable evidence includes a tag name, release branch, commit SHA, or upstream release page that ties the package version to the source. Unacceptable evidence includes repository default branches, approximate version names, or unchecked local folder names.
+
+### Resolving A Version To A Ref
+
+Upstream repositories do not all tag releases the same way. Determine the repository's release scheme first, then resolve the package version to a ref:
+
+- **Semver tags** (e.g. `vX.Y.Z`): match the package version directly to the tag.
+- **Non-semver tags** (date-based, build-numbered, or otherwise): the version usually lives in the release notes, not the tag. Do not walk tags one by one. List releases and match the package version string in the release bodies, then take that release's tag and commit SHA. Releases are usually chronological, so a coarse search converges in a few lookups.
+
+A package's major version may be realigned to track another dependency, so a low major does not imply old source; rely on the resolved ref, not the version's shape. If a repository's scheme is unclear, inspect a couple of recent releases to learn it before resolving. Record each resolved version-to-ref mapping (tag plus SHA) so the lookup is not repeated.
+
+## Missing Or Stale Source
+
+If the matching local checkout does not exist after an ignored-file-aware check, do not silently continue with default-branch source. Report the gap before making compatibility-sensitive conclusions.
+
+Use this wording pattern:
+
+```text
+Upstream source status:
+- Aspire.Hosting {version}: no matching local checkout under external/aspire/{ref}; using targeted GitHub fallback for {symbols/files} only.
+- Aspire.Hosting.AWS {version}: local checkout {path} verified at {ref-or-sha}.
+- LocalStack.Aspire.Hosting {version}: local checkout {path} verified at {ref-or-sha}.
+- LocalStack.Client {version}: not involved in this change.
+```
+
+Create or refresh `external/` checkouts only when the user has approved that setup work or when the current task explicitly includes source setup. Keep those checkouts uncommitted.
+
+### Setting Up Checkouts
+
+When approved, create one checkout per resolved ref. Use a shallow clone to limit size:
+
+```bash
+git clone --depth 1 --branch {ref} {repo-url} external/{name}/{ref}
+```
+
+- `{name}` is the checkout root from the package-to-source map; `{ref}` is the resolved tag, used verbatim (including non-semver forms).
+- `external/` is gitignored, so these clones never enter repository status. Do not commit them.
+- After cloning, confirm the checkout's `HEAD` matches the resolved commit SHA before trusting it.
+
+## Evidence Report
+
+Before recommending or making changes, provide the evidence in this shape:
+
+```text
+Compatibility evidence:
+- Package versions: Aspire.Hosting {version}, Aspire.Hosting.AWS {version}, LocalStack.Client {version-or-not-involved}.
+- Upstream refs checked: {repo}@{ref-or-sha}, ...
+- Upstream files/symbols checked: {file}:{symbol}, ...
+- Repo files/tests checked: {file}:{symbol-or-test}, ...
+- Conclusion: {what changed, what is compatible, what is risky, or what remains unverified}.
+```
+
+Keep the report short, but include enough detail that another agent can reproduce the source lookup.
+
+## Search Guidance
+
+Search by the exact behavior under review:
+
+- Extension methods: `AddLocalStack`, `UseLocalStack`, `WithReference`, `WithEnvironment`, `WaitFor`, `ExcludeFromManifest`.
+- Aspire resource model: annotations, `IResourceWithEnvironment`, `IResourceWithWaitSupport`, endpoint references, connection string callbacks, manifest publishing.
+- AWS integration: CloudFormation resources, CDK stacks/bootstrap, Lambda emulator resources, SQS event source resources, output/reference annotations.
+- LocalStack hosting integration: `AddLocalStack`, `UseLocalStack`, LocalStack resource annotations, endpoint propagation, and AppHost environment wiring.
+- LocalStack client: `ILocalStackOptions`, `LocalStackOptions`, `SessionOptions`, `ConfigOptions`, `AddLocalStack`, `AddAwsService`, environment variable binding.
+
+These are starting points, not a fixed checklist. Add or remove searches based on the concrete task.
+
+## Official Aspire Skills Cross-Check
+
+Official Microsoft Aspire skills are useful for Aspire CLI and distributed application workflows, but they may describe newer Aspire versions than this repository uses. If official guidance conflicts with verified package source, prefer verified package source and call out the version mismatch.
+
+Use official skills when available for:
+
+| Task | Skill |
+| --- | --- |
+| AppHost lifecycle | `aspire` or `aspire-orchestration` |
+| Logs, dashboard, traces | `aspire-monitoring` |
+| AppHost scaffold/resource graph work | `aspireify` |
+| New skeleton creation | `aspire-init` |
+| Publish/deploy/destroy | `aspire-deployment`, approval-gated |
+
+Installed local skills can supplement this one:
+
+| Task | Skill |
+| --- | --- |
+| Explicit configuration and env vars | `aspire-configuration` |
+| Playground ServiceDefaults | `aspire-service-defaults` |
+| `DistributedApplicationTestingBuilder` patterns | `aspire-integration-testing`, adapted to this repo's test framework |
+
+## Common Mistakes
+
+- Do not use upstream default branches for compatibility-sensitive source checks.
+- Do not assume package versions map directly to semver git tags; verify the upstream tag/release scheme.
+- Do not copy examples from external testing guidance without adapting them to this repo's test framework.
+- Do not commit `external/` source checkouts.
+- Do not treat GitHub MCP as the default source-reading path when local source is available.
+- Do not claim source compatibility from this repository's tests alone; upstream API shape must be checked for version-sensitive behavior.
+- Do not leave the evidence trail implicit in chat history; summarize refs and file paths before the recommendation or edit.
diff --git a/docs/plans/README.md b/docs/plans/README.md
index 4258c7b..236e95a 100644
--- a/docs/plans/README.md
+++ b/docs/plans/README.md
@@ -1,7 +1,7 @@
# Plans
-Detailed, per-workstream implementation plans for BadgeSmith. Each plan is linked from
-the Status & Plan Mapping table in [../ROADMAP.md](../ROADMAP.md).
+Only active, detailed implementation plans belong here. Completed or superseded plans
+are deleted after their durable outcome is summarized in [../ROADMAP.md](../ROADMAP.md).
Naming: `YYYY-MM-DD-.md`. A plan should produce working, verifiable changes
-on its own.
+on its own and should not become a permanent duplicate history.
diff --git a/docs/research/2026-07-02-code-review-findings.md b/docs/research/2026-07-02-code-review-findings.md
new file mode 100644
index 0000000..83fdd02
--- /dev/null
+++ b/docs/research/2026-07-02-code-review-findings.md
@@ -0,0 +1,166 @@
+# Code Review Findings β Full Codebase Deep-Dive
+
+Date: 2026-07-02
+
+Full read-through of `src/BadgeSmith.Api` (~4.1k LOC), `build/` CDK, `src/BadgeSmith.Host`,
+`tests/`, and `scripts/`. Findings are ordered by severity. File references point at the
+line as of commit `671e40e`.
+
+## 1. Bugs (behavior-affecting)
+
+### 1.1 HMAC `repoIdentifier` built wrong β `Repo` twice, `Platform` missing
+
+`src/BadgeSmith.Api/Core/Security/HmacAuthenticationService.cs:42`:
+
+```csharp
+var repoIdentifier = $"{authContext.Owner}/{authContext.Repo}/{authContext.Repo}/{authContext.Branch}";
+```
+
+Consequences:
+
+- Nonce partition key (`NONCE#{repoIdentifier}`) has no platform scope; the same nonce
+ value used for two platforms of the same repo/branch collides.
+- The ingestion response `Repository` field returns `owner/repo/repo/branch` to clients
+ (`Features/TestResults/Handlers/TestResultIngestionHandler.cs:86`).
+- Log lines carry the malformed identifier.
+
+Fix: `{Owner}/{Repo}/{Platform}/{Branch}` (decide canonical order once; nonce keys in
+DynamoDB are TTL-bound (45 min), so a key-shape change has no migration cost).
+
+### 1.2 `GetLatestTestResultAsync` builds GSI1PK from non-normalized values
+
+`src/BadgeSmith.Api/Features/TestResults/TestResultsService.cs:86-93` computes four
+`ToLowerInvariant()` locals and then never uses them:
+
+```csharp
+var gsi1Pk = $"LATEST#{owner}#{repo}#{platform}#{branch}"; // raw, not normalized
+```
+
+Write path stores lowercase keys (`TestResultEntity.FromPayload`, called with normalized
+values at `TestResultsService.cs:50`). Any badge/redirect query with an uppercase
+character is a guaranteed 404. Currently masked because README badge URLs use lowercase.
+
+### 1.3 Build-script default RID mismatches CDK artifact expectation
+
+- `scripts/build-lambda.ps1:5` and `scripts/build-lambda.sh:5` default to `RID=linux-x64`.
+- CDK requires `../artifacts/badge-lambda-linux-arm64.zip` + `Architecture.ARM_64`
+ (`build/BadgeSmith.CDK.Shared/Constructs/BadgeSmithFunctionConstruct.cs:35`).
+
+CI passes `--rid linux-arm64` explicitly, so deploys work; a default local build produces
+an artifact CDK cannot find. Align the default (arm64) or parameterize CDK by RID.
+
+### 1.4 Seeder onboarding template is invalid JSON
+
+`tests/seeders/BadgeSmith.DynamoDb.Seeders/organization-pat-mapping.json.dist:5,12` β
+missing closing quote on `"name": ",`. Copying the template yields a parse
+failure that the seeder swallows with a warning (`OrgSecretSeeder.cs:122-126`).
+
+### 1.5 Plaintext GitHub PAT on disk (rotate)
+
+`tests/seeders/BadgeSmith.DynamoDb.Seeders/organization-pat-mapping.json` contains a
+real-looking `ghp_β¦` token. The file is gitignored (verified), but it is plaintext on
+disk and copied into every `bin/` output (`CopyToOutputDirectory=Always`). If live,
+rotate it; consider sourcing the secret from user-secrets/env instead of a JSON file.
+
+## 2. Security / robustness improvements
+
+- **Nonce burned before signature validation.**
+ `HmacAuthenticationService.ValidateRequestAsync` order is timestamp β nonce (DynamoDB
+ write) β secret β signature. Every invalid-signature request costs a DynamoDB write +
+ secret lookup, and a legitimate retry after a signature mistake is rejected as replay.
+ Prefer: timestamp β secret + signature β nonce last.
+- **Exception messages leak to clients.** `Core/Routing/ApiRouter.cs:66` returns
+ `$"Unhandled error: {ex.Message}"` in the 500 body (Program.cs catch uses a generic
+ message β inconsistent). `NonceService.cs:88` and `TestResultsService.cs:73` embed
+ `ex.Message` into `Error` results that handlers serialize to clients.
+- **`HttpUtility.UrlDecode` wrong for path segments.**
+ `Core/Routing/RouteValues.cs:51,67` β `+` decodes to space, corrupting segments that
+ legitimately contain `+`. Use `Uri.UnescapeDataString`; also replaces
+ `HttpUtility.UrlEncode` at `Features/GitHub/GitHubPackageService.cs:52` and drops the
+ `System.Web` dependency.
+- **Production DynamoDB tables: `RemovalPolicy.DESTROY`, no PITR, no deletion
+ protection** (`build/BadgeSmith.CDK.Shared/Constructs/DynamoDbTablesConstruct.cs:40,76,93`).
+- **`Request.Headers` null-safety inconsistent.** Ingestion handler checks null; badge
+ handlers dereference directly (`TestResultsBadgeHandler.cs:52`,
+ `NuGetPackageBadgeHandler.cs:59`, `GithubPackagesBadgeHandler.cs:75`) β NRE β 500.
+- **GitHub versions endpoint has no pagination.**
+ `GitHubPackageService.GetLatestVersionAsync` reads only the first page (default 30
+ items); a `?version=` range targeting older versions can silently miss.
+
+## 3. Refactoring opportunities (duplication / design)
+
+- **Bootstrap duplicated** across `Program.cs` (`#if !ENABLE_TELEMETRY`) and
+ `Program.Telemetry.cs` (`#if ENABLE_TELEMETRY`): `FunctionCoreAsync` and handler setup
+ are copies. Extract the shared core; keep only the tracer wrapper conditional.
+- **`TryExtractRouteParameters` copy-pasted 3Γ** (~35 lines each) across
+ TestResults ingestion/badge/redirection handlers. One shared extractor.
+- **Provider dispatch belongs in the route table.** Registering
+ `/badges/packages/nuget/{package}` and `/badges/packages/github/{org}/{package}`
+ literal routes removes both handlers' `TryValidateRequest` provider checks and
+ cross-provider hint blocks.
+- **NuGet/GitHub package services ~70% identical** (conditional GET, ETag/304 handling,
+ cache write: `NuGetPackageService.cs:47-91` vs `GitHubPackageService.cs:56-105`;
+ copy-paste evidence: "NuGet API error" message at `GitHubPackageService.cs:96`).
+ Extract a shared cached-conditional-fetch helper.
+- **Zero-alloc routing self-defeats.** Span-based `RouteValues` is immediately
+ materialized into an `ImmutableDictionary` per request (`ApiRouter.cs:54`), and
+ `RouteResolver.TryResolve` heap-allocates the param buffer per request
+ (`RouteResolver.cs:18`). Either carry spans through, or use a plain `Dictionary`.
+- **`Lazy` used eagerly in three places** β value constructor receives an invoked
+ result instead of a factory delegate: `Core/Observability/LoggerFactory.cs:16`,
+ `Core/Http/HttpClientFactory.cs:16-17`, `Core/ApplicationRegistry.cs:34`.
+- **Dead code:** `Core/Observability/Loggers/SimpleLogger.cs` (no references),
+ `Core/Routing/Patterns/RegexPattern.cs` (unused in production, yet 325 lines of tests),
+ `RouteTable.Routes` public setter (never assigned), narrow `ResponseHelper.Redirect`
+ overload shadowed by the flexible one.
+- Cosmetic: `Core/Settings.cs:14` typo `DefaulEnableTelemetryFactoryPerfLogs`;
+ `src/shared/Constants.cs:13,21,29` doubled words (`TestResultsTableTableName` etc.).
+
+## 4. Test suite gaps
+
+Only routing is tested (7 classes, good quality). Zero tests for: HMAC/nonce/secrets
+security stack, all feature handlers/services, caching, retry handler, `ResponseHelper`
+(ETag/If-None-Match logic), `ApiRouter`, and the real `RouteTable`.
+
+- `RouteResolverTests` fabricates its own route table which has drifted from production
+ (`TestIngestion` modeled as `ExactPattern("/tests/results")`; production uses a
+ 4-parameter `TemplatePattern`).
+- `RegexPattern` is heavily tested but not wired into production.
+- `TestBase.VerifyLogging` and `SetupILogger` are dead; no `.Verify` interaction checks.
+- `RouteTestBuilder`/`RouteTestExtensions` duplicated verbatim between the unit and
+ performance test projects; unit test csproj references BenchmarkDotNet needlessly.
+- Benchmark suite: `_Current` vs `_Optimized` pairs in `BufferAllocationBenchmarks`
+ execute identical code paths (vestigial); no committed baseline, no CI perf gate.
+
+Highest-value first tests: `NuGetVersionService`, `ResponseHelper` (both pure),
+`HmacAuthenticationService` (would have caught bug 1.1), `ResilienceRetryHandler`,
+`ApiRouter` + real `RouteTable`.
+
+## 5. Local-dev / scripts
+
+- `scripts/localstack.yml` is dead (zero references; stale compose version, removed
+ `PORT_WEB_UI` var, quoted `DEBUG` value). Delete or wire up and document.
+- k6: hardcoded deployed URL (`k6-perf-test.js:45`); README documents `K6_API_URL` /
+ `K6_DURATION` / `K6_VUS` env vars that the script never reads.
+- `test-ingestion.sh:120` uses GNU-only `date %3N` β broken on macOS despite README
+ claiming support; writes `response.tmp` into CWD instead of `mktemp`.
+- `build-lambda.ps1` help text shows GNU-style `--clean/--push` flags the script cannot
+ parse; `build-lambda.sh` lacks the ps1's output-zip existence check.
+- Seeder: `WORKER_TIMEOUT_IN_SECONDS` only bounds shutdown, not the seeding work
+ (`StartupTimeout` never set); AppHost injects `300` while launchSettings says `60`;
+ null-guard misses `OrgName`/`Type` before `ToLowerInvariant()`
+ (`OrgSecretSeeder.cs:135-142`).
+- Local/prod parity nit: prod sets `APP_NAME` / `APP_ENABLE_TELEMETRY_FACTORY_PERF_LOGS`
+ (values equal the code defaults), Aspire host sets neither.
+
+## 6. Suggested wave plan
+
+1. **Wave 1 β correctness:** bugs 1.1β1.4, nonce ordering, error-message hygiene, PAT
+ rotation (1.5).
+2. **Wave 2 β safety net:** tests for HMAC/ResponseHelper/real RouteTable +
+ `NuGetVersionService`; align resolver tests with production routes.
+3. **Wave 3 β hygiene:** DRY refactors, dead-code removal, script/docs drift fixes,
+ DynamoDB PITR/removal-policy decision.
+
+Performance opportunities are tracked separately in
+[2026-07-02-performance-opportunities.md](2026-07-02-performance-opportunities.md).
diff --git a/docs/research/2026-07-02-performance-opportunities.md b/docs/research/2026-07-02-performance-opportunities.md
new file mode 100644
index 0000000..5289fd5
--- /dev/null
+++ b/docs/research/2026-07-02-performance-opportunities.md
@@ -0,0 +1,219 @@
+# Performance Opportunities β Cold Start & Memory Footprint
+
+Date: 2026-07-02
+
+Goal: squeeze bootstrap latency and memory footprint of the Native AOT Lambda
+(`src/BadgeSmith.Api`). Every claim below is either a production measurement or a
+direct source-code observation (file:line as of `671e40e`). Estimates are marked as
+estimates; per AGENTS.md, no change ships without before/after measurement.
+
+## Measured baseline (production, 2026-07-02)
+
+`badge-smith-function`, `provided.al2023`, arm64, 512 MB. Sampled ~40 REPORT lines from
+the last 30 days of CloudWatch logs:
+
+| Metric | Value |
+| --- | --- |
+| Init Duration | ~105β140 ms |
+| Warm, cache-hit invoke | 1β3 ms |
+| Cold invoke Duration (after init) | 165β680 ms typical; outliers 1.3β1.7 s |
+| Max Memory Used | 32β49 MB (of 512 MB) |
+| Artifact | `bootstrap` 13.9 MB uncompressed / 6.3 MB zip (local x64 build) |
+
+Notes: the INIT phase is billed (AWS change, Aug 2025) β init ms are billed ms. Cold
+starts are frequent at this traffic level, so the cold path dominates user-visible
+latency for cache-missing badges.
+
+## Traffic, concurrency, and edge profile (measured 2026-07-02)
+
+Lambda, 30-day window (CloudWatch metrics + Logs Insights over all REPORT lines):
+
+| Metric | Value |
+| --- | --- |
+| Total invocations | 3,213 (β107/day β **0.07 RPM**) |
+| Typical day / spike days | 10β40 / 330β455 invocations |
+| Max concurrency | **3 on most days**; 12β33 on spike days |
+| Cold-start ratio | 517 / 3,213 = **16%** |
+| Avg init / duration p50 / p95 / max | 116 ms / 1.2 ms / 291 ms / 1,655 ms |
+
+The recurring max-concurrency of exactly 3 confirms the burst shape: a README render
+makes shields.io fetch ~3 badge URLs in parallel; on a CloudFront miss all 3 hit Lambda
+simultaneously, and since one execution environment serves one request at a time, that
+is 3 environments = **3 parallel cold starts**. They do not queue behind each other β
+each pays its own full cold penalty independently.
+
+CloudFront distribution `E2I09H2SLUEGLF` (api.localstackfor.net), same window:
+
+| Metric | Value |
+| --- | --- |
+| CloudFront requests | 5,683 |
+| Forwarded to Lambda | 3,213 β edge absorbs only **~43%** |
+| 4xx rate | **52%** (bot probes + nonexistent repo/branch badges) |
+| CacheHitRate metric | unavailable (additional metrics not enabled) |
+
+Key insight: `ResponseHelper.NotFound`/`BadRequest` send **no Cache-Control**, and the
+cache policy is origin-controlled β so 404s are never cached at the edge. Half the
+traffic is 4xx, and every bot probe / broken badge URL wakes the Lambda. The low p50
+(1.2 ms) is largely these cheap 404s.
+
+## Ranked opportunities
+
+### 0. Serve misses at the edge (HIGH β removes cold starts instead of speeding them up)
+
+Infrastructure-side, cheaper than any code change, driven by the edge measurements
+above:
+
+- **Cache negative responses**: add a short `Cache-Control` (`s-maxage=60β300`) to
+ 404/400 badge responses in `ResponseHelper`. With a 52% 4xx rate, this alone removes
+ a large share of Lambda invocations.
+- **Raise badge TTL**: `s-maxage=600` is conservative for badge data; 1800β3600 (with
+ the existing `stale-while-revalidate`) pushes edge absorption up from ~43% and makes
+ the "3 parallel cold starts per README render" scenario rare.
+- Optional: enable CloudFront additional metrics to get a real `CacheHitRate` series
+ for before/after validation.
+
+### 1. Move first-request work into the INIT phase (HIGH β cold-start latency)
+
+Init only builds `ApiRouter` (`Program.cs:15`). Everything else is `Lazy` and runs
+inside the **first billed invoke**: AWS SDK client construction + credential chain +
+endpoint resolution, `HttpClient`/`SocketsHttpHandler` creation, `MemoryCache`,
+`LoggerFactory`. That is exactly the measured 165β680 ms cold-invoke gap (warm is
+1β3 ms).
+
+The INIT phase runs with a full-vCPU burst regardless of the memory setting, while a
+512 MB invoke gets ~0.29 vCPU β the same work runs several times faster in init. Add an
+explicit warm-up in `Main` before `LambdaBootstrap.RunAsync`: touch the
+`ApplicationRegistry` graph, force AWS credential resolution (e.g., a cheap signed call
+or `ResolveIdentityAsync`), optionally pre-open the DynamoDB TLS connection.
+
+Validate: REPORT init/cold-duration distribution before vs after.
+
+### 2. ILC / publish settings (HIGH β binary size, MED β init time)
+
+No ILC or GC knobs are set anywhere (verified). Current explicit `TrimMode=partial`
+(`BadgeSmith.Api.csproj:17`) roots all unannotated assemblies β including the AWS SDK β
+into the AOT image. Candidates, each measured individually via publish-size diff:
+
+- `TrimMode=full` β likely the single biggest size lever. Trim/AOT warnings are
+ blocking (KNOWN_ISSUES); AWS SDK v4 claims trim-compat, verify at publish.
+- `IlcGenerateMstatFile=true` + sizoscope β measure what fills the 13.9 MB before
+ guessing further.
+- `StackTraceSupport=false` β real size win, real diagnostics cost; decide consciously.
+- `UseSystemResourceKeys=true` β strips framework exception-message resources.
+- `OptimizationPreference=Size` β try; the service is I/O-bound.
+- Feature switches for prod (telemetry-off) builds: `MetricsSupport=false`,
+ `HttpActivityPropagationSupport=false`.
+
+Smaller image β less to load/relocate β faster INIT; measure both size and init.
+
+### 3. Cache outcomes, not just payloads (MED β warm latency and allocations)
+
+Package services cache the raw upstream JSON but recompute everything per badge hit:
+deserialize + `NuGetVersion.TryParse` over **every** version (500+ for popular
+packages) + range filtering, on every request including cache hits
+(`NuGetPackageService.cs:93-108`, `GitHubPackageService.cs:106-122`,
+`NuGetVersionService.ParseAndFilterVersions`). Cache the final result keyed by
+`(packageId, versionRange, includePrerelease)` with the same TTL; the cache-hit path
+becomes near-allocation-free. Validate: BenchmarkDotNet with a 500-version corpus,
+`Allocated` column.
+
+### 4. Lambda memory rightsizing (MED β cost/latency tradeoff, measure first)
+
+Peak memory is 49 MB of 512 MB. Two directions: 256 MB halves GB-s cost but halves CPU
+(cold path slows); 1024 MB doubles CPU (cold TLS/init-heavy work speeds up) at double
+rate. Run AWS Lambda Power Tuning before touching `MemorySize`
+(`BadgeSmithFunctionConstruct.cs:39`); latency-first β likely 1024 MB, cost-first β
+256 MB. Traffic is small enough that this is a latency decision, not a cost one.
+
+### 5. Request-path allocation cleanup (LOW-MED each; mostly free wins)
+
+- `ApiRouter.cs:54` β `ToImmutableDictionary()` per request (builder + AVL nodes,
+ slower lookups). A `Dictionary(capacity, OrdinalIgnoreCase)` or a fixed-slot struct
+ (β€5 params today) is cheaper. The elaborate span-based `RouteValues` is currently
+ nullified by this materialization.
+- `RouteResolver.cs:18` β param buffer heap-allocated per request. An
+ `[InlineArray]` buffer sized from the route table at startup removes the allocation
+ **and** resolves GitHub issue #1 (buffer-overflow guard) in one change.
+- `RouteResolver.cs:23` β `Normalize(d.Method)` per descriptor per request; precompute
+ normalized methods in the resolver constructor.
+- `ResponseHelper` β `Func` closures invoked immediately (pure indirection
+ + closure allocs); pass dictionaries directly. Do NOT share static header instances:
+ `CorsHandler.ApplyResponseHeaders` mutates response headers.
+- `Program.cs:36` β `CreateLogger()` per request; hoist to a static field.
+
+### 6. HMAC path: drop the double hex round-trip (LOW perf, includes a correctness fix)
+
+`HmacAuthenticationService.cs:127-143`: computed HMAC β hex string β lowercased β
+`Convert.FromHexString` again; provided signature also hex-decoded. Replace with static
+`HMACSHA256.HashData(key, payload, stackalloc 32B)` + `FixedTimeEquals` on raw bytes.
+While there: `Convert.FromHexString(providedHash)` **throws `FormatException` on
+malformed input today** (no catch β verified), turning a garbage `X-Signature` header
+into a 500 instead of a 401. Use the `OperationStatus`/Try overload.
+
+### 7. Robustness items found during this pass (not perf, tracked here)
+
+- No `IsBase64Encoded` handling anywhere (verified). If API Gateway ever
+ base64-encodes a POST body (content-type/encoding dependent), HMAC validation and
+ JSON parsing silently fail. Cheap guard in the ingestion handler.
+- Request CTS uses a compile-time constant timeout (`Settings.LambdaTimeout`) rather
+ than `ILambdaContext.RemainingTime`.
+
+## Explicitly not worth it
+
+- **Hand-rolled SIMD**: the hot primitives are already vectorized/HW-accelerated in the
+ BCL β `IndexOf('/')` and ordinal-ignore-case compares (SpanHelpers), hex conversion,
+ SHA-256/HMAC (OS crypto, ARMv8 crypto extensions). Payloads are ~200 B JSON; there is
+ no loop in this codebase long enough for custom vectorization to beat the BCL.
+- Swapping SHA-256 ETags for XxHash (~1 Β΅s on 200 B is already noise).
+- Replacing NuGet.Versioning with hand-rolled semver (range/prerelease correctness risk).
+- Pooling ~200 B response bodies; `IlcInstructionSet` tuning on Graviton.
+- Dropping `Microsoft.Extensions.Caching.Memory` for a hand-rolled TTL cache β revisit
+ only if the item-2 mstat data shows it paying meaningful size; it has no background
+ timer (expiration piggybacks on access), so there is no freeze/thaw concern.
+
+## Honest assessment (2026-07-02)
+
+Question asked: "is this the fastest, lowest-memory .NET Lambda that can be built?"
+
+- **Architecture: A.** AOT + RuntimeSupport (no ASP.NET host), arm64, source-gen-only
+ JSON, no DI/config framework, conditional compilation stripping telemetry from prod.
+ Reference points: a typical managed `dotnet8` + ASP.NET-hosted Lambda inits at
+ 400β900 ms and idles at 90β150 MB; this one is 116 ms / 33β49 MB.
+- **Memory: A-.** 33β49 MB is near the practical floor for AOT + AWS SDK v4 + two
+ HttpClient stacks. Chasing sub-25 MB is vanity β billing is by configured memory.
+- **Cold-start execution: B-.** Effective cold start is ~300β800 ms, not 116 ms,
+ because lazy-init defers AWS/TLS/HttpClient setup into the first billed invoke at
+ ~0.29 vCPU. The remaining gap to the floor is items 0β2 above, all cheap.
+- Deliberately out of bounds: raw runtime-API loop + hand-parsed event JSON (last ~2%,
+ not worth the maintainability cost).
+
+## Decisions (2026-07-02)
+
+Agreed direction, pending "go" for implementation:
+
+1. **Do**: negative-response caching + badge TTL increase (item 0); INIT-phase warm-up
+ (item 1); `TrimMode=full` + ILC/feature switches with per-knob size measurement
+ (item 2). Result caching (item 3) and request-path cleanup (item 5) ride along.
+2. **Rejected β provisioned concurrency**: eliminates colds but is always-on paid
+ capacity; overkill at 0.07 RPM.
+3. **Rejected β keep-warm ping (EventBridge)**: keeps only 1 environment warm; the
+ 3-parallel-fetch burst still colds the other 2. Limited value once item 0 lands.
+4. **N/A β SnapStart**: not available for `provided.al2023` custom runtime.
+5. **Not doing β hand-rolled SIMD / raw runtime loop / NuGet.Versioning replacement**:
+ see "Explicitly not worth it".
+
+## Measurement plan
+
+1. `IlcGenerateMstatFile` + sizoscope snapshot before any csproj change.
+2. Publish-size diff per ILC knob; zero-AOT-warning bar holds.
+3. CloudWatch REPORT init + cold-duration distribution before/after (baseline above).
+4. BenchmarkDotNet: `ParseAndFilterVersions` (500-version corpus) and the routing
+ pipeline `Allocated` column. Fix the vestigial `_Current`/`_Optimized` benchmark
+ pairs first (they currently execute identical code).
+5. k6 end-to-end after wiring `K6_API_URL` support.
+
+## Process note
+
+A marketplace perf-scan agent was dispatched for a second opinion; its report cited
+nonexistent files and fabricated code (zero tool calls recorded) and was discarded.
+All findings above come from direct source reading and CloudWatch measurements.
diff --git a/docs/research/2026-07-04-localstack-lambda-image-spike.md b/docs/research/2026-07-04-localstack-lambda-image-spike.md
new file mode 100644
index 0000000..bc9e487
--- /dev/null
+++ b/docs/research/2026-07-04-localstack-lambda-image-spike.md
@@ -0,0 +1,110 @@
+# LocalStack Lambda Image Spike
+
+Date: 2026-07-04
+
+## Purpose
+
+Determine whether BadgeSmith's published Native AOT Lambda container image can be executed locally through LocalStack over a normal HTTP endpoint for k6 benchmark runs.
+
+## Environment
+
+| Component | Observed value |
+| --- | --- |
+| OS shell | Windows PowerShell with Docker Desktop Linux backend |
+| Docker | Client 29.5.3, Server 29.5.3 |
+| AWS CLI | aws-cli/2.34.37 Python/3.14.4 Windows/11 exe/AMD64 |
+| curl | 8.20.0 |
+| LocalStack image | `localstack/localstack:4.6` |
+| LocalStack edition/version | Community, 4.6.0 |
+| Lambda image | `badge-smith:localstack-spike` |
+| Lambda image size | 193 MB |
+
+Full command excerpts are in `artifacts/localstack-lambda-image-spike.log`.
+
+## Commands And Evidence
+
+### Image Build
+
+The Lambda image built successfully from `src/BadgeSmith.Api/Dockerfile`:
+
+```text
+docker build -f "src/BadgeSmith.Api/Dockerfile" --target lambda-image -t badge-smith:localstack-spike .
+...
+BadgeSmith.Api -> /artifacts/publish/
+naming to docker.io/library/badge-smith:localstack-spike done
+```
+
+### LocalStack Startup
+
+LocalStack started with Docker socket access and reported healthy:
+
+```text
+docker run -d --name bs-ls-spike -p 4566:4566 -e DEBUG=1 -v /var/run/docker.sock:/var/run/docker.sock localstack/localstack:4.6
+
+Invoke-WebRequest http://localhost:4566/_localstack/health
+{"edition":"community","version":"4.6.0",...}
+```
+
+### Seeding
+
+The planned command did not seed the spike container:
+
+```text
+bash scripts/perf-baseline-seed.sh bridge
+aws --endpoint-url http://localhost:4566 dynamodb list-tables
+TableNames: []
+```
+
+Tracing showed the script is hard-coded around `bs-perf-ls` local endpoint discovery, while this spike uses `bs-ls-spike`:
+
+```text
++ docker port bs-perf-ls 4566/tcp
++ LS_PORT=
+```
+
+The same DynamoDB tables, Secrets Manager entries, org-secret mappings, and five benchmark test-result rows were seeded manually through `aws --endpoint-url http://localhost:4566` with dummy LocalStack credentials. Verification:
+
+```text
+badge-smith-github-org-secrets badge-smith-hmac-nonce badge-smith-test-result
+badgesmith/github/test-org/testdata badgesmith/github/test-org/package badgesmith/github/localstack-dotnet/package
+5
+```
+
+### Function URL Attempt
+
+LocalStack accepted the image function definition and Function URL configuration:
+
+```text
+aws --endpoint-url http://localhost:4566 lambda create-function --function-name badge-smith-spike --package-type Image --code ImageUri=badge-smith:localstack-spike ...
+FunctionName: badge-smith-spike
+PackageType: Image
+Architectures: x86_64
+
+aws --endpoint-url http://localhost:4566 lambda wait function-active-v2 --function-name badge-smith-spike
+
+aws --endpoint-url http://localhost:4566 lambda create-function-url-config --function-name badge-smith-spike --auth-type NONE
+FunctionUrl: http://i8eh5cigasvweb5v15xh8by1pzh4wn8t.lambda-url.us-east-1.localhost.localstack.cloud:4566/
+```
+
+Invoking `/health` through the Function URL failed before BadgeSmith started:
+
+```text
+curl -i "http://i8eh5cigasvweb5v15xh8by1pzh4wn8t.lambda-url.us-east-1.localhost.localstack.cloud:4566/health"
+HTTP/1.1 500 INTERNAL SERVER ERROR
+X-Amzn-Errortype: InternalError
+
+NotImplementedError: Container images are a Pro feature.
+localstack.services.lambda_.invocation.assignment.AssignmentException: Could not start new environment: NotImplementedError:Container images are a Pro feature.
+```
+
+API Gateway v2 was not attempted because the failure is at Lambda image execution startup. API Gateway v2 would still invoke the same image-based Lambda and cannot prove HTTP event shape while LocalStack Community refuses to run the container image.
+
+## Decision
+
+- Selected target: none
+- Reason: LocalStack failed to execute the published BadgeSmith Lambda image reliably. Do not reintroduce RIE; use Aspire Testing for contract coverage and deployed AWS for AOT artifact verification.
+
+## Follow-Up
+
+- Task 8 cannot build a working LocalStack image-backed benchmark harness in LocalStack Community from this evidence.
+- `scripts/perf-baseline-seed.sh` should be updated or guarded because it assumes the benchmark container name `bs-perf-ls` and did not seed the Task 7 `bs-ls-spike` endpoint.
diff --git a/docs/research/2026-07-06-cliwrap-script-replacement.md b/docs/research/2026-07-06-cliwrap-script-replacement.md
new file mode 100644
index 0000000..e09e02d
--- /dev/null
+++ b/docs/research/2026-07-06-cliwrap-script-replacement.md
@@ -0,0 +1,152 @@
+# CliWrap Script Replacement For BadgeSmith Tools
+
+Date: 2026-07-06
+
+Temporary research note for W1.5 file-based tooling migration.
+
+## Sources
+
+- CliWrap README: `https://github.com/Tyrrrz/CliWrap`
+- CliWrap v3 usage and migration guidance.
+- Context7 library lookup: `/tyrrrz/cliwrap`.
+- BadgeSmith script inventory under `scripts/` and `.github/workflows/`.
+
+## Recommended Patterns
+
+Use CliWrap for external process execution instead of `Process.Start`, shell scripts, or
+manual command strings.
+
+Pass arguments as arrays or builders. This avoids shell quoting rules and command injection
+risks.
+
+Use streaming execution for long-running commands such as `docker buildx build`, `k6 run`,
+and CDK local performance-stack steps that need live diagnostics.
+
+Use buffered execution for commands whose output is parsed, such as `git rev-parse`,
+`docker port`, `docker stats --format`, and AWS CLI commands that emit JSON.
+
+Use `WithWorkingDirectory` instead of `pushd` and `popd`.
+
+Use `WithEnvironmentVariables` for AWS, CDK, LocalStack, and test-specific environment
+overrides. Only clear inherited environment values when there is a concrete reason.
+
+Use C# native APIs instead of process calls where practical. Replace `curl` with
+`HttpClient`, `date` with `DateTimeOffset.UtcNow`, `uuidgen` with `Guid.NewGuid()`, and
+inline Python/JQ JSON manipulation with `System.Text.Json`.
+
+## Example Streaming Command
+
+```csharp
+await Cli.Wrap("docker")
+ .WithArguments([
+ "buildx", "build",
+ "-f", dockerfile,
+ "--target", "export-zip",
+ "--build-arg", $"RID={rid}",
+ "--platform", platform,
+ "--output", $"type=local,dest={outDir}",
+ context
+ ])
+ .ExecuteAsync(cancellationToken);
+```
+
+For commands with important real-time output, wrap streaming in a `ProcessRunner` helper so
+stdout and stderr are consistently written to the console and optional log files.
+
+## Example Buffered Command
+
+```csharp
+var result = await Cli.Wrap("git")
+ .WithArguments(["rev-parse", "--short", "HEAD"])
+ .ExecuteBufferedAsync(cancellationToken);
+
+var shortSha = result.StandardOutput.Trim();
+```
+
+## Example Environment And Working Directory
+
+```csharp
+await Cli.Wrap("npx")
+ .WithWorkingDirectory(Path.Combine(repoRoot, "build"))
+ .WithArguments([
+ "-y",
+ "-p", "aws-cdk-local@3.0.4",
+ "-p", "aws-cdk@2.1129.0",
+ "cdklocal",
+ "deploy",
+ "BadgeSmithPerformanceStack",
+ "--require-approval", "never"
+ ])
+ .WithEnvironmentVariables(env => env
+ .Set("AWS_ACCESS_KEY_ID", "test")
+ .Set("AWS_SECRET_ACCESS_KEY", "test")
+ .Set("AWS_DEFAULT_REGION", "us-east-1")
+ .Set("AWS_REGION", "us-east-1")
+ .Set("AWS_ENDPOINT_URL", $"http://localhost:{localStackPort}")
+ .Set("CDK_DEFAULT_ACCOUNT", "000000000000")
+ .Set("CDK_DEFAULT_REGION", "us-east-1")
+ .Set("LOCALSTACK_HOST", $"localhost:{localStackPort}"))
+ .ExecuteAsync(cancellationToken);
+```
+
+This pattern is for BadgeSmith-specific orchestration such as local performance-stack
+deployment. Generic production `cdk synth`, `cdk diff`, and `cdk deploy` workflow steps do
+not need wrapping when they are already one-line commands.
+
+## ProcessRunner Shape
+
+A small `ProcessRunner` should provide two paths:
+
+- `RunStreamingAsync` for long-running commands where logs matter.
+- `RunBufferedAsync` for commands whose output is parsed.
+
+The helper should support executable name, argument collection, optional working directory,
+optional environment overrides, verbose logging, cancellation, and clear exit-code handling.
+
+Default CliWrap validation should fail on non-zero exit codes. Opt out with explicit
+validation only for commands where non-zero status is an expected branch.
+
+## Path Handling
+
+Use `Path.Combine`, absolute repository paths, and `Directory.CreateDirectory` rather than
+shell path manipulation. The .NET tool runs natively on Windows and Unix, so much of the
+existing Bash WSL path translation can disappear.
+
+When a child tool requires a host path in a specific format, isolate that conversion in one
+helper and keep call sites clean.
+
+## Temporary Files And Artifacts
+
+Use `Path.Combine(Path.GetTempPath(), Path.GetRandomFileName())` for process coordination
+paths when the file should not be created immediately. `Path.GetTempFileName()` creates a
+0-byte file as a side effect, so reserve it for cases where that behavior is required. Use
+the repository `artifacts/` directory for intentional outputs such as k6 summaries,
+LocalStack logs, and Lambda ZIP artifacts.
+
+Always clean transient files in `finally` blocks. Keep diagnostic artifacts on failure when
+they help troubleshooting.
+
+## Anti-Patterns To Avoid
+
+- Do not use `bash -c`, `cmd /C`, or PowerShell as a generic process wrapper.
+- Do not build raw argument strings that rely on shell escaping.
+- Do not set `CommandResultValidation.None` globally.
+- Do not ignore stderr; Docker and similar tools often write useful progress or warnings
+ there.
+- Do not parse human output if a tool offers JSON or `--format` output.
+- Do not call external tools for behavior that has a simple .NET API equivalent.
+
+## BadgeSmith Migration Mapping
+
+| Current script behavior | Preferred C# replacement |
+| --- | --- |
+| `docker buildx build ...` | CliWrap streaming command |
+| `k6 run --summary-export ...` | CliWrap streaming command with summary path |
+| `git rev-parse --short HEAD` | CliWrap buffered command |
+| `docker port` and `docker stats --format` | CliWrap buffered command |
+| AWS CLI table and secret operations | CliWrap buffered or streaming command with explicit LocalStack env |
+| `curl -X POST` for signed ingestion | `HttpClient` |
+| shell `date` | `DateTimeOffset.UtcNow` |
+| `uuidgen` | `Guid.NewGuid().ToString("N")` |
+| inline Python/JQ JSON processing | `System.Text.Json` |
+| `trap cleanup EXIT` | `try/finally` |
diff --git a/docs/research/2026-07-06-spectre-console-cli-usage.md b/docs/research/2026-07-06-spectre-console-cli-usage.md
new file mode 100644
index 0000000..d8acf27
--- /dev/null
+++ b/docs/research/2026-07-06-spectre-console-cli-usage.md
@@ -0,0 +1,146 @@
+# Spectre.Console.Cli Usage For BadgeSmith Tools
+
+Date: 2026-07-06
+
+Temporary research note for W1.5 file-based tooling migration.
+
+## Sources
+
+- Spectre.Console.Cli documentation: `https://spectreconsole.net/cli/`
+- Spectre.Console.Cli multi-command tutorial and command app configuration guidance.
+- Spectre.Console.Cli command lifecycle, async command, validation, help text, error handling, and testing guidance.
+- Context7 library lookup: `/spectreconsole/spectre.console.cli`.
+
+## Recommended Patterns
+
+Use `CommandApp` with branches rather than one default command. BadgeSmith needs a command
+tree with `lambda`, `perf`, `tests`, and `badge` branches.
+
+Use `AsyncCommand` for every command because most work involves file I/O,
+process execution, or HTTP requests.
+
+Use `CommandSettings` classes for command arguments and options. Keep simple validation in
+`CommandSettings.Validate()` and command-aware validation in the command's `Validate()`
+override.
+
+Configure help and examples on registration. Use descriptions, examples, and default value
+display so the CLI replaces the help text that currently lives in shell scripts.
+
+Use a central exception handler for consistent exit codes and user-facing errors. Command
+implementations should catch only expected, local conditions they can handle.
+
+Prefer injected `IAnsiConsole` or a thin console abstraction over static `AnsiConsole` in
+command bodies when it improves testability. Do not introduce a broad dependency injection
+framework unless command construction needs it.
+
+If DI is needed, use a small local `ITypeRegistrar` implementation. Avoid adding
+`Spectre.Console.Cli.Extensions.DependencyInjection` unless it provides a concrete benefit.
+
+## File-Based App Shape
+
+The selected W1.5 shape is not a traditional `.csproj` tool. It is a .NET 10 file-based app
+with includes:
+
+```csharp
+#!/usr/bin/env -S dotnet --
+#:property TargetFramework=net10.0
+#:property PublishAot=false
+#:property PackAsTool=false
+#:package Spectre.Console.Cli
+#:include Commands/**/*.cs
+#:include Infrastructure/**/*.cs
+```
+
+This keeps the tool lightweight while avoiding a single oversized source file.
+
+## Example Command Registration
+
+```csharp
+var app = new CommandApp();
+app.Configure(config =>
+{
+ config.SetApplicationName("badgesmith");
+ config.Settings.ShowOptionDefaultValues = true;
+ config.Settings.CaseSensitivity = CaseSensitivity.None;
+
+ config.AddBranch("lambda", lambda =>
+ {
+ lambda.AddCommand("build")
+ .WithDescription("Build the BadgeSmith Lambda ZIP or container image.")
+ .WithExample("lambda", "build", "--target", "zip", "--rid", "linux-arm64", "--clean");
+ });
+
+ config.AddBranch("tests", tests =>
+ {
+ tests.AddCommand("run")
+ .WithDescription("Run a test project once per target framework.");
+ tests.AddCommand("ingest")
+ .WithDescription("Post test result payloads to BadgeSmith.");
+ });
+});
+
+return await app.RunAsync(args);
+```
+
+## Example Settings Validation
+
+```csharp
+public sealed class LambdaBuildSettings : CommandSettings
+{
+ [CommandOption("--target")]
+ [Description("Build target: zip, image, or both.")]
+ public string Target { get; init; } = "zip";
+
+ [CommandOption("--rid")]
+ [Description("Runtime identifier: linux-arm64 or linux-x64.")]
+ public string Rid { get; init; } = "linux-arm64";
+
+ public override ValidationResult Validate()
+ {
+ if (Target is not "zip" and not "image" and not "both")
+ {
+ return ValidationResult.Error("--target must be zip, image, or both.");
+ }
+
+ if (Rid is not "linux-arm64" and not "linux-x64")
+ {
+ return ValidationResult.Error("--rid must be linux-arm64 or linux-x64.");
+ }
+
+ return ValidationResult.Success();
+ }
+}
+```
+
+## Exit Codes
+
+Use small, stable exit-code conventions:
+
+| Code | Meaning |
+| --- | --- |
+| `0` | Success |
+| `1` | General command failure |
+| `2` | Input or validation failure |
+| `3` | External process failure |
+| `4` | Network or HTTP failure |
+| `130` | Cancellation |
+
+## Anti-Patterns To Avoid
+
+- Do not put all validation inside `ExecuteAsync`.
+- Do not use synchronous `.Result` or `.Wait()` over async APIs.
+- Do not use static console calls everywhere if a command needs testability.
+- Do not hide many unrelated behaviors behind one command with mode flags when a branch
+ and subcommand would be clearer.
+- Do not convert W1.5 into a traditional `.csproj` tool unless file-based structure becomes
+ a proven maintenance problem.
+
+## Notes For Implementation
+
+Add `Spectre.Console.Cli` to `Directory.Packages.props`. Add testing-specific Spectre
+packages only if command parsing tests are included in this work.
+
+Keep generated help output as the replacement for removed shell-script help text.
+
+Treat the traditional project recommendation from generic Spectre examples as rejected for
+W1.5. BadgeSmith's selected design is file-based app plus `#:include`.
diff --git a/docs/research/baselines/.gitkeep b/docs/research/baselines/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/docs/research/baselines/2026-07-04-final-localstack-smoke.json b/docs/research/baselines/2026-07-04-final-localstack-smoke.json
new file mode 100644
index 0000000..28f4009
--- /dev/null
+++ b/docs/research/baselines/2026-07-04-final-localstack-smoke.json
@@ -0,0 +1,28 @@
+{
+ "date": "2026-07-04",
+ "label": "final-localstack-smoke",
+ "gitSha": "f178fab",
+ "arch": "amd64",
+ "upstream": "mock",
+ "image": {
+ "binaryBytes": 13913736,
+ "zipBytes": 6275435,
+ "mstat": "artifacts/mstat/bootstrap.mstat"
+ },
+ "boot": {
+ "startToReadyMs": 1193
+ },
+ "k6": {
+ "p50Ms": 22.4538,
+ "p95Ms": 235.4588099999998,
+ "p99Ms": 364.68760200000014,
+ "rps": 1.095273796817547,
+ "errorRate": 0
+ },
+ "memory": {
+ "rssIdleMb": 16.3,
+ "rssPeakMb": 20.279,
+ "source": "docker-stats-localstack-lambda-worker",
+ "containerId": "46ce58998a9a"
+ }
+}
\ No newline at end of file
diff --git a/docs/research/baselines/2026-07-04-live-cloudfront-smoke.json b/docs/research/baselines/2026-07-04-live-cloudfront-smoke.json
new file mode 100644
index 0000000..4bd4472
--- /dev/null
+++ b/docs/research/baselines/2026-07-04-live-cloudfront-smoke.json
@@ -0,0 +1,100 @@
+{
+ "date": "2026-07-04",
+ "label": "live-cloudfront-smoke",
+ "gitSha": "9ff91ac",
+ "target": {
+ "kind": "cloudfront-distribution",
+ "url": "https://api.localstackfor.net",
+ "distributionId": "E2I09H2SLUEGLF",
+ "domainName": "d1abn2jrw3q4e6.cloudfront.net",
+ "alias": "api.localstackfor.net",
+ "origin": "g4yecfi5hl.execute-api.eu-central-1.amazonaws.com",
+ "awsProfile": "personal",
+ "region": "eu-central-1"
+ },
+ "lambda": {
+ "functionName": "badge-smith-function",
+ "runtime": "provided.al2023",
+ "architecture": "arm64",
+ "configuredMemoryMb": 512,
+ "timeoutSeconds": 20,
+ "lastModified": "2025-12-20T19:34:30.000+0000"
+ },
+ "run": {
+ "startUtc": "2026-07-04T22:36:42.6900000Z",
+ "endUtcApproximate": "2026-07-04T22:37:44.4320000Z",
+ "duration": "60s",
+ "vus": 1,
+ "summaryExport": "artifacts/k6-live-cloudfront-summary.json",
+ "endTimeNote": "Approximate end time is derived from k6 summary rates; CloudWatch query windows include buffer time."
+ },
+ "k6": {
+ "httpRequests": 53,
+ "iterations": 49,
+ "rps": 0.8584004679701341,
+ "checks": {
+ "passes": 161,
+ "fails": 0,
+ "rate": 1.0
+ },
+ "http": {
+ "avgMs": 83.72795660377359,
+ "p50Ms": 45.1296,
+ "p95Ms": 253.06403999999998,
+ "p99Ms": 392.0573919999998,
+ "maxMs": 417.5571,
+ "failedRate": 0.11320754716981132
+ },
+ "applicationErrorsRate": 0.0,
+ "cacheHitRate": 1.0,
+ "clientColdStartHeuristicRate": 0.025,
+ "notes": [
+ "k6 http_req_failed includes expected 404 responses from test-result and edge-case scenarios; all explicit checks passed.",
+ "clientColdStartHeuristicRate is derived from response time heuristics, not Lambda REPORT Init Duration.",
+ "Use this as a CloudFront comparison only. Direct API Gateway remains the Lambda/API baseline because CloudFront can serve requests without invoking Lambda."
+ ]
+ },
+ "cloudWatchLogs": {
+ "logGroup": "/aws/lambda/badge-smith-function",
+ "reportWindowStartEpoch": 1783204596,
+ "reportWindowEndEpoch": 1783204675,
+ "durationAndMemoryQueryId": "21c9f32a-6531-4de1-b9f0-be522b9b3b6f",
+ "initDurationQueryId": "2fe7417e-0eba-4eac-8670-3dc975fa8b5e",
+ "errorSearchQueryId": "ddab7e87-382b-47fa-9166-b829d08774d9",
+ "reportCount": 25,
+ "duration": {
+ "minMs": 1.15,
+ "avgMs": 46.9236,
+ "p50Ms": 5.28,
+ "p95Ms": 194.07,
+ "p99Ms": 280.45,
+ "maxMs": 280.45
+ },
+ "memory": {
+ "minUsedMb": 34,
+ "avgUsedMb": 47.76,
+ "maxUsedMb": 51,
+ "configuredMb": 512
+ },
+ "coldStarts": {
+ "reportCount": 25,
+ "initReportCount": 1,
+ "initRate": 0.04,
+ "minInitDurationMs": 127.42,
+ "avgInitDurationMs": 127.42,
+ "p95InitDurationMs": 127.42,
+ "maxInitDurationMs": 127.42
+ },
+ "matchingErrorMessages": 0
+ },
+ "comparison": {
+ "directGatewayBaseline": "docs/research/baselines/2026-07-04-live-gateway-smoke.json",
+ "directGatewayClientRequests": 64,
+ "directGatewayLambdaReportCount": 64,
+ "cloudFrontClientRequests": 53,
+ "cloudFrontLambdaReportCount": 25,
+ "cloudFrontLambdaForwardingRatio": 0.4716981132075472,
+ "cloudFrontEstimatedEdgeAbsorptionRatio": 0.5283018867924528,
+ "interpretation": "CloudFront lowered observed client latency and Lambda REPORT count in this smoke run. Do not use CloudFront numbers as the Lambda/API service baseline."
+ }
+}
diff --git a/docs/research/baselines/2026-07-04-live-gateway-smoke.json b/docs/research/baselines/2026-07-04-live-gateway-smoke.json
new file mode 100644
index 0000000..f73ed0b
--- /dev/null
+++ b/docs/research/baselines/2026-07-04-live-gateway-smoke.json
@@ -0,0 +1,85 @@
+{
+ "date": "2026-07-04",
+ "label": "live-gateway-smoke",
+ "gitSha": "2827531",
+ "target": {
+ "kind": "api-gateway-http-v2-direct",
+ "url": "https://g4yecfi5hl.execute-api.eu-central-1.amazonaws.com",
+ "cloudFrontBypassed": true,
+ "awsProfile": "personal",
+ "region": "eu-central-1"
+ },
+ "lambda": {
+ "functionName": "badge-smith-function",
+ "runtime": "provided.al2023",
+ "architecture": "arm64",
+ "configuredMemoryMb": 512,
+ "timeoutSeconds": 20,
+ "lastModified": "2025-12-20T19:34:30.000+0000"
+ },
+ "run": {
+ "startUtc": "2026-07-04T22:05:05.8359435Z",
+ "endUtc": "2026-07-04T22:06:08.5895387Z",
+ "duration": "60s",
+ "vus": 1,
+ "summaryExport": "artifacts/k6-live-gateway-summary.json"
+ },
+ "k6": {
+ "httpRequests": 64,
+ "iterations": 57,
+ "rps": 1.0351188676082952,
+ "checks": {
+ "passes": 172,
+ "fails": 0,
+ "rate": 1.0
+ },
+ "http": {
+ "avgMs": 134.751090625,
+ "p50Ms": 67.67195000000001,
+ "p95Ms": 309.1397699999999,
+ "p99Ms": 398.27357599999993,
+ "maxMs": 412.1555,
+ "failedRate": 0.265625
+ },
+ "applicationErrorsRate": 0.0,
+ "cacheHitRate": 1.0,
+ "clientColdStartHeuristicRate": 0.06976744186046512,
+ "notes": [
+ "k6 http_req_failed includes expected 404 responses from test-result and edge-case scenarios; all explicit checks passed.",
+ "clientColdStartHeuristicRate is derived from response time heuristics, not Lambda REPORT Init Duration."
+ ]
+ },
+ "cloudWatchLogs": {
+ "logGroup": "/aws/lambda/badge-smith-function",
+ "reportWindowStartEpoch": 1783202700,
+ "reportWindowEndEpoch": 1783202785,
+ "durationAndMemoryQueryId": "9e164597-e0b0-48d5-9574-decd78c23691",
+ "initDurationQueryId": "cb71d1fb-2fb7-4034-8570-758b9fe64dbc",
+ "errorSearchQueryId": "d3e37c58-50d5-45b0-b730-5a8aa675c93e",
+ "reportCount": 64,
+ "duration": {
+ "minMs": 0.94,
+ "avgMs": 69.4367,
+ "p50Ms": 5.04,
+ "p95Ms": 229.96,
+ "p99Ms": 300.36,
+ "maxMs": 300.36
+ },
+ "memory": {
+ "minUsedMb": 34,
+ "avgUsedMb": 47.4688,
+ "maxUsedMb": 49,
+ "configuredMb": 512
+ },
+ "coldStarts": {
+ "reportCount": 64,
+ "initReportCount": 1,
+ "initRate": 0.015625,
+ "minInitDurationMs": 134.12,
+ "avgInitDurationMs": 134.12,
+ "p95InitDurationMs": 134.12,
+ "maxInitDurationMs": 134.12
+ },
+ "matchingErrorMessages": 0
+ }
+}
diff --git a/docs/research/baselines/2026-07-04-localstack-smoke.json b/docs/research/baselines/2026-07-04-localstack-smoke.json
new file mode 100644
index 0000000..66c682f
--- /dev/null
+++ b/docs/research/baselines/2026-07-04-localstack-smoke.json
@@ -0,0 +1,28 @@
+{
+ "date": "2026-07-04",
+ "label": "localstack-smoke",
+ "gitSha": "f178fab",
+ "arch": "amd64",
+ "upstream": "mock",
+ "image": {
+ "binaryBytes": 13913736,
+ "zipBytes": 6275435,
+ "mstat": "artifacts/mstat/bootstrap.mstat"
+ },
+ "boot": {
+ "startToReadyMs": 803
+ },
+ "k6": {
+ "p50Ms": 20.2579,
+ "p95Ms": 149.92516999999987,
+ "p99Ms": 245.82959400000016,
+ "rps": 1.1535423839877859,
+ "errorRate": 0
+ },
+ "memory": {
+ "rssIdleMb": 17.38,
+ "rssPeakMb": 20.899,
+ "source": "docker-stats-localstack-lambda-worker",
+ "containerId": "18f35ad2a63b"
+ }
+}
\ No newline at end of file
diff --git a/global.json b/global.json
index d46d21e..7c8e277 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "10.0.100",
+ "version": "10.0.301",
"rollForward": "latestFeature",
"allowPrerelease": false
}
diff --git a/scripts/README-PERF-TESTING.md b/scripts/README-PERF-TESTING.md
deleted file mode 100644
index 8fbe765..0000000
--- a/scripts/README-PERF-TESTING.md
+++ /dev/null
@@ -1,228 +0,0 @@
-# BadgeSmith Lambda Performance Testing
-
-Comprehensive k6 performance testing for the BadgeSmith Lambda function with realistic traffic patterns.
-
-## π Quick Start
-
-### Install k6
-
-```bash
-# Windows (Chocolatey)
-choco install k6
-
-# macOS (Homebrew)
-brew install k6
-
-# Linux (apt)
-sudo apt update && sudo apt install k6
-
-# Or download from: https://k6.io/docs/getting-started/installation/
-```
-
-## π Test Types
-
-### 1. Quick Smoke Test (2 minutes)
-
-Fast validation test to ensure the Lambda is responding correctly:
-
-```bash
-k6 run --duration 2m --vus 10 scripts/k6-perf-test.js
-```
-
-### 2. Standard Load Test (5 minutes)
-
-Default test with moderate load - good for regular performance checks:
-
-```bash
-k6 run --duration 5m --vus 50 scripts/k6-perf-test.js
-```
-
-### 3. Stress Test (10 minutes)
-
-High load test to find the breaking point and trigger memory pressure:
-
-```bash
-k6 run --duration 10m --vus 200 scripts/k6-perf-test.js
-```
-
-### 4. Endurance Test (30+ minutes)
-
-Long-running test to detect memory leaks and performance degradation over time:
-
-```bash
-# 30 minute endurance test with sustained load
-k6 run --duration 30m --vus 30 scripts/k6-perf-test.js
-
-# 1 hour endurance test
-k6 run --duration 1h --vus 25 scripts/k6-perf-test.js
-
-# 2 hour marathon test
-k6 run --duration 2h --vus 20 scripts/k6-perf-test.js
-```
-
-### 5. Spike Test (3 minutes)
-
-Short bursts of extreme load to test Lambda cold start handling:
-
-```bash
-k6 run --duration 3m --vus 500 scripts/k6-perf-test.js
-```
-
-### 6. Capacity Test (15 minutes)
-
-Find maximum sustainable throughput:
-
-```bash
-k6 run --duration 15m --vus 300 scripts/k6-perf-test.js
-```
-
-## π§ Advanced Configuration
-
-### Custom Staging Patterns
-
-Override the built-in stages with your own load pattern:
-
-```bash
-# Gradual ramp-up test
-k6 run --stage 1m:10,5m:50,5m:100,5m:150,1m:0 scripts/k6-perf-test.js
-
-# Step load test
-k6 run --stage 2m:25,2m:50,2m:75,2m:100,2m:0 scripts/k6-perf-test.js
-
-# Spike pattern
-k6 run --stage 30s:10,30s:500,1m:10,30s:800,1m:0 scripts/k6-perf-test.js
-```
-
-### Save Results to Files
-
-```bash
-# JSON output for detailed analysis
-k6 run --duration 5m --vus 50 --out json=results.json scripts/k6-perf-test.js
-
-# CSV output for spreadsheet analysis
-k6 run --duration 5m --vus 50 --out csv=results.csv scripts/k6-perf-test.js
-
-# Multiple outputs
-k6 run --duration 5m --vus 50 --out json=results.json --out csv=results.csv scripts/k6-perf-test.js
-```
-
-### Environment Variables
-
-```bash
-# Set custom API endpoint
-K6_API_URL=https://your-api-gateway-url.amazonaws.com k6 run scripts/k6-perf-test.js
-
-# Custom test duration and VUs
-K6_DURATION=10m K6_VUS=100 k6 run scripts/k6-perf-test.js
-```
-
-## π Understanding Results
-
-k6 provides comprehensive reporting at the end of each test:
-
-```text
-β http_req_duration..............: avg=77ms p(95)=66ms p(99)=76ms
-β http_req_failed................: 1.00% β 1700 β 18
-β http_reqs......................: 1814 29.4/s
-β cold_starts....................: 0.07% β 1429 β 1
-β cache_hits.....................: 85.5% β 1550 β 264
-β memory_pressure_responses......: 0 count
-β All thresholds passed!
-```
-
-### Key Metrics
-
-- **http_req_duration**: Response times (avg, p95, p99) - aim for p95 < 200ms
-- **http_req_failed**: Error rate percentage - aim for < 5%
-- **http_reqs**: Total requests and requests/second
-- **cold_starts**: Cold start detection rate - aim for < 5%
-- **cache_hits**: Cache effectiveness - higher is better
-- **memory_pressure_responses**: Lambda memory issues - should be 0
-
-### Performance Targets
-
-| Metric | Excellent | Good | Needs Work |
-|--------|-----------|------|------------|
-| P95 Response Time | < 100ms | < 200ms | > 500ms |
-| Error Rate | < 1% | < 5% | > 10% |
-| Cold Start Rate | < 1% | < 5% | > 10% |
-| Cache Hit Rate | > 80% | > 60% | < 40% |
-
-## π― Test Scenarios
-
-The test automatically simulates realistic traffic patterns:
-
-- **40% NuGet Package Badges** (`/badges/packages/nuget/{package}`)
- - Real packages: Newtonsoft.Json, Microsoft.Extensions.Http, etc.
-
-- **30% GitHub Package Badges** (`/badges/packages/github/{org}/{package}`)
- - Real orgs: microsoft, facebook, localstack-dotnet
-
-- **15% Test Result Badges** (`/badges/tests/{platform}/{owner}/{repo}/{branch}`)
- - Multiple platforms: linux, windows
-
-- **10% Health Checks & Redirects** (`/health`, redirects)
- - Administrative endpoints
-
-- **5% Edge Cases** (URL encoding, invalid routes, rapid requests)
- - Error handling and cache testing
-
-## π Live Monitoring
-
-During tests, watch for:
-
-```text
-π Progress: 1340 requests completed | VUs: 30 | Time: 270s
-β οΈ Slow NuGet response: 245ms for Microsoft.Extensions.Http
-π Slow GitHub response: 650ms for microsoft/vscode
-```
-
-## βοΈ AWS Integration
-
-Monitor these AWS Lambda metrics during tests:
-
-1. **Lambda Console**: Functions β badge-smith-function β Monitoring
-2. **CloudWatch Metrics**:
- - Duration, Memory usage, Concurrent executions
- - Throttles, Errors, Dead letter queue
-3. **Cost Tracking**: Monitor billing during high-load tests
-
-## π οΈ Troubleshooting
-
-### High Error Rates
-
-- Check Lambda logs in CloudWatch
-- Verify API Gateway URL is correct
-- Review external API connectivity (GitHub, NuGet)
-
-### Poor Performance
-
-- Increase Lambda memory allocation (current: 512MB)
-- Check for cold starts during load spikes
-- Analyze memory usage patterns
-
-### Failed Thresholds
-
-k6 will show exactly which thresholds failed:
-
-```text
-β http_req_duration..............: avg=200ms p(95)=500ms p(99)=1s
-β cold_starts....................: 10.00%
-β Some thresholds failed!
-```
-
-## π CI/CD Integration
-
-Add performance testing to your pipeline:
-
-```yaml
-# GitHub Actions example
-- name: Performance Test
- run: k6 run --duration 2m --vus 20 scripts/k6-perf-test.js
-```
-
-```yaml
-# Azure DevOps example
-- script: k6 run --duration 5m --vus 50 --out json=perf-results.json scripts/k6-perf-test.js
- displayName: 'Run Performance Tests'
-```
diff --git a/scripts/README-TEST-INGESTION.md b/scripts/README-TEST-INGESTION.md
deleted file mode 100644
index 20c8843..0000000
--- a/scripts/README-TEST-INGESTION.md
+++ /dev/null
@@ -1,187 +0,0 @@
-# BadgeSmith Test Ingestion Scripts
-
-Scripts for testing HMAC authentication and test result ingestion endpoints.
-
-## π Quick Start
-
-### Prerequisites
-
-**PowerShell (Windows/Linux/macOS):**
-
-- PowerShell 7.0+
-
-**Bash (Linux/macOS/WSL):**
-
-- bash 4.0+
-- curl
-- openssl
-- jq (optional, for pretty JSON output)
-- uuidgen
-
-### 1. Start BadgeSmith Locally
-
-```bash
-# Start Aspire with LocalStack
-dotnet run --project src/BadgeSmith.Host
-```
-
-### 2. Set Up Test Secret
-
-Make sure you have a test organization configured in your `tests/seeders/BadgeSmith.DynamoDb.Seeders/organization-pat-mapping.json`:
-
-```json
-{
- "secrets": [
- {
- "org_name": "localstack-dotnet",
- "name": "test-secret-name",
- "secret": "your-test-hmac-secret-here",
- "type": "TestData",
- "description": "Test HMAC secret for ingestion"
- }
- ]
-}
-```
-
-### 3. Test the Ingestion Endpoint
-
-**PowerShell:**
-
-```powershell
-# Using sample payload file
-.\scripts\test-ingestion.ps1 -BaseUrl "http://localhost:9474" `
- -Owner "localstack-dotnet" -Repo "localstack.client" `
- -Platform "linux" -Branch "main" -Secret "your-test-hmac-secret-here" `
- -PayloadFile "scripts\sample-test-payload.json" -ShowDetails
-
-# Using inline payload
-.\scripts\test-ingestion.ps1 -BaseUrl "http://localhost:9474" `
- -Owner "localstack-dotnet" -Repo "localstack.client" `
- -Platform "linux" -Branch "main" -Secret "your-test-hmac-secret-here" `
- -Payload '{"platform":"Linux","passed":190,"failed":0,"skipped":0,"total":190,"url_html":"https://github.com/localstack-dotnet/dotnet-aspire-for-localstack/runs/47628811004","timestamp":"2025-09-05T10:57:00Z","commit":"4d8474bda0b16fbbb69887d0d08c3885843bbdc7","run_id":"16814735762","workflow_run_url":"https://github.com/localstack-dotnet/dotnet-aspire-for-localstack/actions/runs/16814735762"}'
-```
-
-**Bash:**
-
-```bash
-# Using sample payload file
-./scripts/test-ingestion.sh --base-url "http://localhost:9474" \
- --owner "localstack-dotnet" --repo "localstack.client" \
- --platform "linux" --branch "main" --secret "your-test-hmac-secret-here" \
- --payload-file "scripts/sample-test-payload.json" --verbose
-
-# Using inline payload
-./scripts/test-ingestion.sh --base-url "http://localhost:9474" \
- --owner "localstack-dotnet" --repo "localstack.client" \
- --platform "linux" --branch "main" --secret "your-test-hmac-secret-here" \
- --payload '{"platform":"Linux","passed":190,"failed":0,"skipped":0,"total":190,"url_html":"https://github.com/localstack-dotnet/dotnet-aspire-for-localstack/runs/47628811004","timestamp":"2025-09-05T10:57:00Z","commit":"4d8474bda0b16fbbb69887d0d08c3885843bbdc7","run_id":"16814735762","workflow_run_url":"https://github.com/localstack-dotnet/dotnet-aspire-for-localstack/actions/runs/16814735762"}'
-```
-
-## π Expected Responses
-
-### Success (201 Created)
-
-```json
-{
- "test_result_id": "badge-smith-abc123...",
- "repository": "localstack-dotnet/localstack.client/linux/main",
- "timestamp": "2025-09-05T10:57:00.123Z"
-}
-```
-
-### Authentication Errors (400/401)
-
-```json
-{
- "message": "X-Signature header is required",
- "error_details": [
- {
- "error_code": "MISSING_AUTH_HEADERS",
- "property_name": "headers"
- }
- ]
-}
-```
-
-### Validation Errors (400)
-
-```json
-{
- "message": "Test counts cannot be negative",
- "error_details": [
- {
- "error_code": "INVALID_TEST_PAYLOAD",
- "property_name": "payload"
- }
- ]
-}
-```
-
-### Duplicate Results (409 Conflict)
-
-```json
-{
- "message": "Test result with run_id '16814735762' already exists",
- "error_details": [
- {
- "error_code": "DUPLICATE_TEST_RESULT",
- "property_name": "run_id"
- }
- ]
-}
-```
-
-## π HMAC Authentication Details
-
-The scripts automatically handle:
-
-1. **Signature Generation**: HMAC-SHA256 of the exact payload
-2. **Timestamp**: ISO 8601 format with current UTC time
-3. **Nonce**: Unique GUID for replay protection
-4. **Headers**: Proper X-Signature, X-Timestamp, X-Nonce format
-
-### Security Notes
-
-- β οΈ **Secret Management**: Never commit real secrets to version control
-- π **Payload Integrity**: The exact JSON string is hashed - formatting matters
-- β° **Timestamp Window**: Requests are valid for 5 minutes (configurable)
-- π **Nonce Uniqueness**: Each request needs a unique nonce
-
-## π§ͺ Testing Scenarios
-
-### Valid Request
-
-```bash
-./scripts/test-ingestion.sh --base-url "http://localhost:9474" \
- --owner "localstack-dotnet" --repo "localstack.client" \
- --platform "linux" --branch "main" --secret "test-secret" \
- --payload-file "scripts/sample-test-payload.json" --verbose
-```
-
-### Invalid Signature (should fail with 400)
-
-```bash
-./scripts/test-ingestion.sh --base-url "http://localhost:9474" \
- --owner "localstack-dotnet" --repo "localstack.client" \
- --platform "linux" --branch "main" --secret "wrong-secret" \
- --payload-file "scripts/sample-test-payload.json"
-```
-
-### Duplicate Run ID (should fail with 409 after first success)
-
-```bash
-# Run the same request twice - second should fail with 409
-./scripts/test-ingestion.sh --base-url "http://localhost:9474" \
- --owner "localstack-dotnet" --repo "localstack.client" \
- --platform "linux" --branch "main" --secret "test-secret" \
- --payload-file "scripts/sample-test-payload.json"
-```
-
-### Invalid Payload (should fail with 400)
-
-```bash
-./scripts/test-ingestion.sh --base-url "http://localhost:9474" \
- --owner "localstack-dotnet" --repo "localstack.client" \
- --platform "linux" --branch "main" --secret "test-secret" \
- --payload '{"platform":"Linux","passed":-1,"total":0}'
-```
diff --git a/scripts/build-lambda.ps1 b/scripts/build-lambda.ps1
deleted file mode 100644
index 3bfb304..0000000
--- a/scripts/build-lambda.ps1
+++ /dev/null
@@ -1,109 +0,0 @@
-# scripts/build-lambda.ps1
-[CmdletBinding(PositionalBinding = $false)]
-param(
- [Alias('t')][ValidateSet('zip', 'image', 'both')] [string]$Target = 'zip',
- [Alias('r')] [string]$Rid = 'linux-x64', # linux-x64 | linux-arm64
- [Alias('i')] [string]$ImageTag = 'badgesmith-lambda:local',
- [Alias('f')] [string]$Dockerfile = 'src/BadgeSmith.Api/Dockerfile',
- [Alias('c')] [string]$Context = '.',
- [Alias('o')] [string]$OutDir = 'artifacts',
- [switch]$Push,
- [switch]$Clean,
- [Alias('h', 'help')] [switch]$Usage
-)
-$ErrorActionPreference = 'Stop'
-
-function Show-Usage {
- @'
-Build BadgeSmith Lambda (zip and/or container image) via Docker Buildx.
-
-USAGE:
- scripts\build-lambda.ps1 [-t zip|image|both] [-r linux-x64|linux-arm64]
- [-i ] [-f ] [-c ]
- [-o ] [--push] [--clean] [-Verbose] [-h]
-
-OPTIONS:
- -t, --target zip|image|both (default: zip)
- -r, --rid linux-x64|linux-arm64 (default: linux-x64)
- -i, --image-tag Docker image tag (default: badgesmith-lambda:local)
- -f, --dockerfile Path to Dockerfile (default: src/BadgeSmith.Api/Dockerfile)
- -c, --context Build context (default: .)
- -o, --out Output dir for artifacts (default: artifacts)
- --push Push image after build
- --clean Clean output directory before writing
- -Verbose Show docker commands
- -h, --help Show this help
-
-EXAMPLES:
- # Zip only (default RID linux-x64)
- .\scripts\build-lambda.ps1 -t zip --clean
-
- # Zip for ARM64
- .\scripts\build-lambda.ps1 -t zip -r linux-arm64 --clean
-
- # Container image (donβt push)
- .\scripts\build-lambda.ps1 -t image -i yourrepo/badgesmith:latest
-
- # Both zip + image, push image
- .\scripts\build-lambda.ps1 -t both -i .dkr.ecr.eu-central-1.amazonaws.com/badgesmith:latest --push
-'@ | Write-Output
-}
-
-if ($Usage) { Show-Usage; exit 0 }
-
-function Get-Platform([string]$rid) {
- switch ($rid) {
- 'linux-arm64' { 'linux/arm64' }
- default { 'linux/amd64' }
- }
-}
-
-function Invoke-Docker([string[]]$DockerArgs) {
- Write-Verbose ("docker " + ($DockerArgs -join ' '))
- & docker @DockerArgs
- if ($LASTEXITCODE -ne 0) { throw "Docker failed ($LASTEXITCODE)" }
-}
-
-# prep artifacts dir
-if ($Clean -and (Test-Path $OutDir)) { Remove-Item "$OutDir\*" -Recurse -Force }
-if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir | Out-Null }
-
-$platform = Get-Platform $Rid
-
-# ZIP (export-only stage so no symlinks/junk) + platform for cross-arch
-if ($Target -in @('zip', 'both')) {
- $zipArgs = @(
- 'buildx', 'build',
- '-f', $Dockerfile,
- '--target', 'export-zip',
- '--build-arg', "RID=$Rid",
- '--platform', $platform,
- '--output', "type=local,dest=$OutDir",
- $Context
- )
- Invoke-Docker $zipArgs
-}
-
-# IMAGE
-if ($Target -in @('image', 'both')) {
- $imgArgs = @(
- 'buildx', 'build',
- '-f', $Dockerfile,
- '--target', 'lambda-image',
- '--build-arg', "RID=$Rid",
- '--platform', $platform,
- '-t', $ImageTag
- )
- if ($Push) { $imgArgs += '--push' }
- $imgArgs += $Context
- Invoke-Docker $imgArgs
-}
-
-if ($Target -in @('zip', 'both')) {
- $expectedZip = Join-Path $OutDir ("badge-lambda-{0}.zip" -f $Rid)
- if (-not (Test-Path $expectedZip)) {
- throw "ZIP not found: $expectedZip. Re-run with -Verbose to see docker output."
- }
-}
-
-Write-Host "`nDone. Artifacts in '$OutDir'." -ForegroundColor Green
diff --git a/scripts/build-lambda.sh b/scripts/build-lambda.sh
deleted file mode 100755
index c010991..0000000
--- a/scripts/build-lambda.sh
+++ /dev/null
@@ -1,94 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-TARGET="zip" # zip|image|both
-RID="linux-x64" # linux-x64|linux-arm64
-IMAGE_TAG="badgesmith-lambda:local"
-DOCKERFILE="src/BadgeSmith.Api/Dockerfile"
-CONTEXT="."
-OUT_DIR="artifacts"
-PUSH=0
-CLEAN=0
-VERBOSE=0
-
-usage() {
- cat <<'EOF'
-Build BadgeSmith Lambda (zip and/or container image) via Docker Buildx.
-
-USAGE:
- scripts/build-lambda.sh [options]
-
-OPTIONS:
- -t, --target zip|image|both (default: zip)
- -r, --rid linux-x64|linux-arm64 (default: linux-x64)
- -i, --image-tag Docker image tag (default: badgesmith-lambda:local)
- -f, --dockerfile Path to Dockerfile (default: src/BadgeSmith.Api/Dockerfile)
- -c, --context Build context (default: .)
- -o, --out Output dir for artifacts (default: artifacts)
- --push Push image after build
- --clean Clean output directory before writing
- -v, --verbose Verbose docker commands
- -h, --help Show this help
-
-EXAMPLES:
- # Zip only (default RID linux-x64)
- scripts/build-lambda.sh --target zip --clean
-
- # Zip for ARM64
- scripts/build-lambda.sh --target zip --rid linux-arm64 --clean
-
- # Container image (donβt push)
- scripts/build-lambda.sh --target image --image-tag yourrepo/badgesmith:latest
-
- # Both zip + image, push image
- scripts/build-lambda.sh --target both \
- --image-tag .dkr.ecr.eu-central-1.amazonaws.com/badgesmith:latest --push
-EOF
-}
-
-while [[ $# -gt 0 ]]; do
- case "$1" in
- -t|--target) TARGET="${2:-}"; shift 2;;
- -r|--rid) RID="${2:-}"; shift 2;;
- -i|--image-tag) IMAGE_TAG="${2:-}"; shift 2;;
- -f|--dockerfile) DOCKERFILE="${2:-}"; shift 2;;
- -c|--context) CONTEXT="${2:-}"; shift 2;;
- -o|--out) OUT_DIR="${2:-}"; shift 2;;
- --push) PUSH=1; shift;;
- --clean) CLEAN=1; shift;;
- -v|--verbose) VERBOSE=1; shift;;
- -h|--help) usage; exit 0;;
- *) echo "Unknown arg: $1"; usage; exit 2;;
- esac
-done
-
-platform="linux/amd64"; [[ "$RID" == "linux-arm64" ]] && platform="linux/arm64"
-[[ $CLEAN -eq 1 ]] && rm -rf "$OUT_DIR"
-mkdir -p "$OUT_DIR"
-
-run() { [[ $VERBOSE -eq 1 ]] && echo "+ docker $*" >&2; docker "$@"; }
-
-if [[ "$TARGET" == "zip" || "$TARGET" == "both" ]]; then
- run buildx build \
- -f "$DOCKERFILE" \
- --target export-zip \
- --build-arg "RID=$RID" \
- --platform "$platform" \
- --output "type=local,dest=$OUT_DIR" \
- "$CONTEXT"
-fi
-
-if [[ "$TARGET" == "image" || "$TARGET" == "both" ]]; then
- args=( buildx build
- -f "$DOCKERFILE"
- --target lambda-image
- --build-arg "RID=$RID"
- --platform "$platform"
- -t "$IMAGE_TAG"
- "$CONTEXT"
- )
- [[ $PUSH -eq 1 ]] && args+=( --push )
- run "${args[@]}"
-fi
-
-echo "Done. Artifacts in '$OUT_DIR'."
diff --git a/scripts/k6-perf-test.js b/scripts/k6-perf-test.js
index 8d0b1d6..010d489 100644
--- a/scripts/k6-perf-test.js
+++ b/scripts/k6-perf-test.js
@@ -14,35 +14,62 @@ const errorRate = new Rate("errors");
const memoryPressureCounter = new Counter("memory_pressure_responses");
const cacheHitRate = new Rate("cache_hits");
-// Test configuration
-export const options = {
- stages: [
- // Warm-up phase - gentle ramp to establish baseline
- { duration: "30s", target: 5 }, // Warm up the Lambda
-
- // Load testing phases
- { duration: "1m", target: 20 }, // Normal load
- { duration: "2m", target: 50 }, // High load
- { duration: "1m", target: 100 }, // Stress test - trigger memory pressure
- { duration: "30s", target: 200 }, // Spike test - force cold starts
-
- // Cool down
- { duration: "30s", target: 0 },
- ],
+// Environment configuration
+const BASE_URL = __ENV.K6_API_URL || "https://g4yecfi5hl.execute-api.eu-central-1.amazonaws.com";
+const DURATION = __ENV.K6_DURATION || null;
+const VUS = __ENV.K6_VUS ? parseInt(__ENV.K6_VUS, 10) : null;
+
+// Request wrapper
+function invoke(method, path, headers, params) {
+ const k6Params = Object.assign({}, params || {});
+ if (headers && Object.keys(headers).length > 0) {
+ k6Params.headers = headers;
+ }
- thresholds: {
- http_req_duration: ["p(95)<500"], // 95% under 500ms
- http_req_failed: ["rate<0.1"], // Less than 10% errors
- cold_starts: ["rate<0.05"], // Less than 5% cold starts during steady state
- errors: ["rate<0.05"], // Less than 5% application errors
- },
+ return http.request(method, `${BASE_URL}${path}`, null, k6Params);
+}
- // Enhanced summary configuration for comprehensive reporting
- summaryTrendStats: ["avg", "min", "med", "max", "p(90)", "p(95)", "p(99)", "count"],
- summaryTimeUnit: "ms",
+// Test configuration
+var hasOverrides = DURATION || VUS;
+const thresholds = {
+ http_req_duration: ["p(95)<500"], // 95% under 500ms
+ http_req_failed: ["rate<0.1"], // Less than 10% errors
+ errors: ["rate<0.05"], // Less than 5% application errors
};
-const BASE_URL = "https://g4yecfi5hl.execute-api.eu-central-1.amazonaws.com";
+if (!hasOverrides) {
+ thresholds.cold_starts = ["rate<0.05"]; // Less than 5% cold starts during steady state
+}
+
+export const options = Object.assign(
+ {
+ thresholds,
+
+ // Enhanced summary configuration for comprehensive reporting
+ summaryTrendStats: ["avg", "min", "med", "max", "p(90)", "p(95)", "p(99)", "count"],
+ summaryTimeUnit: "ms",
+ },
+ hasOverrides
+ ? {
+ duration: DURATION || "30s",
+ vus: VUS || 1,
+ }
+ : {
+ stages: [
+ // Warm-up phase - gentle ramp to establish baseline
+ { duration: "30s", target: 5 }, // Warm up the Lambda
+
+ // Load testing phases
+ { duration: "1m", target: 20 }, // Normal load
+ { duration: "2m", target: 50 }, // High load
+ { duration: "1m", target: 100 }, // Stress test - trigger memory pressure
+ { duration: "30s", target: 200 }, // Spike test - force cold starts
+
+ // Cool down
+ { duration: "30s", target: 0 },
+ ],
+ }
+);
// Test data pools - realistic package names and scenarios
const testScenarios = {
@@ -86,9 +113,19 @@ function detectMemoryPressure(response) {
return false;
}
+function getHeader(response, name) {
+ const lowerName = name.toLowerCase();
+ for (const key in response.headers) {
+ if (key.toLowerCase() === lowerName) {
+ return response.headers[key];
+ }
+ }
+ return undefined;
+}
+
function checkCacheHeaders(response) {
- const etag = response.headers["etag"];
- const cacheControl = response.headers["cache-control"];
+ const etag = getHeader(response, "etag");
+ const cacheControl = getHeader(response, "cache-control");
const isFromCache = !!(etag && cacheControl);
cacheHitRate.add(isFromCache ? 1 : 0);
return isFromCache;
@@ -139,15 +176,12 @@ export default function () {
function testNugetPackageBadges() {
group("NuGet Package Badges", () => {
const packageName = randomChoice(testScenarios.nugetPackages);
- const url = `${BASE_URL}/badges/packages/nuget/${packageName}`;
-
- const response = http.get(url, {
- headers: {
- Accept: "application/json",
- "User-Agent": "k6-perf-test/1.0",
- },
- tags: { scenario: "nuget_badge", package: packageName },
- });
+ const path = `/badges/packages/nuget/${packageName}`;
+
+ const response = invoke("GET", path, {
+ Accept: "application/json",
+ "User-Agent": "k6-perf-test/1.0",
+ }, { tags: { scenario: "nuget_badge", package: packageName } });
// Performance analysis
const isColdStart = detectColdStart(response);
@@ -159,8 +193,7 @@ function testNugetPackageBadges() {
"status is 200": (r) => r.status === 200,
"response time < 500ms": (r) => r.timings.duration < 500,
"has badge data": (r) => r.json() && r.json().schemaVersion,
- "has cache headers": (r) => r.headers["cache-control"] !== undefined,
- "not a cold start": (r) => !isColdStart || Math.random() < 0.1, // Allow some cold starts
+ "has cache headers": (r) => getHeader(r, "cache-control") !== undefined,
});
// Live reporting for slow responses
@@ -176,15 +209,12 @@ function testNugetPackageBadges() {
function testGithubPackageBadges() {
group("GitHub Package Badges", () => {
const pkg = randomChoice(testScenarios.githubPackages);
- const url = `${BASE_URL}/badges/packages/github/${pkg.org}/${pkg.package}?prerelease=true`;
-
- const response = http.get(url, {
- headers: {
- Accept: "application/json",
- "User-Agent": "k6-perf-test/1.0",
- },
- tags: { scenario: "github_badge", org: pkg.org, package: pkg.package },
- });
+ const path = `/badges/packages/github/${pkg.org}/${pkg.package}?prerelease=true`;
+
+ const response = invoke("GET", path, {
+ Accept: "application/json",
+ "User-Agent": "k6-perf-test/1.0",
+ }, { tags: { scenario: "github_badge", org: pkg.org, package: pkg.package } });
detectColdStart(response);
detectMemoryPressure(response);
@@ -209,9 +239,9 @@ function testGithubPackageBadges() {
function testResultBadges() {
group("Test Result Badges", () => {
const test = randomChoice(testScenarios.testResults);
- const url = `${BASE_URL}/badges/tests/${test.platform}/${test.owner}/${test.repo}/${encodeURIComponent(test.branch)}`;
+ const path = `/badges/tests/${test.platform}/${test.owner}/${test.repo}/${encodeURIComponent(test.branch)}`;
- const response = http.get(url, {
+ const response = invoke("GET", path, null, {
tags: { scenario: "test_badge", platform: test.platform },
});
@@ -231,7 +261,7 @@ function testResultBadges() {
function testHealthAndMisc() {
group("Health and Miscellaneous", () => {
// Health check
- const healthResponse = http.get(`${BASE_URL}/health`, {
+ const healthResponse = invoke("GET", "/health", null, {
tags: { scenario: "health_check" },
});
@@ -243,15 +273,15 @@ function testHealthAndMisc() {
// Test a redirect endpoint
if (Math.random() < 0.5) {
const test = randomChoice(testScenarios.testResults);
- const redirectUrl = `${BASE_URL}/redirect/test-results/${test.platform}/${test.owner}/${test.repo}/${encodeURIComponent(test.branch)}`;
+ const redirectPath = `/redirect/test-results/${test.platform}/${test.owner}/${test.repo}/${encodeURIComponent(test.branch)}`;
- const redirectResponse = http.get(redirectUrl, {
+ const redirectResponse = invoke("GET", redirectPath, null, {
redirects: 0, // Don't follow redirects
tags: { scenario: "redirect_test" },
});
check(redirectResponse, {
- "redirect status is 3xx": (r) => r.status >= 300 && r.status < 400,
+ "redirect status is 3xx or 404": (r) => (r.status >= 300 && r.status < 400) || r.status === 404,
});
}
});
@@ -264,7 +294,7 @@ function testEdgeCases() {
if (edgeCase < 0.3) {
// URL-encoded package names
const packageName = "Microsoft%2EExtensions%2EHttp";
- const response = http.get(`${BASE_URL}/badges/packages/nuget/${packageName}`, {
+ const response = invoke("GET", `/badges/packages/nuget/${packageName}`, null, {
tags: { scenario: "edge_case", type: "url_encoded" },
});
@@ -274,13 +304,13 @@ function testEdgeCases() {
} else if (edgeCase < 0.6) {
// Rapid successive requests to same endpoint (cache testing)
const packageName = randomChoice(testScenarios.nugetPackages);
- const url = `${BASE_URL}/badges/packages/nuget/${packageName}`;
+ const path = `/badges/packages/nuget/${packageName}`;
for (let i = 0; i < 3; i++) {
- const response = http.get(url, {
- headers: i > 0 ? { "If-None-Match": "test-etag" } : {},
- tags: { scenario: "edge_case", type: "cache_burst" },
- });
+ const response = invoke("GET", path,
+ i > 0 ? { "If-None-Match": "test-etag" } : null,
+ { tags: { scenario: "edge_case", type: "cache_burst" } }
+ );
if (i === 0) {
check(response, {
@@ -290,8 +320,7 @@ function testEdgeCases() {
}
} else {
// Invalid routes (should be handled gracefully)
- const invalidUrl = `${BASE_URL}/badges/invalid/route/structure`;
- const response = http.get(invalidUrl, {
+ const response = invoke("GET", "/badges/invalid/route/structure", null, {
tags: { scenario: "edge_case", type: "invalid_route" },
});
@@ -313,7 +342,7 @@ export function setup() {
console.log(" - Custom metrics from your application logs");
// Warm up the Lambda
- const warmupResponse = http.get(`${BASE_URL}/health`);
+ const warmupResponse = invoke("GET", "/health");
console.log(`π₯ Warmup response time: ${warmupResponse.timings.duration}ms`);
return { startTime: new Date() };
diff --git a/scripts/localstack.yml b/scripts/localstack.yml
deleted file mode 100644
index 904f6f0..0000000
--- a/scripts/localstack.yml
+++ /dev/null
@@ -1,24 +0,0 @@
-version: "3.0"
-
-services:
- localstack:
- image: localstack/localstack:4.6.0
- ports:
- - "4566:4566"
- - "${PORT_WEB_UI-8080}:${PORT_WEB_UI-8080}"
-
- environment:
- - DEBUG="1"
- - DOCKER_HOST=unix:///var/run/docker.sock
- - LAMBDA_DOCKER_NETWORK=development
-
- volumes:
- - "/var/run/docker.sock:/var/run/docker.sock"
-
- networks:
- - development
-
-networks:
- development:
- name: development
- driver: bridge
diff --git a/scripts/test-ingestion.ps1 b/scripts/test-ingestion.ps1
deleted file mode 100644
index 3aaed6e..0000000
--- a/scripts/test-ingestion.ps1
+++ /dev/null
@@ -1,162 +0,0 @@
-# scripts/test-ingestion.ps1
-[CmdletBinding()]
-param(
- [Parameter(Mandatory=$true)]
- [string]$BaseUrl,
-
- [Parameter(Mandatory=$true)]
- [string]$Owner,
-
- [Parameter(Mandatory=$true)]
- [string]$Repo,
-
- [Parameter(Mandatory=$true)]
- [string]$Platform,
-
- [Parameter(Mandatory=$true)]
- [string]$Branch,
-
- [Parameter(Mandatory=$true)]
- [string]$Secret,
-
- [Parameter(Mandatory=$false)]
- [string]$PayloadFile = "",
-
- [Parameter(Mandatory=$false)]
- [string]$Payload = "",
-
- [switch]$ShowDetails
-)
-
-$ErrorActionPreference = 'Stop'
-
-function Show-Usage {
- @'
-Test BadgeSmith HMAC authentication and test result ingestion.
-
-USAGE:
- scripts\test-ingestion.ps1 -BaseUrl -Owner -Repo
- -Platform -Branch -Secret
- [-PayloadFile ] [-Payload ] [-ShowDetails]
-
-EXAMPLES:
- # Using payload file
- .\scripts\test-ingestion.ps1 -BaseUrl "http://localhost:9474" `
- -Owner "localstack-dotnet" -Repo "localstack.client" `
- -Platform "linux" -Branch "main" -Secret "your-hmac-secret" `
- -PayloadFile "test-payload.json"
-
- # Using inline payload
- .\scripts\test-ingestion.ps1 -BaseUrl "http://localhost:9474" `
- -Owner "localstack-dotnet" -Repo "localstack.client" `
- -Platform "linux" -Branch "main" -Secret "your-hmac-secret" `
- -Payload '{"platform":"Linux","passed":190,"failed":0,...}'
-
-PAYLOAD FORMAT:
- {
- "platform": "Linux",
- "passed": 190,
- "failed": 0,
- "skipped": 0,
- "total": 190,
- "url_html": "https://github.com/owner/repo/runs/123",
- "timestamp": "2025-09-05T10:57:00Z",
- "commit": "4d8474bda0b16fbbb69887d0d08c3885843bbdc7",
- "run_id": "16814735762",
- "workflow_run_url": "https://github.com/owner/repo/actions/runs/456"
- }
-'@ | Write-Output
-}
-
-# Help is handled by CmdletBinding() automatically
-
-# Validate inputs
-if ([string]::IsNullOrWhiteSpace($PayloadFile) -and [string]::IsNullOrWhiteSpace($Payload)) {
- Write-Error "Either -PayloadFile or -Payload must be provided"
- Show-Usage
- exit 1
-}
-
-if (![string]::IsNullOrWhiteSpace($PayloadFile) -and ![string]::IsNullOrWhiteSpace($Payload)) {
- Write-Error "Cannot specify both -PayloadFile and -Payload"
- Show-Usage
- exit 1
-}
-
-# Load payload
-$payloadJson = if ($PayloadFile) {
- if (!(Test-Path $PayloadFile)) {
- Write-Error "Payload file not found: $PayloadFile"
- exit 1
- }
- Get-Content $PayloadFile -Raw
-} else {
- $Payload
-}
-
-# Normalize parameters
-$Owner = $Owner.ToLowerInvariant()
-$Repo = $Repo.ToLowerInvariant()
-$Platform = $Platform.ToLowerInvariant()
-$Branch = $Branch.ToLowerInvariant()
-
-# Generate authentication headers
-$timestamp = [DateTimeOffset]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
-$nonce = [Guid]::NewGuid().ToString("N")
-
-# Compute HMAC-SHA256 signature
-$hmac = [System.Security.Cryptography.HMACSHA256]::new([System.Text.Encoding]::UTF8.GetBytes($Secret))
-$hash = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($payloadJson))
-$signature = "sha256=" + [BitConverter]::ToString($hash).Replace("-", "").ToLowerInvariant()
-$hmac.Dispose()
-
-# Build request URL
-$url = "$BaseUrl/tests/results/$Platform/$Owner/$Repo/$Branch"
-
-# Prepare headers
-$headers = @{
- 'Content-Type' = 'application/json'
- 'X-Signature' = $signature
- 'X-Timestamp' = $timestamp
- 'X-Nonce' = $nonce
-}
-
-if ($ShowDetails) {
- Write-Host "π Sending test result ingestion request" -ForegroundColor Green
- Write-Host "URL: $url" -ForegroundColor Cyan
- Write-Host "Headers:" -ForegroundColor Cyan
- $headers.GetEnumerator() | ForEach-Object { Write-Host " $($_.Key): $($_.Value)" -ForegroundColor Gray }
- Write-Host "Payload:" -ForegroundColor Cyan
- Write-Host $payloadJson -ForegroundColor Gray
- Write-Host ""
-}
-
-try {
- # Send request
- $response = Invoke-RestMethod -Uri $url -Method POST -Headers $headers -Body $payloadJson -ContentType 'application/json'
-
- Write-Host "β
Request successful!" -ForegroundColor Green
- Write-Host "Response:" -ForegroundColor Cyan
- $response | ConvertTo-Json -Depth 10 | Write-Host -ForegroundColor Gray
-}
-catch {
- Write-Host "β Request failed!" -ForegroundColor Red
- Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red
-
- if ($_.Exception.Response) {
- $statusCode = $_.Exception.Response.StatusCode
- Write-Host "Status Code: $statusCode" -ForegroundColor Red
-
- try {
- $errorBody = $_.Exception.Response.GetResponseStream()
- $reader = [System.IO.StreamReader]::new($errorBody)
- $errorContent = $reader.ReadToEnd()
- Write-Host "Response Body:" -ForegroundColor Red
- Write-Host $errorContent -ForegroundColor Gray
- } catch {
- Write-Host "Could not read error response body" -ForegroundColor Red
- }
- }
-
- exit 1
-}
diff --git a/scripts/test-ingestion.sh b/scripts/test-ingestion.sh
deleted file mode 100644
index 8e6d0a7..0000000
--- a/scripts/test-ingestion.sh
+++ /dev/null
@@ -1,163 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# scripts/test-ingestion.sh
-# Test BadgeSmith HMAC authentication and test result ingestion
-
-BASE_URL=""
-OWNER=""
-REPO=""
-PLATFORM=""
-BRANCH=""
-SECRET=""
-PAYLOAD_FILE=""
-PAYLOAD=""
-VERBOSE=0
-
-usage() {
- cat <<'EOF'
-Test BadgeSmith HMAC authentication and test result ingestion.
-
-USAGE:
- scripts/test-ingestion.sh --base-url --owner --repo
- --platform --branch --secret
- [--payload-file ] [--payload ] [--verbose]
-
-OPTIONS:
- --base-url Base URL of the API (e.g., http://localhost:9474)
- --owner Repository owner/organization
- --repo Repository name
- --platform Platform (linux/windows/macos)
- --branch Branch name
- --secret HMAC secret for authentication
- --payload-file Path to JSON file containing test results
- --payload Inline JSON payload (alternative to --payload-file)
- --verbose Show detailed request information
- -h, --help Show this help
-
-EXAMPLES:
- # Using payload file
- scripts/test-ingestion.sh --base-url "http://localhost:9474" \
- --owner "localstack-dotnet" --repo "localstack.client" \
- --platform "linux" --branch "main" --secret "your-hmac-secret" \
- --payload-file "test-payload.json"
-
- # Using inline payload
- scripts/test-ingestion.sh --base-url "http://localhost:9474" \
- --owner "localstack-dotnet" --repo "localstack.client" \
- --platform "linux" --branch "main" --secret "your-hmac-secret" \
- --payload '{"platform":"Linux","passed":190,"failed":0,"skipped":0,"total":190,...}'
-
-PAYLOAD FORMAT:
- {
- "platform": "Linux",
- "passed": 190,
- "failed": 0,
- "skipped": 0,
- "total": 190,
- "url_html": "https://github.com/owner/repo/runs/123",
- "timestamp": "2025-09-05T10:57:00Z",
- "commit": "4d8474bda0b16fbbb69887d0d08c3885843bbdc7",
- "run_id": "16814735762",
- "workflow_run_url": "https://github.com/owner/repo/actions/runs/456"
- }
-EOF
-}
-
-while [[ $# -gt 0 ]]; do
- case "$1" in
- --base-url) BASE_URL="${2:-}"; shift 2;;
- --owner) OWNER="${2:-}"; shift 2;;
- --repo) REPO="${2:-}"; shift 2;;
- --platform) PLATFORM="${2:-}"; shift 2;;
- --branch) BRANCH="${2:-}"; shift 2;;
- --secret) SECRET="${2:-}"; shift 2;;
- --payload-file) PAYLOAD_FILE="${2:-}"; shift 2;;
- --payload) PAYLOAD="${2:-}"; shift 2;;
- --verbose) VERBOSE=1; shift;;
- -h|--help) usage; exit 0;;
- *) echo "Unknown argument: $1"; usage; exit 2;;
- esac
-done
-
-# Validate required parameters
-if [[ -z "$BASE_URL" || -z "$OWNER" || -z "$REPO" || -z "$PLATFORM" || -z "$BRANCH" || -z "$SECRET" ]]; then
- echo "β Missing required parameters"
- usage
- exit 1
-fi
-
-if [[ -z "$PAYLOAD_FILE" && -z "$PAYLOAD" ]]; then
- echo "β Either --payload-file or --payload must be provided"
- usage
- exit 1
-fi
-
-if [[ -n "$PAYLOAD_FILE" && -n "$PAYLOAD" ]]; then
- echo "β Cannot specify both --payload-file and --payload"
- usage
- exit 1
-fi
-
-# Load payload
-if [[ -n "$PAYLOAD_FILE" ]]; then
- if [[ ! -f "$PAYLOAD_FILE" ]]; then
- echo "β Payload file not found: $PAYLOAD_FILE"
- exit 1
- fi
- PAYLOAD_JSON=$(cat "$PAYLOAD_FILE")
-else
- PAYLOAD_JSON="$PAYLOAD"
-fi
-
-# Normalize parameters (lowercase)
-OWNER=$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')
-REPO=$(echo "$REPO" | tr '[:upper:]' '[:lower:]')
-PLATFORM=$(echo "$PLATFORM" | tr '[:upper:]' '[:lower:]')
-BRANCH=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]')
-
-# Generate authentication headers
-TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ")
-NONCE=$(uuidgen | tr -d '-' | tr '[:upper:]' '[:lower:]')
-
-# Compute HMAC-SHA256 signature
-SIGNATURE="sha256=$(echo -n "$PAYLOAD_JSON" | openssl dgst -sha256 -hmac "$SECRET" -binary | xxd -p -c 256)"
-
-# Build request URL
-URL="$BASE_URL/tests/results/$PLATFORM/$OWNER/$REPO/$BRANCH"
-
-if [[ $VERBOSE -eq 1 ]]; then
- echo "π Sending test result ingestion request"
- echo "URL: $URL"
- echo "Headers:"
- echo " Content-Type: application/json"
- echo " X-Signature: $SIGNATURE"
- echo " X-Timestamp: $TIMESTAMP"
- echo " X-Nonce: $NONCE"
- echo "Payload:"
- echo "$PAYLOAD_JSON"
- echo ""
-fi
-
-# Send request using curl
-HTTP_CODE=$(curl -s -w "%{http_code}" -o response.tmp \
- -X POST "$URL" \
- -H "Content-Type: application/json" \
- -H "X-Signature: $SIGNATURE" \
- -H "X-Timestamp: $TIMESTAMP" \
- -H "X-Nonce: $NONCE" \
- -d "$PAYLOAD_JSON")
-
-RESPONSE_BODY=$(cat response.tmp)
-rm -f response.tmp
-
-if [[ "$HTTP_CODE" -ge 200 && "$HTTP_CODE" -lt 300 ]]; then
- echo "β
Request successful! (HTTP $HTTP_CODE)"
- echo "Response:"
- echo "$RESPONSE_BODY" | jq . 2>/dev/null || echo "$RESPONSE_BODY"
-else
- echo "β Request failed! (HTTP $HTTP_CODE)"
- echo "Response:"
- echo "$RESPONSE_BODY" | jq . 2>/dev/null || echo "$RESPONSE_BODY"
- exit 1
-fi
diff --git a/src/BadgeSmith.Api/BadgeSmith.Api.csproj b/src/BadgeSmith.Api/BadgeSmith.Api.csproj
index 13b68d3..1c11008 100644
--- a/src/BadgeSmith.Api/BadgeSmith.Api.csproj
+++ b/src/BadgeSmith.Api/BadgeSmith.Api.csproj
@@ -69,6 +69,9 @@
Core\Constants.cs
+
+ Protocol\HmacCanonicalRequest.cs
+
diff --git a/src/BadgeSmith.Api/Core/Http/HttpClientFactory.cs b/src/BadgeSmith.Api/Core/Http/HttpClientFactory.cs
index b4fdcbe..4d6c1ba 100644
--- a/src/BadgeSmith.Api/Core/Http/HttpClientFactory.cs
+++ b/src/BadgeSmith.Api/Core/Http/HttpClientFactory.cs
@@ -1,6 +1,5 @@
-#pragma warning disable S1075
-
using System.Net;
+using static BadgeSmith.Constants;
namespace BadgeSmith.Api.Core.Http;
@@ -10,8 +9,79 @@ namespace BadgeSmith.Api.Core.Http;
///
internal static class HttpClientFactory
{
+#pragma warning disable S1075 // These are intentional public service defaults, overridable for local tests.
private const string NugetApiUrl = "https://api.nuget.org/";
private const string GithubApiUrl = "https://api.github.com/";
+#pragma warning restore S1075
+
+ private static Uri ResolveBaseUri(string envVar, string fallback)
+ {
+ var upstreamMode = ResolveUpstreamMode();
+ var value = Environment.GetEnvironmentVariable(envVar);
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ if (upstreamMode == UpstreamModeMock)
+ {
+ throw new InvalidOperationException($"{envVar} is required when {UpstreamModeEnvironmentVariable} is {UpstreamModeMock}.");
+ }
+
+ return new Uri(fallback);
+ }
+
+ if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)
+ || string.IsNullOrWhiteSpace(uri.Host)
+ || !string.IsNullOrEmpty(uri.UserInfo)
+ || !string.IsNullOrEmpty(uri.Query)
+ || !string.IsNullOrEmpty(uri.Fragment)
+ || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
+ {
+ throw new InvalidOperationException($"{envVar} must be an absolute HTTP or HTTPS URL without credentials, query, or fragment.");
+ }
+
+ if (upstreamMode == UpstreamModeLive && uri.Scheme != Uri.UriSchemeHttps)
+ {
+ throw new InvalidOperationException($"{envVar} must use HTTPS when {UpstreamModeEnvironmentVariable} is {UpstreamModeLive}.");
+ }
+
+ // Base addresses must end with '/' for correct relative URI resolution
+ // (e.g. HttpClient appends "v3-flatcontainer/..." relative to the base path).
+ if (!uri.AbsolutePath.EndsWith('/'))
+ {
+ var builder = new UriBuilder(uri);
+ builder.Path += "/";
+ return builder.Uri;
+ }
+
+ return uri;
+ }
+
+ private static string ResolveUpstreamMode()
+ {
+ var value = Environment.GetEnvironmentVariable(UpstreamModeEnvironmentVariable);
+ if (string.IsNullOrWhiteSpace(value) || value.Equals(UpstreamModeLive, StringComparison.OrdinalIgnoreCase))
+ {
+ return UpstreamModeLive;
+ }
+
+ if (value.Equals(UpstreamModeMock, StringComparison.OrdinalIgnoreCase))
+ {
+#if ENABLE_LOCALSTACK
+ if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("HTTP_NUGET_BASE_URL"))
+ || string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("HTTP_GITHUB_BASE_URL")))
+ {
+ throw new InvalidOperationException(
+ $"{UpstreamModeMock} upstream mode requires both HTTP_NUGET_BASE_URL and HTTP_GITHUB_BASE_URL.");
+ }
+
+ return UpstreamModeMock;
+#else
+ throw new InvalidOperationException($"{UpstreamModeMock} upstream mode is unavailable in production builds.");
+#endif
+ }
+
+ throw new InvalidOperationException(
+ $"{UpstreamModeEnvironmentVariable} must be either {UpstreamModeLive} or {UpstreamModeMock}.");
+ }
private static readonly Lazy NugetSocketsHttpHandlerFactory = new(CreateHandlerInstance());
private static readonly Lazy GithubSocketsHttpHandlerFactory = new(CreateHandlerInstance());
@@ -37,7 +107,7 @@ public static HttpClient CreateNuGetClient()
{
var httpClient = new HttpClient(NugetRetryHandlerFactory.Value, disposeHandler: false)
{
- BaseAddress = new Uri(NugetApiUrl),
+ BaseAddress = ResolveBaseUri("HTTP_NUGET_BASE_URL", NugetApiUrl),
Timeout = TimeSpan.FromSeconds(10),
DefaultRequestVersion = HttpVersion.Version11,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher,
@@ -53,7 +123,7 @@ public static HttpClient CreateGithubClient()
{
var httpClient = new HttpClient(GithubRetryHandlerFactory.Value, disposeHandler: false)
{
- BaseAddress = new Uri(GithubApiUrl),
+ BaseAddress = ResolveBaseUri("HTTP_GITHUB_BASE_URL", GithubApiUrl),
Timeout = TimeSpan.FromSeconds(10),
DefaultRequestVersion = HttpVersion.Version11,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher,
diff --git a/src/BadgeSmith.Api/Core/Routing/ApiRouter.cs b/src/BadgeSmith.Api/Core/Routing/ApiRouter.cs
index 2c9cd6c..439a99f 100644
--- a/src/BadgeSmith.Api/Core/Routing/ApiRouter.cs
+++ b/src/BadgeSmith.Api/Core/Routing/ApiRouter.cs
@@ -1,4 +1,6 @@
-ο»Ώusing System.Diagnostics;
+ο»Ώ#pragma warning disable CA1873 // Replace with LoggerMessage source-generated logging.
+
+using System.Diagnostics;
using Amazon.Lambda.APIGatewayEvents;
using BadgeSmith.Api.Core.Routing.Contracts;
using Microsoft.Extensions.Logging;
@@ -63,7 +65,9 @@ public async Task RouteAsync(APIGatewayHttpApi
activity?.SetStatus(ActivityStatusCode.Error);
activity?.AddException(ex);
_logger.LogError(ex, "An error occurred while handling API route");
- return Helpers.ResponseHelper.InternalServerError($"Unhandled error: {ex.Message}");
+ return Helpers.ResponseHelper.InternalServerError("An error occurred processing the request");
}
}
}
+
+#pragma warning restore CA1873
diff --git a/src/BadgeSmith.Api/Core/Routing/Cors/CorsHandler.cs b/src/BadgeSmith.Api/Core/Routing/Cors/CorsHandler.cs
index 915b57d..62a4428 100644
--- a/src/BadgeSmith.Api/Core/Routing/Cors/CorsHandler.cs
+++ b/src/BadgeSmith.Api/Core/Routing/Cors/CorsHandler.cs
@@ -1,3 +1,5 @@
+#pragma warning disable CA1873 // Replace with LoggerMessage source-generated logging.
+
using Amazon.Lambda.APIGatewayEvents;
using BadgeSmith.Api.Core.Routing.Contracts;
using BadgeSmith.Api.Core.Routing.Helpers;
@@ -211,3 +213,5 @@ private static void AppendVary(IDictionary headers, string token
return headers.TryGetValue(headerName, out var value) ? value.Trim() : null;
}
}
+
+#pragma warning restore CA1873
diff --git a/src/BadgeSmith.Api/Core/Security/HmacAuthenticationService.cs b/src/BadgeSmith.Api/Core/Security/HmacAuthenticationService.cs
index 444f629..9371fe4 100644
--- a/src/BadgeSmith.Api/Core/Security/HmacAuthenticationService.cs
+++ b/src/BadgeSmith.Api/Core/Security/HmacAuthenticationService.cs
@@ -1,8 +1,12 @@
+#pragma warning disable CA1873 // Replace with LoggerMessage source-generated logging.
+
+using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using BadgeSmith.Api.Core.Security.Contracts;
+using BadgeSmith.Protocol;
using Microsoft.Extensions.Logging;
namespace BadgeSmith.Api.Core.Security;
@@ -34,22 +38,15 @@ public async Task ValidateRequestAsync(HmacAuthContext
ValidateHmacAuthContext(authContext);
- if (!TryParseTimestamp(authContext.Timestamp, out var requestTimestamp, out var timestampError))
+ var timestamp = authContext.Timestamp.Trim();
+ var nonce = authContext.Nonce.Trim();
+
+ if (!TryParseTimestamp(timestamp, out var requestTimestamp, out var timestampError))
{
return timestampError;
}
- var repoIdentifier = $"{authContext.Owner}/{authContext.Repo}/{authContext.Repo}/{authContext.Branch}";
- var nonceResult = await _nonceService.ValidateAndMarkNonceAsync(authContext.Nonce, repoIdentifier, requestTimestamp, ct).ConfigureAwait(false);
-
- if (!nonceResult.IsSuccess)
- {
- return nonceResult.Failure.Match
- (
- alreadyUsed => alreadyUsed,
- error => error
- );
- }
+ var repoIdentifier = $"{authContext.Owner.ToLowerInvariant()}/{authContext.Repo.ToLowerInvariant()}/{authContext.Platform.ToLowerInvariant()}/{authContext.Branch}";
var secretResult = await _gitHubOrgSecretsService.GetGitHubTokenAsync(authContext.Owner, TokenType, ct).ConfigureAwait(false);
if (secretResult is { IsSuccess: false, GithubSecret: null })
@@ -63,17 +60,31 @@ public async Task ValidateRequestAsync(HmacAuthContext
var secret = secretResult.GithubSecret!;
- if (!ValidateHmacSignature(authContext.Signature, authContext.RequestBody, secret))
+ if (!ValidateHmacSignature(authContext, timestamp, nonce, secret))
{
_logger.LogWarning("Invalid HMAC signature for repository {RepoIdentifier}", repoIdentifier);
return new InvalidSignature("HMAC signature verification failed");
}
+ var nonceResult = await _nonceService.ValidateAndMarkNonceAsync(nonce, repoIdentifier, requestTimestamp, ct).ConfigureAwait(false);
+
+ if (!nonceResult.IsSuccess)
+ {
+ return nonceResult.Failure.Match
+ (
+ alreadyUsed => alreadyUsed,
+ error => error
+ );
+ }
+
_logger.LogInformation("Successfully authenticated request for repository {RepoIdentifier}", repoIdentifier);
return new AuthenticatedRequest(repoIdentifier, requestTimestamp);
}
- [SuppressMessage("Usage", "MA0015:Specify the parameter name in ArgumentException")]
+ [SuppressMessage(
+ "Usage",
+ "MA0015:Specify the parameter name in ArgumentException",
+ Justification = "The validated values are nested request properties rather than method parameters.")]
private static void ValidateHmacAuthContext(HmacAuthContext routeContext)
{
ArgumentNullException.ThrowIfNull(routeContext);
@@ -117,28 +128,47 @@ private static bool TryParseTimestamp(string timestampStr, out DateTimeOffset re
return true;
}
- private static bool ValidateHmacSignature(string providedSignature, string payload, string secret)
+ private static bool ValidateHmacSignature(HmacAuthContext authContext, string timestamp, string nonce, string secret)
{
+ var providedSignature = authContext.Signature;
if (!providedSignature.StartsWith("sha256=", StringComparison.OrdinalIgnoreCase))
{
return false;
}
- var providedHash = providedSignature[7..];
+ var providedHash = providedSignature.AsSpan(7);
+ if (providedHash.Length != 64)
+ {
+ return false;
+ }
- var computedHash = ComputeHmacSha256(payload, secret);
+ Span providedHashBytes = stackalloc byte[32];
+ var status = Convert.FromHexString(providedHash, providedHashBytes, out var charsConsumed, out var bytesWritten);
+ if (status != OperationStatus.Done || charsConsumed != providedHash.Length || bytesWritten != providedHashBytes.Length)
+ {
+ return false;
+ }
- return CryptographicOperations.FixedTimeEquals(Convert.FromHexString(providedHash), Convert.FromHexString(computedHash));
+ var canonicalText = HmacCanonicalRequest.CreateCanonicalText(
+ authContext.Platform,
+ authContext.Owner,
+ authContext.Repo,
+ authContext.Branch,
+ timestamp,
+ nonce,
+ authContext.RequestBody);
+ var computedHashBytes = ComputeHmacSha256(canonicalText, secret);
+ return CryptographicOperations.FixedTimeEquals(providedHashBytes, computedHashBytes);
}
- private static string ComputeHmacSha256(string payload, string secret)
+ private static byte[] ComputeHmacSha256(string payload, string secret)
{
var keyBytes = Encoding.UTF8.GetBytes(secret);
var payloadBytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(keyBytes);
- var hashBytes = hmac.ComputeHash(payloadBytes);
-
- return Convert.ToHexString(hashBytes).ToLowerInvariant();
+ return hmac.ComputeHash(payloadBytes);
}
}
+
+#pragma warning restore CA1873
diff --git a/src/BadgeSmith.Api/Core/Security/NonceService.cs b/src/BadgeSmith.Api/Core/Security/NonceService.cs
index 1c1c52f..4641b8d 100644
--- a/src/BadgeSmith.Api/Core/Security/NonceService.cs
+++ b/src/BadgeSmith.Api/Core/Security/NonceService.cs
@@ -1,3 +1,5 @@
+#pragma warning disable CA1873 // Replace with LoggerMessage source-generated logging.
+
using System.Globalization;
using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.Model;
@@ -85,7 +87,9 @@ public async Task ValidateAndMarkNonceAsync(
catch (Exception ex)
{
_logger.LogError(ex, "Failed to validate nonce {Nonce} for repository {RepoIdentifier}", nonce, repoIdentifier);
- return new Error($"Failed to validate nonce: {ex.Message}");
+ return new Error("Failed to validate nonce");
}
}
}
+
+#pragma warning restore CA1873
diff --git a/src/BadgeSmith.Api/Dockerfile b/src/BadgeSmith.Api/Dockerfile
index 9ed55c5..e20e693 100644
--- a/src/BadgeSmith.Api/Dockerfile
+++ b/src/BadgeSmith.Api/Dockerfile
@@ -11,6 +11,7 @@ ARG PROJECT=src/BadgeSmith.Api/BadgeSmith.Api.csproj
ARG CONFIG=Release
ARG RID=linux-x64
ARG PUBLISH_DIR=/artifacts/publish
+ARG MSTAT=false
# Tooling needed for NativeAOT link
RUN apt-get update && apt-get install -y --no-install-recommends clang zlib1g-dev zip \
@@ -28,11 +29,16 @@ RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet publish ${PROJECT} -c ${CONFIG} -r ${RID} --self-contained true \
-p:PublishAot=true -p:StripSymbols=true -p:DebugType=none -p:EnableTelemetry=false -p:EnableLocalStack=false \
+ -p:IlcGenerateMstatFile=${MSTAT} \
-p:EnableSourceControlManagerQueries=false \
-p:EmbedUntrackedSources=false \
-o ${PUBLISH_DIR} \
&& chmod +x ${PUBLISH_DIR}/bootstrap
+RUN if [ "$MSTAT" = "true" ]; then \
+ find /src -name '*.mstat' -exec cp {} ${PUBLISH_DIR}/bootstrap.mstat \; ; \
+ fi
+
###############################
# Stage 1: Lambda Container Image
###############################
@@ -61,3 +67,9 @@ FROM scratch AS export-zip
ARG RID=linux-x64
ARG ZIP_NAME=badge-lambda-${RID}.zip
COPY --from=lambda-zip /out/${ZIP_NAME} /${ZIP_NAME}
+
+###############################
+# Stage 4: Export mstat (build with --build-arg MSTAT=true)
+###############################
+FROM scratch AS export-mstat
+COPY --from=build /artifacts/publish/bootstrap.mstat /bootstrap.mstat
diff --git a/src/BadgeSmith.Api/Features/GitHub/GitHubPackageService.cs b/src/BadgeSmith.Api/Features/GitHub/GitHubPackageService.cs
index f8b4498..8563c9d 100644
--- a/src/BadgeSmith.Api/Features/GitHub/GitHubPackageService.cs
+++ b/src/BadgeSmith.Api/Features/GitHub/GitHubPackageService.cs
@@ -1,3 +1,5 @@
+#pragma warning disable CA1873 // Replace with LoggerMessage source-generated logging.
+
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
@@ -122,3 +124,5 @@ public async Task GetLatestVersionAsync(
);
}
}
+
+#pragma warning restore CA1873
diff --git a/src/BadgeSmith.Api/Features/GitHub/GithubPackagesBadgeHandler.cs b/src/BadgeSmith.Api/Features/GitHub/GithubPackagesBadgeHandler.cs
index b18ff90..cfbf34a 100644
--- a/src/BadgeSmith.Api/Features/GitHub/GithubPackagesBadgeHandler.cs
+++ b/src/BadgeSmith.Api/Features/GitHub/GithubPackagesBadgeHandler.cs
@@ -1,3 +1,5 @@
+#pragma warning disable CA1873 // Replace with LoggerMessage source-generated logging.
+
using System.Diagnostics;
using Amazon.Lambda.APIGatewayEvents;
using BadgeSmith.Api.Core;
@@ -152,3 +154,5 @@ private bool TryValidateRequest(RouteContext routeContext, out string org, out s
return true;
}
}
+
+#pragma warning restore CA1873
diff --git a/src/BadgeSmith.Api/Features/NuGet/NuGetPackageBadgeHandler.cs b/src/BadgeSmith.Api/Features/NuGet/NuGetPackageBadgeHandler.cs
index 369fb9d..eb5a814 100644
--- a/src/BadgeSmith.Api/Features/NuGet/NuGetPackageBadgeHandler.cs
+++ b/src/BadgeSmith.Api/Features/NuGet/NuGetPackageBadgeHandler.cs
@@ -1,3 +1,5 @@
+#pragma warning disable CA1873 // Replace with LoggerMessage source-generated logging.
+
using System.Diagnostics;
using Amazon.Lambda.APIGatewayEvents;
using BadgeSmith.Api.Core;
@@ -123,3 +125,5 @@ private bool TryValidateRequest(RouteContext routeContext, out string packageId,
return true;
}
}
+
+#pragma warning restore CA1873
diff --git a/src/BadgeSmith.Api/Features/NuGet/NuGetPackageService.cs b/src/BadgeSmith.Api/Features/NuGet/NuGetPackageService.cs
index 55388b4..934028a 100644
--- a/src/BadgeSmith.Api/Features/NuGet/NuGetPackageService.cs
+++ b/src/BadgeSmith.Api/Features/NuGet/NuGetPackageService.cs
@@ -1,3 +1,5 @@
+#pragma warning disable CA1873 // Replace with LoggerMessage source-generated logging.
+
using System.Net;
using System.Text.Json;
using BadgeSmith.Api.Core;
@@ -108,3 +110,5 @@ public async Task GetLatestVersionAsync(
);
}
}
+
+#pragma warning restore CA1873
diff --git a/src/BadgeSmith.Api/Features/TestResults/Handlers/TestResultIngestionHandler.cs b/src/BadgeSmith.Api/Features/TestResults/Handlers/TestResultIngestionHandler.cs
index e35b3c9..202fef5 100644
--- a/src/BadgeSmith.Api/Features/TestResults/Handlers/TestResultIngestionHandler.cs
+++ b/src/BadgeSmith.Api/Features/TestResults/Handlers/TestResultIngestionHandler.cs
@@ -1,4 +1,6 @@
-ο»Ώusing System.Diagnostics;
+ο»Ώ#pragma warning disable CA1873 // Replace with LoggerMessage source-generated logging.
+
+using System.Diagnostics;
using System.Text.Json;
using Amazon.Lambda.APIGatewayEvents;
using BadgeSmith.Api.Core.Routing;
@@ -195,15 +197,12 @@ private static bool TryParseTestPayload(string? requestBody, out TestResultPaylo
payload = JsonSerializer.Deserialize(requestBody, LambdaFunctionJsonSerializerContext.Default.TestResultPayload)!;
return true;
}
- catch (JsonException ex)
- {
- errorResponse = ResponseHelper.BadRequest($"Invalid JSON payload: {ex.Message}");
- return false;
- }
- catch (Exception ex)
+ catch (JsonException)
{
- errorResponse = ResponseHelper.InternalServerError($"Failed to parse payload: {ex.Message}");
+ errorResponse = ResponseHelper.BadRequest("Invalid JSON payload");
return false;
}
}
}
+
+#pragma warning restore CA1873
diff --git a/src/BadgeSmith.Api/Features/TestResults/Handlers/TestResultRedirectionHandler.cs b/src/BadgeSmith.Api/Features/TestResults/Handlers/TestResultRedirectionHandler.cs
index 29390e8..f7c1a75 100644
--- a/src/BadgeSmith.Api/Features/TestResults/Handlers/TestResultRedirectionHandler.cs
+++ b/src/BadgeSmith.Api/Features/TestResults/Handlers/TestResultRedirectionHandler.cs
@@ -1,3 +1,5 @@
+#pragma warning disable CA1873 // Replace with LoggerMessage source-generated logging.
+
using System.Diagnostics;
using Amazon.Lambda.APIGatewayEvents;
using BadgeSmith.Api.Core.Routing;
@@ -103,3 +105,5 @@ private static bool TryExtractRouteParameters(
return true;
}
}
+
+#pragma warning restore CA1873
diff --git a/src/BadgeSmith.Api/Features/TestResults/Handlers/TestResultsBadgeHandler.cs b/src/BadgeSmith.Api/Features/TestResults/Handlers/TestResultsBadgeHandler.cs
index 0201cc2..306ea0b 100644
--- a/src/BadgeSmith.Api/Features/TestResults/Handlers/TestResultsBadgeHandler.cs
+++ b/src/BadgeSmith.Api/Features/TestResults/Handlers/TestResultsBadgeHandler.cs
@@ -1,4 +1,6 @@
-ο»Ώusing System.Diagnostics;
+ο»Ώ#pragma warning disable CA1873 // Replace with LoggerMessage source-generated logging.
+
+using System.Diagnostics;
using Amazon.Lambda.APIGatewayEvents;
using BadgeSmith.Api.Core.Routing;
using BadgeSmith.Api.Core.Routing.Helpers;
@@ -113,3 +115,5 @@ private static bool TryExtractRouteParameters(
return true;
}
}
+
+#pragma warning restore CA1873
diff --git a/src/BadgeSmith.Api/Features/TestResults/TestResultsService.cs b/src/BadgeSmith.Api/Features/TestResults/TestResultsService.cs
index c445172..ed8b4d5 100644
--- a/src/BadgeSmith.Api/Features/TestResults/TestResultsService.cs
+++ b/src/BadgeSmith.Api/Features/TestResults/TestResultsService.cs
@@ -1,3 +1,5 @@
+#pragma warning disable CA1873 // Replace with LoggerMessage source-generated logging.
+
using System.Globalization;
using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.Model;
@@ -70,7 +72,7 @@ public async Task StoreTestResultAsync(StoreTestResultR
catch (Exception ex)
{
_logger.LogError(ex, "Failed to store test result {RunId} for {Owner}/{Repo}", entity.RunId, entity.Owner, entity.Repo);
- return new Error($"Failed to store test result: {ex.Message}");
+ return new Error("Failed to store test result");
}
}
@@ -90,7 +92,7 @@ public async Task GetLatestTestResultAsync(string owner,
_logger.LogDebug("Querying latest test result for {Owner}/{Repo} on {Platform}/{Branch}", ownerNormalized, repoNormalized, platformNormalized, branchNormalized);
- var gsi1Pk = $"LATEST#{owner}#{repo}#{platform}#{branch}";
+ var gsi1Pk = $"LATEST#{ownerNormalized}#{repoNormalized}#{platformNormalized}#{branchNormalized}";
var queryRequest = new QueryRequest
{
@@ -158,21 +160,29 @@ private static bool TryValidateTestPayload(TestResultPayload payload, out Invali
}
// Validate URLs
- if (!Uri.TryCreate(payload.UrlHtml, UriKind.Absolute, out _))
+ if (!IsValidHttpsUrl(payload.UrlHtml))
{
- error = new InvalidTestPayload("Invalid url_html format");
+ error = new InvalidTestPayload("url_html must be an absolute HTTPS URL without credentials");
return false;
}
- if (!Uri.TryCreate(payload.WorkflowRunUrl, UriKind.Absolute, out _))
+ if (!IsValidHttpsUrl(payload.WorkflowRunUrl))
{
- error = new InvalidTestPayload("Invalid workflow_run_url format");
+ error = new InvalidTestPayload("workflow_run_url must be an absolute HTTPS URL without credentials");
return false;
}
return true;
}
+ private static bool IsValidHttpsUrl(string value)
+ {
+ return Uri.TryCreate(value, UriKind.Absolute, out var uri)
+ && uri.Scheme == Uri.UriSchemeHttps
+ && !string.IsNullOrWhiteSpace(uri.Host)
+ && string.IsNullOrEmpty(uri.UserInfo);
+ }
+
private static PutItemRequest MapToDynamoDbItem(TestResultEntity entity, string tableName)
{
return new PutItemRequest
@@ -244,3 +254,5 @@ private static TestResultEntity MapFromDynamoDbItem(Dictionary FunctionCoreAsync(APIGateway
return ResponseHelper.InternalServerError("An error occurred processing the request");
}
}
+
+#pragma warning restore CA1873
#endif
diff --git a/src/BadgeSmith.Host/BadgeSmith.Host.csproj b/src/BadgeSmith.Host/BadgeSmith.Host.csproj
index da97f5e..d175f43 100644
--- a/src/BadgeSmith.Host/BadgeSmith.Host.csproj
+++ b/src/BadgeSmith.Host/BadgeSmith.Host.csproj
@@ -1,28 +1,31 @@
-
+
$(DefaultTargetFramework)
Exe
true
- $(NoWarn);CS8002
+
+ $(NoWarn);CS8002
true
-
-
+
+
+
+
-
-
-
+
+
+
-
+
diff --git a/src/BadgeSmith.Host/BadgeSmithInfrastructureStack.cs b/src/BadgeSmith.Host/BadgeSmithInfrastructureStack.cs
index 0cd46ca..9abaec6 100644
--- a/src/BadgeSmith.Host/BadgeSmithInfrastructureStack.cs
+++ b/src/BadgeSmith.Host/BadgeSmithInfrastructureStack.cs
@@ -1,5 +1,3 @@
-#pragma warning disable CA1711
-
using Amazon.CDK;
using Amazon.CDK.AWS.DynamoDB;
using Amazon.CDK.AWS.IAM;
@@ -14,7 +12,6 @@ namespace BadgeSmith.Host;
/// Uses the same shared SharedInfrastructureConstruct construct as production to ensure parity.
/// This avoids nested stack complexity that can cause issues with AWS Aspire's CDK provisioner.
///
-#pragma warning disable CA1812
internal sealed class BadgeSmithInfrastructureStack : Stack
{
public BadgeSmithInfrastructureStack(Construct scope, string id, IStackProps? props = null) : base(scope, id, props)
diff --git a/src/BadgeSmith.Host/Program.cs b/src/BadgeSmith.Host/Program.cs
index 3bf024a..d2e1d23 100644
--- a/src/BadgeSmith.Host/Program.cs
+++ b/src/BadgeSmith.Host/Program.cs
@@ -1,4 +1,4 @@
-#pragma warning disable CA2252 // Using 'AddAWSLambdaFunction' requires opting into preview features.
+#pragma warning disable ASPIRECSHARPAPPS001 // AddCSharpApp is experimental in Aspire 13.
using Amazon;
using Aspire.Hosting.AWS.Lambda;
@@ -7,6 +7,10 @@
using static BadgeSmith.Constants;
var builder = DistributedApplication.CreateBuilder(args);
+var upstreamMode = ResolveUpstreamMode();
+var httpNuGetBaseUrl = Environment.GetEnvironmentVariable("HTTP_NUGET_BASE_URL");
+var httpGitHubBaseUrl = Environment.GetEnvironmentVariable("HTTP_GITHUB_BASE_URL");
+ValidateUpstreamConfiguration(upstreamMode, httpNuGetBaseUrl, httpGitHubBaseUrl);
var awsConfig = builder.AddAWSSDKConfig().WithRegion(RegionEndpoint.EUCentral1);
@@ -26,24 +30,103 @@
badgeSmithStack.AddOutput(NonceTableOutputTableName, stack => stack.NonceTable.TableName);
badgeSmithStack.AddOutput(OrgSecretsOutputTableName, stack => stack.OrgSecretsTable.TableName);
-var dynamoDbSeeder = builder.AddProject(name: "BadgeSmithDynamoDbSeeders")
- .WithReference(badgeSmithStack)
- .WithEnvironment("AWS_RESOURCE_ORG_SECRETS_TABLE", badgeSmithStack.GetOutput(OrgSecretsOutputTableName))
- .WithEnvironment("WORKER_TIMEOUT_IN_SECONDS", "300")
- .ExcludeFromManifest();
-
var badgeSmithApi = builder
.AddAWSLambdaFunction(name: "BadgeSmithApi", lambdaHandler: "bootstrap")
.WithEnvironment("DOTNET_ENVIRONMENT", builder.Environment.EnvironmentName)
.WithEnvironment("AWS_RESOURCE_TEST_RESULTS_TABLE", badgeSmithStack.GetOutput(TestResultsOutputTableName))
.WithEnvironment("AWS_RESOURCE_NONCE_TABLE", badgeSmithStack.GetOutput(NonceTableOutputTableName))
.WithEnvironment("AWS_RESOURCE_ORG_SECRETS_TABLE", badgeSmithStack.GetOutput(OrgSecretsOutputTableName))
- .WithReference(badgeSmithStack)
- .WaitFor(dynamoDbSeeder);
+ .WithEnvironment(UpstreamModeEnvironmentVariable, upstreamMode)
+ .WithReference(badgeSmithStack);
+
+if (!string.IsNullOrWhiteSpace(httpNuGetBaseUrl))
+{
+ badgeSmithApi.WithEnvironment("HTTP_NUGET_BASE_URL", httpNuGetBaseUrl);
+}
+
+if (!string.IsNullOrWhiteSpace(httpGitHubBaseUrl))
+{
+ badgeSmithApi.WithEnvironment("HTTP_GITHUB_BASE_URL", httpGitHubBaseUrl);
+}
+
+var secretMappingConfigPath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "..", "tools", "organization-pat-mapping.json"));
+if (upstreamMode == UpstreamModeLive)
+{
+ if (!File.Exists(secretMappingConfigPath))
+ {
+ throw new FileNotFoundException(
+ "Live upstream mode requires tools/organization-pat-mapping.json. Copy the tracked .dist template and add local secrets.",
+ secretMappingConfigPath);
+ }
+
+ var dynamoDbSeeder = builder.AddCSharpApp("BadgeSmithDynamoDbSeeders", "../../tools/badgesmith.cs")
+ .WithArgs("secrets", "seed", "--config", secretMappingConfigPath, "--timeout-seconds", "300")
+ .WithReference(awsConfig)
+ .WithReference(badgeSmithStack)
+ .WithEnvironment("AWS_RESOURCE_ORG_SECRETS_TABLE", badgeSmithStack.GetOutput(OrgSecretsOutputTableName))
+ .ExcludeFromManifest();
+
+ badgeSmithApi.WaitFor(dynamoDbSeeder);
+}
builder.AddAWSAPIGatewayEmulator("APIGatewayEmulator", APIGatewayType.HttpV2)
+ .WithEnvironment("DOTNET_SYSTEM_GLOBALIZATION_INVARIANT", "1")
+ .WithEnvironment("LANG", "C")
+ .WithEnvironment("LC_ALL", "C")
.WithReference(badgeSmithApi, Method.Any, "/{proxy+}");
builder.UseLocalStack(localstack);
await builder.Build().RunAsync().ConfigureAwait(false);
+
+static string ResolveUpstreamMode()
+{
+ var value = Environment.GetEnvironmentVariable(UpstreamModeEnvironmentVariable);
+ if (string.IsNullOrWhiteSpace(value) || value.Equals(UpstreamModeLive, StringComparison.OrdinalIgnoreCase))
+ {
+ return UpstreamModeLive;
+ }
+
+ if (value.Equals(UpstreamModeMock, StringComparison.OrdinalIgnoreCase))
+ {
+ return UpstreamModeMock;
+ }
+
+ throw new InvalidOperationException(
+ $"{UpstreamModeEnvironmentVariable} must be either {UpstreamModeLive} or {UpstreamModeMock}.");
+}
+
+static void ValidateUpstreamConfiguration(string upstreamMode, string? nuGetBaseUrl, string? gitHubBaseUrl)
+{
+ if (upstreamMode == UpstreamModeMock
+ && (string.IsNullOrWhiteSpace(nuGetBaseUrl) || string.IsNullOrWhiteSpace(gitHubBaseUrl)))
+ {
+ throw new InvalidOperationException(
+ $"{UpstreamModeMock} upstream mode requires both HTTP_NUGET_BASE_URL and HTTP_GITHUB_BASE_URL.");
+ }
+
+ ValidateUpstreamUrl("HTTP_NUGET_BASE_URL", nuGetBaseUrl, upstreamMode);
+ ValidateUpstreamUrl("HTTP_GITHUB_BASE_URL", gitHubBaseUrl, upstreamMode);
+}
+
+static void ValidateUpstreamUrl(string variableName, string? value, string upstreamMode)
+{
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return;
+ }
+
+ var allowHttp = upstreamMode == UpstreamModeMock;
+ if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)
+ || string.IsNullOrWhiteSpace(uri.Host)
+ || !string.IsNullOrEmpty(uri.UserInfo)
+ || !string.IsNullOrEmpty(uri.Query)
+ || !string.IsNullOrEmpty(uri.Fragment)
+ || (uri.Scheme != Uri.UriSchemeHttps && (!allowHttp || uri.Scheme != Uri.UriSchemeHttp)))
+ {
+ var allowedSchemes = allowHttp ? "HTTP or HTTPS" : "HTTPS";
+ throw new InvalidOperationException(
+ $"{variableName} must be an absolute {allowedSchemes} URL without credentials, query, or fragment in {upstreamMode} mode.");
+ }
+}
+#pragma warning restore ASPIRECSHARPAPPS001
diff --git a/src/shared/Constants.cs b/src/shared/Constants.cs
index eb769e5..4dc8cfc 100644
--- a/src/shared/Constants.cs
+++ b/src/shared/Constants.cs
@@ -44,6 +44,8 @@ internal static class Constants
public const string LambdaOutputFunctionArn = "BadgeSmithLambdaFunctionArn";
+ public const string LambdaOutputFunctionUrl = "BadgeSmithLambdaFunctionUrl";
+
public const string HttpLambdaIntegrationId = "BadgeSmithLambdaIntegration";
public const string ApiGatewayRoleId = "BadgeSmithApi";
@@ -70,9 +72,17 @@ internal static class Constants
public const string ProductionStackId = "BadgeSmithStack";
+ public const string LocalPerformanceStackId = "BadgeSmithPerformanceStack";
+
public const string SharedInfrastructureConstructId = "BadgeSmithSharedInfrastructureConstruct";
public const string ApiLocalStackForNetDomain = "api.localstackfor.net";
+ public const string UpstreamModeEnvironmentVariable = "BADGESMITH_UPSTREAM_MODE";
+
+ public const string UpstreamModeLive = "Live";
+
+ public const string UpstreamModeMock = "Mock";
+
public const int LambdaTimeoutInSeconds = 20;
}
diff --git a/src/shared/Protocol/HmacCanonicalRequest.cs b/src/shared/Protocol/HmacCanonicalRequest.cs
new file mode 100644
index 0000000..8f2be5f
--- /dev/null
+++ b/src/shared/Protocol/HmacCanonicalRequest.cs
@@ -0,0 +1,61 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace BadgeSmith.Protocol;
+
+internal static class HmacCanonicalRequest
+{
+ private const string Scheme = "BADGESMITH-HMAC";
+ private const string Method = "POST";
+
+ public static string CreateCanonicalText(
+ string platform,
+ string owner,
+ string repo,
+ string branch,
+ string timestamp,
+ string nonce,
+ string body)
+ {
+ ArgumentNullException.ThrowIfNull(platform);
+ ArgumentNullException.ThrowIfNull(owner);
+ ArgumentNullException.ThrowIfNull(repo);
+ ArgumentNullException.ThrowIfNull(branch);
+ ArgumentNullException.ThrowIfNull(timestamp);
+ ArgumentNullException.ThrowIfNull(nonce);
+ ArgumentNullException.ThrowIfNull(body);
+
+ return string.Concat(
+ Scheme,
+ '\n',
+ Method,
+ '\n',
+ CreateIngestionPath(platform, owner, repo, branch),
+ '\n',
+ timestamp.Trim(),
+ '\n',
+ nonce.Trim(),
+ '\n',
+ ComputeBodySha256Hex(body));
+ }
+
+ private static string CreateIngestionPath(string platform, string owner, string repo, string branch)
+ {
+ return string.Concat(
+ "/tests/results/",
+ Uri.EscapeDataString(platform.ToLowerInvariant()),
+ '/',
+ Uri.EscapeDataString(owner.ToLowerInvariant()),
+ '/',
+ Uri.EscapeDataString(repo.ToLowerInvariant()),
+ '/',
+ Uri.EscapeDataString(branch));
+ }
+
+ private static string ComputeBodySha256Hex(string body)
+ {
+ var bodyBytes = Encoding.UTF8.GetBytes(body);
+ var hashBytes = SHA256.HashData(bodyBytes);
+ return Convert.ToHexString(hashBytes).ToLowerInvariant();
+ }
+}
diff --git a/tests/BadgeSmith.Api.Performance.Tests/BadgeSmith.Api.Performance.Tests.csproj b/tests/BadgeSmith.Api.Performance.Tests/BadgeSmith.Api.Performance.Tests.csproj
index d95cb52..5ca1c9d 100644
--- a/tests/BadgeSmith.Api.Performance.Tests/BadgeSmith.Api.Performance.Tests.csproj
+++ b/tests/BadgeSmith.Api.Performance.Tests/BadgeSmith.Api.Performance.Tests.csproj
@@ -3,6 +3,7 @@
$(DefaultTargetFramework)
Exe
+
$(NoWarn);CA1707;CA1303
true
diff --git a/tests/BadgeSmith.Api.Performance.Tests/BufferAllocationBenchmarks.cs b/tests/BadgeSmith.Api.Performance.Tests/BufferAllocationBenchmarks.cs
index 53899be..d64a65c 100644
--- a/tests/BadgeSmith.Api.Performance.Tests/BufferAllocationBenchmarks.cs
+++ b/tests/BadgeSmith.Api.Performance.Tests/BufferAllocationBenchmarks.cs
@@ -1,4 +1,4 @@
-#pragma warning disable CA1812,CA1852,CA1515
+#pragma warning disable CA1812, CA1852, CA1515 // BenchmarkDotNet requires public, non-sealed types instantiated by generated code.
using System.Buffers;
using BadgeSmith.Api.Core.Routing;
@@ -20,7 +20,7 @@ public class BufferAllocationBenchmarks
[Benchmark]
[BenchmarkCategory("Quick")]
- public void RouteValues_Set_2Parameters_FixedArray()
+ public void RouteValues_Set_Should_Measure_Fixed_Array_When_2_Parameters()
{
var buffer = new (string, int, int)[8]; // Current strategy
var values = new RouteValues(TestPath.AsSpan(), buffer.AsSpan());
@@ -31,7 +31,7 @@ public void RouteValues_Set_2Parameters_FixedArray()
[Benchmark]
[BenchmarkCategory("Quick")]
- public void RouteValues_Set_4Parameters_FixedArray()
+ public void RouteValues_Set_Should_Measure_Fixed_Array_When_4_Parameters()
{
var buffer = new (string, int, int)[8]; // Current strategy
var values = new RouteValues(TestPath.AsSpan(), buffer.AsSpan());
@@ -43,7 +43,7 @@ public void RouteValues_Set_4Parameters_FixedArray()
}
[Benchmark]
- public void RouteValues_Set_8Parameters_FixedArray()
+ public void RouteValues_Set_Should_Measure_Fixed_Array_When_8_Parameters()
{
var buffer = new (string, int, int)[8]; // Current strategy - will fill exactly
var values = new RouteValues(TestPath.AsSpan(), buffer.AsSpan());
@@ -55,7 +55,7 @@ public void RouteValues_Set_8Parameters_FixedArray()
}
[Benchmark]
- public void RouteValues_Set_2Parameters_ArrayPool()
+ public void RouteValues_Set_Should_Measure_ArrayPool_When_2_Parameters()
{
var buffer = ArrayPool<(string, int, int)>.Shared.Rent(8);
try
@@ -72,7 +72,7 @@ public void RouteValues_Set_2Parameters_ArrayPool()
}
[Benchmark]
- public void RouteValues_ParameterExtraction_String()
+ public void RouteValues_Should_Measure_Parameter_Extraction_When_Using_String()
{
var buffer = new (string, int, int)[8];
var values = new RouteValues(TestPath.AsSpan(), buffer.AsSpan());
@@ -87,7 +87,7 @@ public void RouteValues_ParameterExtraction_String()
[Benchmark]
[BenchmarkCategory("Quick")]
- public void RouteValues_ParameterExtraction_Span()
+ public void RouteValues_Should_Measure_Parameter_Extraction_When_Using_Span()
{
var buffer = new (string, int, int)[8];
var values = new RouteValues(TestPath.AsSpan(), buffer.AsSpan());
@@ -101,7 +101,7 @@ public void RouteValues_ParameterExtraction_Span()
}
[Benchmark]
- public void RouteResolver_TryResolve_Current()
+ public void RouteResolver_TryResolve_Should_Measure_Current_Implementation()
{
// Simulate the current RouteResolver.TryResolve an allocation pattern
var routes = new[]
@@ -116,7 +116,7 @@ public void RouteResolver_TryResolve_Current()
}
[Benchmark]
- public void RouteResolver_GetAllowedMethods_Current()
+ public void RouteResolver_GetAllowedMethods_Should_Measure_Current_Implementation()
{
// Simulate the current RouteResolver.GetAllowedMethods allocation pattern
var routes = new[]
@@ -131,7 +131,7 @@ public void RouteResolver_GetAllowedMethods_Current()
}
[Benchmark]
- public void RouteValues_Dictionary_Conversion()
+ public void RouteValues_Should_Measure_Dictionary_Conversion()
{
var buffer = new (string, int, int)[8];
var values = new RouteValues(TestPath.AsSpan(), buffer.AsSpan());
@@ -146,7 +146,7 @@ public void RouteValues_Dictionary_Conversion()
[Benchmark]
[BenchmarkCategory("Quick")]
- public void RouteResolver_TryResolve_Optimized()
+ public void RouteResolver_TryResolve_Should_Measure_Optimized_Implementation()
{
// Test the optimized version with buffer sharing - same logic but tests our fix
var routes = new[]
@@ -161,7 +161,7 @@ public void RouteResolver_TryResolve_Optimized()
}
[Benchmark]
- public void RouteResolver_MultiRoute_GetAllowedMethods()
+ public void RouteResolver_GetAllowedMethods_Should_Measure_Multi_Route_Case()
{
// Test with multiple routes to see real-world buffer reuse impact
var routes = new[]
@@ -179,7 +179,7 @@ public void RouteResolver_MultiRoute_GetAllowedMethods()
}
[Benchmark]
- public void RouteResolver_MultiRoute_TryResolve()
+ public void RouteResolver_TryResolve_Should_Measure_Multi_Route_Case()
{
// Test TryResolve with multiple routes
var routes = new[]
@@ -197,7 +197,7 @@ public void RouteResolver_MultiRoute_TryResolve()
[Benchmark]
[BenchmarkCategory("Quick")]
- public void BufferAllocation_Isolated_Current()
+ public void BufferAllocation_Should_Measure_Isolated_Current_Implementation()
{
// Test JUST the buffer allocation (current approach - per route)
for (var i = 0; i < 3; i++) // Simulate 3 route checks
@@ -210,7 +210,7 @@ public void BufferAllocation_Isolated_Current()
[Benchmark]
[BenchmarkCategory("Quick")]
- public void BufferAllocation_Isolated_Optimized()
+ public void BufferAllocation_Should_Measure_Isolated_Optimized_Implementation()
{
// Test JUST the buffer allocation (optimized approach - shared)
var paramBuffer = new (string, int, int)[8]; // SHARED buffer
@@ -221,3 +221,5 @@ public void BufferAllocation_Isolated_Optimized()
}
}
}
+
+#pragma warning restore CA1812, CA1852, CA1515
diff --git a/tests/BadgeSmith.Api.Performance.Tests/RoutingBenchmarks.cs b/tests/BadgeSmith.Api.Performance.Tests/RoutingBenchmarks.cs
index a0f92c2..f507763 100644
--- a/tests/BadgeSmith.Api.Performance.Tests/RoutingBenchmarks.cs
+++ b/tests/BadgeSmith.Api.Performance.Tests/RoutingBenchmarks.cs
@@ -1,4 +1,4 @@
-ο»Ώ#pragma warning disable CA1812,CA1852,CA1515
+ο»Ώ#pragma warning disable CA1812, CA1852, CA1515 // BenchmarkDotNet requires public, non-sealed types instantiated by generated code.
using BadgeSmith.Api.Core.Routing;
using BadgeSmith.Api.Core.Routing.Patterns;
@@ -46,7 +46,7 @@ public void Setup()
[Arguments("/badges/packages/github/localstack-dotnet/localstack.client")]
[Arguments("/badges/tests/linux/localstack-dotnet/dotnet-aspire-for-localstack/main")]
[Arguments("/redirect/test-results/linux/localstack-dotnet/dotnet-aspire-for-localstack/main")]
- public bool RouteResolver_TryResolve(string path)
+ public bool RouteResolver_TryResolve_Should_Measure_Route_Resolution(string path)
{
return _resolver.TryResolve("GET", path, out _);
}
@@ -56,7 +56,7 @@ public bool RouteResolver_TryResolve(string path)
[Arguments("/badges/packages/nuget/Microsoft.Extensions.Http")]
[Arguments("/badges/packages/nuget/AutoMapper")]
[Arguments("/badges/packages/nuget/FluentValidation")]
- public bool TemplatePattern_NuGetPackage_TryMatch(string path)
+ public bool TemplatePattern_TryMatch_Should_Measure_NuGet_Package_Route(string path)
{
var values = RouteTestBuilder.CreateRouteValues(path);
return _nugetPattern.TryMatch(path.AsSpan(), ref values);
@@ -67,7 +67,7 @@ public bool TemplatePattern_NuGetPackage_TryMatch(string path)
[Arguments("/badges/packages/github/microsoft/vscode")]
[Arguments("/badges/packages/github/facebook/react")]
[Arguments("/badges/packages/github/AutoMapper/AutoMapper")]
- public bool TemplatePattern_GitHubPackage_TryMatch(string path)
+ public bool TemplatePattern_TryMatch_Should_Measure_GitHub_Package_Route(string path)
{
var values = RouteTestBuilder.CreateRouteValues(path);
return _githubPattern.TryMatch(path.AsSpan(), ref values);
@@ -78,7 +78,7 @@ public bool TemplatePattern_GitHubPackage_TryMatch(string path)
[Arguments("/badges/tests/windows/microsoft/vscode/main")]
[Arguments("/badges/tests/macos/facebook/react/main")]
[Arguments("/badges/tests/linux/localstack-dotnet/localstack.client/feature%2Fawesome-badge")]
- public bool TemplatePattern_TestBadge_TryMatch(string path)
+ public bool TemplatePattern_TryMatch_Should_Measure_Test_Badge_Route(string path)
{
var values = RouteTestBuilder.CreateRouteValues(path);
return _testPattern.TryMatch(path.AsSpan(), ref values);
@@ -87,14 +87,14 @@ public bool TemplatePattern_TestBadge_TryMatch(string path)
[Benchmark]
[Arguments("/health")]
[Arguments("/tests/results")]
- public bool ExactPattern_TryMatch(string path)
+ public bool ExactPattern_TryMatch_Should_Measure_Exact_Route(string path)
{
var values = RouteTestBuilder.CreateRouteValues(path);
return _healthPattern.TryMatch(path.AsSpan(), ref values);
}
[Benchmark]
- public void RouteValues_ParameterExtraction()
+ public void RouteValues_Should_Measure_Parameter_Extraction()
{
const string path = "/badges/packages/github/localstack-dotnet/localstack.client";
var values = RouteTestBuilder.CreateRouteValues(path);
@@ -111,7 +111,7 @@ public void RouteValues_ParameterExtraction()
}
[Benchmark]
- public void RouteValues_SpanExtraction()
+ public void RouteValues_Should_Measure_Span_Extraction()
{
const string path = "/badges/packages/github/localstack-dotnet/localstack.client";
var values = RouteTestBuilder.CreateRouteValues(path);
@@ -132,13 +132,13 @@ public void RouteValues_SpanExtraction()
[Arguments("/badges/packages/nuget/Newtonsoft.Json")]
[Arguments("/badges/tests/linux/owner/repo/main")]
[Arguments("/nonexistent/path")]
- public IReadOnlyCollection RouteResolver_GetAllowedMethods(string path)
+ public IReadOnlyCollection RouteResolver_GetAllowedMethods_Should_Measure_Allowed_Method_Discovery(string path)
{
return [.. _resolver.GetAllowedMethods(path)];
}
[Benchmark]
- public void TemplatePattern_ComplexParameterExtraction()
+ public void TemplatePattern_Should_Measure_Complex_Parameter_Extraction()
{
const string path = "/badges/tests/linux/localstack-dotnet/dotnet-aspire-for-localstack/feature%2Fawesome-badge";
var values = RouteTestBuilder.CreateRouteValues(path);
@@ -156,7 +156,7 @@ public void TemplatePattern_ComplexParameterExtraction()
}
[Benchmark]
- public void RouteResolver_FullPipeline_Success()
+ public void RouteResolver_Should_Measure_Full_Pipeline_When_Route_Matches()
{
const string method = "GET";
const string path = "/badges/packages/nuget/Newtonsoft.Json";
@@ -179,7 +179,7 @@ public void RouteResolver_FullPipeline_Success()
}
[Benchmark]
- public void RouteResolver_FullPipeline_NotFound()
+ public void RouteResolver_Should_Measure_Full_Pipeline_When_Route_Is_Not_Found()
{
const string method = "GET";
const string path = "/nonexistent/path/that/wont/match";
@@ -194,3 +194,5 @@ public void RouteResolver_FullPipeline_NotFound()
}
}
}
+
+#pragma warning restore CA1812, CA1852, CA1515
diff --git a/tests/BadgeSmith.Api.Tests/BadgeSmith.Api.Tests.csproj b/tests/BadgeSmith.Api.Tests/BadgeSmith.Api.Tests.csproj
index 62b958e..a990c0e 100644
--- a/tests/BadgeSmith.Api.Tests/BadgeSmith.Api.Tests.csproj
+++ b/tests/BadgeSmith.Api.Tests/BadgeSmith.Api.Tests.csproj
@@ -3,19 +3,43 @@
$(DefaultTargetFramework)
true
Exe
- $(NoWarn);CA1515;CA1034;CA1707;MA0110;MA0009;CA1062;CA1024
+ true
+
+ $(NoWarn);CA1515;CA1034;CA1707;MA0110;MA0009;CA1062;CA1024;CA1812
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/BadgeSmith.Api.Tests/Features/TestResults/TestResultIngestionHandlerTests.cs b/tests/BadgeSmith.Api.Tests/Features/TestResults/TestResultIngestionHandlerTests.cs
new file mode 100644
index 0000000..f605a36
--- /dev/null
+++ b/tests/BadgeSmith.Api.Tests/Features/TestResults/TestResultIngestionHandlerTests.cs
@@ -0,0 +1,44 @@
+using Amazon.Lambda.APIGatewayEvents;
+using BadgeSmith.Api.Core.Routing;
+using BadgeSmith.Api.Core.Security.Contracts;
+using BadgeSmith.Api.Features.TestResults.Contracts;
+using BadgeSmith.Api.Features.TestResults.Handlers;
+using BadgeSmith.Api.Tests.Testing;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace BadgeSmith.Api.Tests.Features.TestResults;
+
+[Trait("Category", TestCategories.Unit)]
+public sealed class TestResultIngestionHandlerTests
+{
+ [Fact]
+ public async Task HandleAsync_Should_Return_Safe_BadRequest_Body_When_Body_Is_Invalid_Json()
+ {
+ var sut = new TestResultIngestionHandler(
+ Mock.Of>(),
+ Mock.Of(),
+ Mock.Of());
+
+ var request = new APIGatewayHttpApiV2ProxyRequest
+ {
+ Body = "{invalid json payload",
+ };
+
+ var routeContext = new RouteContext(
+ request,
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["owner"] = "owner",
+ ["repo"] = "repo",
+ ["platform"] = "linux",
+ ["branch"] = "main",
+ });
+
+ var response = await sut.HandleAsync(routeContext, TestContext.Current.CancellationToken);
+
+ Assert.Equal(400, response.StatusCode);
+ Assert.Equal("Invalid JSON payload", response.Body);
+ }
+}
diff --git a/tests/BadgeSmith.Api.Tests/Features/TestResults/TestResultsServiceTests.cs b/tests/BadgeSmith.Api.Tests/Features/TestResults/TestResultsServiceTests.cs
new file mode 100644
index 0000000..c758a8f
--- /dev/null
+++ b/tests/BadgeSmith.Api.Tests/Features/TestResults/TestResultsServiceTests.cs
@@ -0,0 +1,152 @@
+using Amazon.DynamoDBv2;
+using Amazon.DynamoDBv2.Model;
+using BadgeSmith.Api.Features.TestResults;
+using BadgeSmith.Api.Features.TestResults.Models;
+using BadgeSmith.Api.Tests.Testing;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace BadgeSmith.Api.Tests.Features.TestResults;
+
+[Trait("Category", TestCategories.Unit)]
+public sealed class TestResultsServiceTests
+{
+ [Theory]
+ [InlineData("http://example.com/tests")]
+ [InlineData("javascript:alert(1)")]
+ [InlineData("https://user:password@example.com/tests")]
+ public async Task StoreTestResultAsync_Should_Reject_Payload_When_Result_Url_Is_Insecure(string urlHtml)
+ {
+ var dynamo = new Mock(MockBehavior.Strict);
+ var sut = new TestResultsService(
+ dynamo.Object,
+ tableName: "badge-smith-test-result",
+ Mock.Of>());
+ var payload = CreatePayload(urlHtml);
+
+ var result = await sut.StoreTestResultAsync(
+ new StoreTestResultRequest("owner", "repo", "linux", "main", payload),
+ TestContext.Current.CancellationToken);
+
+ Assert.False(result.IsSuccess);
+ Assert.True(result.Failure.IsT0);
+ dynamo.VerifyNoOtherCalls();
+ }
+
+ [Theory]
+ [InlineData("http://example.com/run")]
+ [InlineData("javascript:alert(1)")]
+ [InlineData("https://user:password@example.com/run")]
+ public async Task StoreTestResultAsync_Should_Reject_Payload_When_Workflow_Run_Url_Is_Insecure(string workflowRunUrl)
+ {
+ var dynamo = new Mock(MockBehavior.Strict);
+ var sut = new TestResultsService(
+ dynamo.Object,
+ tableName: "badge-smith-test-result",
+ Mock.Of>());
+ var payload = CreatePayload("https://example.com/tests", workflowRunUrl);
+
+ var result = await sut.StoreTestResultAsync(
+ new StoreTestResultRequest("owner", "repo", "linux", "main", payload),
+ TestContext.Current.CancellationToken);
+
+ Assert.False(result.IsSuccess);
+ Assert.True(result.Failure.IsT0);
+ dynamo.VerifyNoOtherCalls();
+ }
+
+ [Fact]
+ public async Task StoreTestResultAsync_Should_Accept_Payload_When_Https_Result_Origin_Differs_From_Workflow_Run()
+ {
+ var dynamo = new Mock(MockBehavior.Strict);
+ dynamo
+ .Setup(client => client.PutItemAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new PutItemResponse());
+ var sut = new TestResultsService(
+ dynamo.Object,
+ tableName: "badge-smith-test-result",
+ Mock.Of>());
+ var payload = CreatePayload(
+ "https://reports.example.com/tests",
+ "https://github.example.com/owner/repo/actions/runs/42");
+
+ var result = await sut.StoreTestResultAsync(
+ new StoreTestResultRequest("owner", "repo", "linux", "main", payload),
+ TestContext.Current.CancellationToken);
+
+ Assert.True(result.IsSuccess);
+ dynamo.Verify(
+ client => client.PutItemAsync(It.IsAny(), It.IsAny()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task GetLatestTestResultAsync_Should_Query_Lowercase_GSI1PK_When_Route_Values_Have_Mixed_Case()
+ {
+ QueryRequest? captured = null;
+ var dynamo = new Mock(MockBehavior.Strict);
+ dynamo
+ .Setup(d => d.QueryAsync(It.IsAny(), It.IsAny()))
+ .Callback((req, _) => captured = req)
+ .ReturnsAsync(new QueryResponse
+ {
+ Items = []
+ });
+
+ var sut = new TestResultsService(
+ dynamo.Object,
+ tableName: "badge-smith-test-result",
+ Mock.Of>());
+
+ _ = await sut.GetLatestTestResultAsync("LocalStack-DotNet", "Badge-Smith", "Linux", "Master", TestContext.Current.CancellationToken);
+
+ Assert.NotNull(captured);
+ Assert.Equal("LATEST#localstack-dotnet#badge-smith#linux#master", captured!.ExpressionAttributeValues[":gsi1pk"].S);
+ }
+
+ [Fact]
+ public async Task StoreTestResultAsync_Should_Return_Error_Without_Exception_Message_When_DynamoDb_Throws()
+ {
+ const string secretLeak = "INTERNAL-DYNAMODB-THROTTLE-DETAILS";
+
+ var dynamo = new Mock(MockBehavior.Strict);
+ dynamo
+ .Setup(d => d.PutItemAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException(secretLeak));
+
+ var sut = new TestResultsService(
+ dynamo.Object,
+ tableName: "badge-smith-test-result",
+ Mock.Of>());
+
+ var payload = CreatePayload("https://example.com/html");
+
+ var request = new StoreTestResultRequest("owner", "repo", "linux", "main", payload);
+
+ var result = await sut.StoreTestResultAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.False(result.IsSuccess);
+ Assert.True(result.Failure.IsT2); // Error
+ var error = result.Failure.AsT2;
+ Assert.Equal("Failed to store test result", error.Reason);
+ Assert.DoesNotContain(secretLeak, error.Reason, StringComparison.Ordinal);
+ }
+
+ private static TestResultPayload CreatePayload(
+ string urlHtml,
+ string workflowRunUrl = "https://example.com/run")
+ {
+ return new TestResultPayload(
+ Platform: "linux",
+ Passed: 1,
+ Failed: 0,
+ Skipped: 0,
+ Total: 1,
+ UrlHtml: urlHtml,
+ Timestamp: DateTimeOffset.UtcNow,
+ Commit: "abc123",
+ RunId: "run-1",
+ WorkflowRunUrl: workflowRunUrl);
+ }
+}
diff --git a/tests/BadgeSmith.Api.Tests/Functional/HealthContractTests.cs b/tests/BadgeSmith.Api.Tests/Functional/HealthContractTests.cs
new file mode 100644
index 0000000..ad2b187
--- /dev/null
+++ b/tests/BadgeSmith.Api.Tests/Functional/HealthContractTests.cs
@@ -0,0 +1,22 @@
+using BadgeSmith.Api.Tests.Testing;
+using BadgeSmith.Api.Tests.Testing.Infrastructure;
+using Xunit;
+
+namespace BadgeSmith.Api.Tests.Functional;
+
+[Collection("aspire-contract")]
+[Trait("Category", TestCategories.Integration)]
+[Trait("Category", TestCategories.Functional)]
+public sealed class HealthContractTests(AspireContractFixture stack)
+{
+ [Fact]
+ public async Task Health_Should_Return_200_With_No_Cache_Headers()
+ {
+ var response = await stack.Api.InvokeAsync("GET", "/health", ct: TestContext.Current.CancellationToken);
+
+ Assert.Equal(200, response.StatusCode);
+ Assert.Contains("Healthy", response.Body ?? string.Empty, StringComparison.Ordinal);
+ Assert.NotNull(response.Headers);
+ Assert.Contains("no-store", response.Headers["Cache-Control"], StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/tests/BadgeSmith.Api.Tests/Functional/PackageBadgeContractTests.cs b/tests/BadgeSmith.Api.Tests/Functional/PackageBadgeContractTests.cs
new file mode 100644
index 0000000..5a672e6
--- /dev/null
+++ b/tests/BadgeSmith.Api.Tests/Functional/PackageBadgeContractTests.cs
@@ -0,0 +1,123 @@
+using BadgeSmith.Api.Tests.Testing;
+using BadgeSmith.Api.Tests.Testing.Infrastructure;
+using Xunit;
+
+namespace BadgeSmith.Api.Tests.Functional;
+
+[Collection("aspire-contract")]
+[Trait("Category", TestCategories.Integration)]
+[Trait("Category", TestCategories.Functional)]
+public sealed class PackageBadgeContractTests(AspireContractFixture stack)
+{
+ [Fact]
+ public async Task NuGet_Badge_Should_Return_Highest_Stable()
+ {
+ var r = await stack.Api.InvokeAsync("GET", "/badges/packages/nuget/contracttest.pkg", ct: TestContext.Current.CancellationToken);
+ Assert.Equal(200, r.StatusCode);
+ Assert.Contains("\"message\":\"13.0.3\"", r.Body, StringComparison.Ordinal);
+ Assert.Contains("\"color\":\"blue\"", r.Body, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task NuGet_Badge_Should_Return_Prerelease_Version_When_Prerelease_Is_Requested()
+ {
+ var r = await stack.Api.InvokeAsync("GET", "/badges/packages/nuget/contracttest.pkg?prerelease=true", ct: TestContext.Current.CancellationToken);
+ Assert.Equal(200, r.StatusCode);
+ Assert.Contains("13.0.4-beta1", r.Body, StringComparison.Ordinal);
+ Assert.Contains("\"color\":\"orange\"", r.Body, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task NuGet_Badge_Should_Return_404_When_Package_Is_Unknown()
+ {
+ var r = await stack.Api.InvokeAsync("GET", "/badges/packages/nuget/missing.pkg", ct: TestContext.Current.CancellationToken);
+ Assert.Equal(404, r.StatusCode);
+ }
+
+ [Fact]
+ public async Task NuGet_Badge_Should_Return_400_When_Version_Range_Is_Invalid()
+ {
+ var r = await stack.Api.InvokeAsync("GET", "/badges/packages/nuget/contracttest.pkg?version=not-a-range", ct: TestContext.Current.CancellationToken);
+ Assert.Equal(400, r.StatusCode);
+ }
+
+ [Fact]
+ public async Task NuGet_Badge_Should_Honor_IfNoneMatch()
+ {
+ var first = await stack.Api.InvokeAsync("GET", "/badges/packages/nuget/contracttest.pkg", ct: TestContext.Current.CancellationToken);
+ Assert.Equal(200, first.StatusCode);
+ var second = await stack.Api.InvokeAsync("GET", "/badges/packages/nuget/contracttest.pkg",
+ new Dictionary(StringComparer.Ordinal) { ["if-none-match"] = first.Headers!["ETag"] },
+ ct: TestContext.Current.CancellationToken);
+ Assert.Equal(304, second.StatusCode);
+ }
+
+ [Fact]
+ public async Task NuGet_Badge_Should_Return_Matching_Version_When_Version_Range_Is_Valid()
+ {
+ var r = await stack.Api.InvokeAsync("GET", "/badges/packages/nuget/contracttest.pkg?version=%5B4.0.0%2C5.0.0%29", ct: TestContext.Current.CancellationToken);
+ Assert.Equal(200, r.StatusCode);
+ Assert.Contains("\"message\":\"4.0.2\"", r.Body, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task GitHub_Badge_Should_Return_Highest_Stable()
+ {
+ var r = await stack.Api.InvokeAsync("GET", "/badges/packages/github/test-org/contracttest.pkg", ct: TestContext.Current.CancellationToken);
+ Assert.Equal(200, r.StatusCode);
+ Assert.Contains("\"message\":\"2.1.0\"", r.Body, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task GitHub_Badge_Should_Return_401_When_Upstream_Is_Unauthorized()
+ {
+ var r = await stack.Api.InvokeAsync("GET", "/badges/packages/github/unauthorized-org/any.pkg", ct: TestContext.Current.CancellationToken);
+ Assert.Equal(401, r.StatusCode);
+ }
+
+ [Fact]
+ public async Task GitHub_Badge_Should_Return_403_When_Upstream_Is_Forbidden()
+ {
+ var r = await stack.Api.InvokeAsync("GET", "/badges/packages/github/forbidden-org/any.pkg", ct: TestContext.Current.CancellationToken);
+ Assert.Equal(403, r.StatusCode);
+ }
+
+ [Fact]
+ public async Task GitHub_Badge_Should_Return_404_When_Upstream_Package_Is_Missing()
+ {
+ var r = await stack.Api.InvokeAsync("GET", "/badges/packages/github/test-org/missing.pkg", ct: TestContext.Current.CancellationToken);
+ Assert.Equal(404, r.StatusCode);
+ }
+
+ [Fact]
+ public async Task GitHub_Badge_Should_Return_404_When_Upstream_Versions_Are_Empty()
+ {
+ var r = await stack.Api.InvokeAsync("GET", "/badges/packages/github/test-org/empty.pkg", ct: TestContext.Current.CancellationToken);
+ Assert.Equal(404, r.StatusCode);
+ }
+
+ [Fact]
+ public async Task GitHub_Badge_Should_Honor_IfNoneMatch()
+ {
+ var first = await stack.Api.InvokeAsync("GET", "/badges/packages/github/test-org/contracttest.pkg", ct: TestContext.Current.CancellationToken);
+ Assert.Equal(200, first.StatusCode);
+ var second = await stack.Api.InvokeAsync("GET", "/badges/packages/github/test-org/contracttest.pkg",
+ new Dictionary(StringComparer.Ordinal) { ["if-none-match"] = first.Headers!["ETag"] },
+ ct: TestContext.Current.CancellationToken);
+ Assert.Equal(304, second.StatusCode);
+ }
+
+ [Fact]
+ public async Task GitHub_Badge_Should_Return_401_When_Org_Has_No_Secret()
+ {
+ var r = await stack.Api.InvokeAsync("GET", "/badges/packages/github/unknown-org/some.pkg", ct: TestContext.Current.CancellationToken);
+ Assert.Equal(401, r.StatusCode);
+ }
+
+ [Fact]
+ public async Task Packages_Route_Should_Return_400_When_Provider_Is_Unknown()
+ {
+ var r = await stack.Api.InvokeAsync("GET", "/badges/packages/npm/some.pkg", ct: TestContext.Current.CancellationToken);
+ Assert.Equal(400, r.StatusCode);
+ }
+}
diff --git a/tests/BadgeSmith.Api.Tests/Functional/RoutingContractTests.cs b/tests/BadgeSmith.Api.Tests/Functional/RoutingContractTests.cs
new file mode 100644
index 0000000..c06ffaa
--- /dev/null
+++ b/tests/BadgeSmith.Api.Tests/Functional/RoutingContractTests.cs
@@ -0,0 +1,49 @@
+using BadgeSmith.Api.Tests.Testing;
+using BadgeSmith.Api.Tests.Testing.Infrastructure;
+using Xunit;
+
+namespace BadgeSmith.Api.Tests.Functional;
+
+[Collection("aspire-contract")]
+[Trait("Category", TestCategories.Integration)]
+[Trait("Category", TestCategories.Functional)]
+public sealed class RoutingContractTests(AspireContractFixture stack)
+{
+ [Fact]
+ public async Task Unknown_Route_Should_Return_404()
+ {
+ var r = await stack.Api.InvokeAsync("GET", "/nope/nothing/here", ct: TestContext.Current.CancellationToken);
+ Assert.Equal(404, r.StatusCode);
+ }
+
+ [Fact]
+ public async Task Head_Should_Be_Routed_Like_Get()
+ {
+ var r = await stack.Api.InvokeAsync("HEAD", "/health", ct: TestContext.Current.CancellationToken);
+ Assert.Equal(200, r.StatusCode);
+ }
+
+ [Fact]
+ public async Task Options_Preflight_Should_Return_Cors_Headers()
+ {
+ var r = await stack.Api.InvokeAsync("OPTIONS", "/badges/packages/nuget/contracttest.pkg",
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["origin"] = "https://example.com",
+ ["access-control-request-method"] = "GET",
+ },
+ ct: TestContext.Current.CancellationToken);
+ Assert.Equal(204, r.StatusCode);
+ Assert.NotNull(r.Headers);
+ Assert.Equal("*", r.Headers["Access-Control-Allow-Origin"]);
+ Assert.Contains("GET", r.Headers["Access-Control-Allow-Methods"], StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Responses_Should_Carry_Cors_Header()
+ {
+ var r = await stack.Api.InvokeAsync("GET", "/health", ct: TestContext.Current.CancellationToken);
+ Assert.NotNull(r.Headers);
+ Assert.Equal("*", r.Headers["Access-Control-Allow-Origin"]);
+ }
+}
diff --git a/tests/BadgeSmith.Api.Tests/Functional/TestResultsContractTests.cs b/tests/BadgeSmith.Api.Tests/Functional/TestResultsContractTests.cs
new file mode 100644
index 0000000..a134713
--- /dev/null
+++ b/tests/BadgeSmith.Api.Tests/Functional/TestResultsContractTests.cs
@@ -0,0 +1,353 @@
+using BadgeSmith.Api.Tests.Testing;
+using BadgeSmith.Api.Tests.Testing.Infrastructure;
+using Amazon.DynamoDBv2.Model;
+using Amazon.SecretsManager.Model;
+using System.Globalization;
+using Xunit;
+
+namespace BadgeSmith.Api.Tests.Functional;
+
+[Collection("aspire-contract")]
+[Trait("Category", TestCategories.Integration)]
+[Trait("Category", TestCategories.Functional)]
+public sealed class TestResultsContractTests(AspireContractFixture stack)
+{
+ private const string Owner = "test-org";
+ private const string AlternateOwner = "test-org-alt";
+ private const string Platform = "linux";
+ private const string OrgSecretsTableName = "badge-smith-github-org-secrets";
+ private static readonly DateTimeOffset TimestampSeed = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
+
+ private static TestResultCase CreateCase(string slug, int timestampOffsetSeconds)
+ {
+ var repo = $"test-repo-{slug}";
+ var branch = $"main-{slug}";
+ var runId = $"run-{slug}";
+ var timestamp = TimestampSeed.AddSeconds(timestampOffsetSeconds).ToString("O");
+ var urlHtml = $"https://github.com/{Owner}/{repo}/runs/{timestampOffsetSeconds}";
+ var workflowRunUrl = $"https://github.com/{Owner}/{repo}/actions/runs/{timestampOffsetSeconds}";
+
+ return new TestResultCase(
+ IngestPath: CreateIngestPath(Owner, repo, Platform, branch),
+ BadgePath: CreateBadgePath(Owner, repo, Platform, branch),
+ RedirectPath: $"/redirect/test-results/{Platform}/{Owner}/{repo}/{branch}",
+ Repo: repo,
+ Branch: branch,
+ RunId: runId,
+ Timestamp: timestamp,
+ UrlHtml: urlHtml,
+ WorkflowRunUrl: workflowRunUrl);
+ }
+
+ private static Dictionary AuthHeaders(TestResultCase testCase, string body)
+ {
+ var (sig, ts, nonce) = HmacTestSigner.Sign(Owner, testCase.Repo, Platform, testCase.Branch, body, AwsTestSeeder.HmacSecret);
+ return new Dictionary(StringComparer.Ordinal)
+ {
+ ["x-signature"] = sig,
+ ["x-timestamp"] = ts,
+ ["x-nonce"] = nonce,
+ ["content-type"] = "application/json",
+ };
+ }
+
+ public static TheoryData TamperedFields =>
+ [
+ "owner",
+ "repo",
+ "platform",
+ "branch",
+ "timestamp",
+ "nonce",
+ "body",
+ ];
+
+ [Fact]
+ public async Task Ingestion_Should_Round_Trip_Badge_When_Accepted()
+ {
+ var testCase = CreateCase("roundtrip", 1);
+ var body = testCase.CreatePayload();
+ var post = await stack.Api.InvokeAsync("POST", testCase.IngestPath, AuthHeaders(testCase, body), body, TestContext.Current.CancellationToken);
+ Assert.Equal(201, post.StatusCode);
+
+ var badge = await stack.Api.InvokeAsync("GET", testCase.BadgePath, ct: TestContext.Current.CancellationToken);
+ Assert.Equal(200, badge.StatusCode);
+ Assert.Contains("\"schemaVersion\":1", badge.Body, StringComparison.Ordinal);
+ Assert.Contains("passed", badge.Body, StringComparison.Ordinal);
+ Assert.NotNull(badge.Headers);
+ Assert.StartsWith("\"", badge.Headers["ETag"], StringComparison.Ordinal);
+ Assert.True(badge.Headers.ContainsKey("Last-Modified"));
+
+ var cached = await stack.Api.InvokeAsync("GET", testCase.BadgePath,
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["if-none-match"] = badge.Headers["ETag"]
+ },
+ ct: TestContext.Current.CancellationToken);
+ Assert.Equal(304, cached.StatusCode);
+ }
+
+ [Fact]
+ public async Task Ingestion_Should_Return_401_When_Signature_Is_Invalid()
+ {
+ var testCase = CreateCase("bad-signature", 2);
+ var body = testCase.CreatePayload();
+ var headers = AuthHeaders(testCase, body);
+ headers["x-signature"] = "sha256=" + new string('0', 64);
+ var post = await stack.Api.InvokeAsync("POST", testCase.IngestPath, headers, body, TestContext.Current.CancellationToken);
+ Assert.Equal(401, post.StatusCode);
+ }
+
+ [Theory]
+ [MemberData(nameof(TamperedFields))]
+ public async Task Ingestion_Should_Return_401_And_Not_Store_Result_When_Canonical_Field_Is_Tampered(string fieldName)
+ {
+ if (string.Equals(fieldName, "owner", StringComparison.Ordinal))
+ {
+ await EnsureAlternateOwnerUsesTestSecretAsync(TestContext.Current.CancellationToken);
+ }
+
+ var testCase = CreateCase($"tamper-{fieldName}", 20 + fieldName.Length);
+ var body = testCase.CreatePayload();
+ var headers = AuthHeaders(testCase, body);
+
+ var owner = Owner;
+ var repo = testCase.Repo;
+ var platform = Platform;
+ var branch = testCase.Branch;
+ var sentBody = body;
+
+ switch (fieldName)
+ {
+ case "owner":
+ owner = AlternateOwner;
+ break;
+ case "repo":
+ repo += "-alt";
+ break;
+ case "platform":
+ platform = "windows";
+ break;
+ case "branch":
+ branch += "-alt";
+ break;
+ case "timestamp":
+ headers["x-timestamp"] = DateTimeOffset
+ .Parse(headers["x-timestamp"], CultureInfo.InvariantCulture)
+ .AddSeconds(1)
+ .ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
+ break;
+ case "nonce":
+ headers["x-nonce"] = Guid.NewGuid().ToString("N");
+ break;
+ case "body":
+ sentBody = body.Replace("\"passed\":10", "\"passed\":11", StringComparison.Ordinal);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(fieldName), fieldName, "Unknown field.");
+ }
+
+ var targetIngestPath = CreateIngestPath(owner, repo, platform, branch);
+ var targetBadgePath = CreateBadgePath(owner, repo, platform, branch);
+
+ var post = await stack.Api.InvokeAsync("POST", targetIngestPath, headers, sentBody, TestContext.Current.CancellationToken);
+ Assert.Equal(401, post.StatusCode);
+
+ var badge = await stack.Api.InvokeAsync("GET", targetBadgePath, ct: TestContext.Current.CancellationToken);
+ Assert.Equal(404, badge.StatusCode);
+ }
+
+ [Fact]
+ public async Task Ingestion_Should_Return_400_When_Timestamp_Is_Stale()
+ {
+ var testCase = CreateCase("stale-timestamp", 3);
+ var body = testCase.CreatePayload();
+ var (sig, ts, nonce) = HmacTestSigner.Sign(Owner, testCase.Repo, Platform, testCase.Branch, body, AwsTestSeeder.HmacSecret,
+ timestamp: DateTimeOffset.UtcNow.AddMinutes(-10));
+ var headers = new Dictionary(StringComparer.Ordinal)
+ {
+ ["x-signature"] = sig,
+ ["x-timestamp"] = ts,
+ ["x-nonce"] = nonce,
+ };
+ var post = await stack.Api.InvokeAsync("POST", testCase.IngestPath, headers, body, TestContext.Current.CancellationToken);
+ Assert.Equal(400, post.StatusCode);
+ }
+
+ [Fact]
+ public async Task Ingestion_Should_Return_400_When_Timestamp_Is_Future()
+ {
+ var testCase = CreateCase("future-timestamp", 9);
+ var body = testCase.CreatePayload();
+ var (sig, ts, nonce) = HmacTestSigner.Sign(Owner, testCase.Repo, Platform, testCase.Branch, body, AwsTestSeeder.HmacSecret,
+ timestamp: DateTimeOffset.UtcNow.AddMinutes(10));
+ var headers = new Dictionary(StringComparer.Ordinal)
+ {
+ ["x-signature"] = sig,
+ ["x-timestamp"] = ts,
+ ["x-nonce"] = nonce,
+ };
+ var post = await stack.Api.InvokeAsync("POST", testCase.IngestPath, headers, body, TestContext.Current.CancellationToken);
+ Assert.Equal(400, post.StatusCode);
+ }
+
+ [Fact]
+ public async Task Ingestion_Should_Return_400_When_Nonce_Is_Replayed()
+ {
+ var testCase = CreateCase("nonce-replay", 4);
+ var nonce = Guid.NewGuid().ToString("N");
+ var body1 = testCase.CreatePayload();
+ var (sig1, ts1, _) = HmacTestSigner.Sign(Owner, testCase.Repo, Platform, testCase.Branch, body1, AwsTestSeeder.HmacSecret, nonce: nonce);
+ var first = await stack.Api.InvokeAsync("POST", testCase.IngestPath,
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["x-signature"] = sig1,
+ ["x-timestamp"] = ts1,
+ ["x-nonce"] = nonce
+ }, body1,
+ TestContext.Current.CancellationToken);
+ Assert.Equal(201, first.StatusCode);
+
+ var replay = await stack.Api.InvokeAsync("POST", testCase.IngestPath,
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["x-signature"] = sig1,
+ ["x-timestamp"] = ts1,
+ ["x-nonce"] = nonce
+ }, body1,
+ TestContext.Current.CancellationToken);
+ Assert.Equal(400, replay.StatusCode);
+ }
+
+ [Fact]
+ public async Task Ingestion_Should_Return_400_When_Same_Signed_Request_Is_Replayed_With_Case_Only_Route_Changes()
+ {
+ var testCase = CreateCase("case-replay", 10);
+ var body = testCase.CreatePayload();
+ var headers = AuthHeaders(testCase, body);
+
+ var first = await stack.Api.InvokeAsync(
+ "POST",
+ testCase.IngestPath,
+ headers,
+ body,
+ TestContext.Current.CancellationToken);
+ Assert.Equal(201, first.StatusCode);
+
+ var replay = await stack.Api.InvokeAsync(
+ "POST",
+ CreateIngestPath(Owner.ToUpperInvariant(), testCase.Repo.ToUpperInvariant(), Platform.ToUpperInvariant(), testCase.Branch),
+ headers,
+ body,
+ TestContext.Current.CancellationToken);
+ Assert.Equal(400, replay.StatusCode);
+
+ var badge = await stack.Api.InvokeAsync("GET", testCase.BadgePath, ct: TestContext.Current.CancellationToken);
+ Assert.Equal(200, badge.StatusCode);
+ }
+
+ [Fact]
+ public async Task Ingestion_Should_Return_401_When_Signature_Hex_Is_Malformed()
+ {
+ var testCase = CreateCase("malformed-hex", 6);
+ var body = testCase.CreatePayload();
+ var headers = AuthHeaders(testCase, body);
+ headers["x-signature"] = "sha256=" + new string('z', 64);
+
+ var post = await stack.Api.InvokeAsync(
+ "POST",
+ testCase.IngestPath,
+ headers,
+ body,
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(401, post.StatusCode);
+ }
+
+ [Fact]
+ public async Task Ingestion_Should_Return_400_When_Auth_Headers_Are_Missing()
+ {
+ var testCase = CreateCase("missing-auth", 7);
+ var body = testCase.CreatePayload();
+ var post = await stack.Api.InvokeAsync("POST", testCase.IngestPath, body: body, ct: TestContext.Current.CancellationToken);
+ Assert.Equal(400, post.StatusCode);
+ }
+
+ [Fact]
+ public async Task Badge_Should_Return_404_When_Repo_Is_Unknown()
+ {
+ var badge = await stack.Api.InvokeAsync("GET", "/badges/tests/linux/test-org/no-such-repo/main", ct: TestContext.Current.CancellationToken);
+ Assert.Equal(404, badge.StatusCode);
+ }
+
+ [Fact]
+ public async Task Redirect_Should_Return_302_With_Location()
+ {
+ var testCase = CreateCase("redirect", 8);
+ var body = testCase.CreatePayload();
+ var post = await stack.Api.InvokeAsync("POST", testCase.IngestPath, AuthHeaders(testCase, body), body, TestContext.Current.CancellationToken);
+ Assert.Equal(201, post.StatusCode);
+
+ var redirect = await stack.Api.InvokeAsync("GET", testCase.RedirectPath, ct: TestContext.Current.CancellationToken);
+ Assert.Equal(302, redirect.StatusCode);
+ Assert.NotNull(redirect.Headers);
+ Assert.Equal(testCase.UrlHtml, redirect.Headers["Location"]);
+ Assert.True(redirect.Headers.ContainsKey("Cache-Control"));
+ Assert.Contains("public", redirect.Headers["Cache-Control"], StringComparison.OrdinalIgnoreCase);
+ }
+
+ private sealed record TestResultCase(
+ string IngestPath,
+ string BadgePath,
+ string RedirectPath,
+ string Repo,
+ string Branch,
+ string RunId,
+ string Timestamp,
+ string UrlHtml,
+ string WorkflowRunUrl)
+ {
+ public string CreatePayload() => $$"""
+ {"platform":"linux","passed":10,"failed":0,"skipped":1,"total":11,
+ "url_html":"{{UrlHtml}}",
+ "timestamp":"{{Timestamp}}","commit":"abc1234","run_id":"{{RunId}}",
+ "workflow_run_url":"{{WorkflowRunUrl}}"}
+ """;
+ }
+
+ private static string CreateIngestPath(string owner, string repo, string platform, string branch) => $"/tests/results/{platform}/{owner}/{repo}/{branch}";
+
+ private static string CreateBadgePath(string owner, string repo, string platform, string branch) => $"/badges/tests/{platform}/{owner}/{repo}/{branch}";
+
+ private async Task EnsureAlternateOwnerUsesTestSecretAsync(CancellationToken cancellationToken)
+ {
+ const string secretName = "badgesmith/github/test-org-alt/testdata";
+
+ try
+ {
+ await stack.Secrets.DescribeSecretAsync(new DescribeSecretRequest
+ {
+ SecretId = secretName
+ }, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Amazon.SecretsManager.Model.ResourceNotFoundException)
+ {
+ await stack.Secrets.CreateSecretAsync(new CreateSecretRequest
+ {
+ Name = secretName,
+ SecretString = AwsTestSeeder.HmacSecret,
+ }, cancellationToken).ConfigureAwait(false);
+ }
+
+ await stack.DynamoDb.PutItemAsync(new PutItemRequest
+ {
+ TableName = OrgSecretsTableName,
+ Item = new Dictionary(StringComparer.Ordinal)
+ {
+ ["PK"] = new($"ORG#{AlternateOwner}"),
+ ["SK"] = new("CONST#GITHUB#testdata"),
+ ["SecretName"] = new(secretName),
+ },
+ }, cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/tests/BadgeSmith.Api.Tests/Http/HttpClientFactoryTests.cs b/tests/BadgeSmith.Api.Tests/Http/HttpClientFactoryTests.cs
new file mode 100644
index 0000000..77bc07a
--- /dev/null
+++ b/tests/BadgeSmith.Api.Tests/Http/HttpClientFactoryTests.cs
@@ -0,0 +1,123 @@
+using BadgeSmith.Api.Core.Http;
+using BadgeSmith.Api.Tests.Testing;
+using Xunit;
+using static BadgeSmith.Constants;
+
+namespace BadgeSmith.Api.Tests.Http;
+
+[Trait("Category", TestCategories.Unit)]
+public sealed class HttpClientFactoryTests : IDisposable
+{
+ public void Dispose()
+ {
+ Environment.SetEnvironmentVariable("HTTP_NUGET_BASE_URL", null);
+ Environment.SetEnvironmentVariable("HTTP_GITHUB_BASE_URL", null);
+ Environment.SetEnvironmentVariable(UpstreamModeEnvironmentVariable, null);
+ }
+
+ [Fact]
+ public void CreateNuGetClient_Should_Use_Default_BaseAddress_When_Environment_Variable_Is_Not_Set()
+ {
+ using var client = HttpClientFactory.CreateNuGetClient();
+ Assert.Equal(new Uri("https://api.nuget.org/"), client.BaseAddress);
+ }
+
+ [Fact]
+ public void CreateNuGetClient_Should_Use_Environment_Override_When_Set()
+ {
+ SetMockUpstreams();
+ using var client = HttpClientFactory.CreateNuGetClient();
+ Assert.Equal(new Uri("http://wiremock:8080/nuget/"), client.BaseAddress);
+ }
+
+ [Fact]
+ public void CreateGithubClient_Should_Use_Environment_Override_When_Set()
+ {
+ SetMockUpstreams();
+ using var client = HttpClientFactory.CreateGithubClient();
+ Assert.Equal(new Uri("http://wiremock:8080/github/"), client.BaseAddress);
+ }
+
+ [Fact]
+ public void CreateNuGetClient_Should_Reject_Environment_Override_When_Invalid()
+ {
+ Environment.SetEnvironmentVariable("HTTP_NUGET_BASE_URL", "not-a-uri");
+
+ var exception = Assert.Throws(HttpClientFactory.CreateNuGetClient);
+
+ Assert.Contains("HTTP_NUGET_BASE_URL", exception.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void CreateNuGetClient_Should_Normalize_Trailing_Slash_When_Environment_Override_Is_Missing_Trailing_Slash()
+ {
+ SetMockUpstreams();
+ Environment.SetEnvironmentVariable("HTTP_NUGET_BASE_URL", "http://wiremock:8080/nuget");
+ using var client = HttpClientFactory.CreateNuGetClient();
+ Assert.Equal(new Uri("http://wiremock:8080/nuget/"), client.BaseAddress);
+ }
+
+ [Fact]
+ public void CreateGithubClient_Should_Normalize_Trailing_Slash_When_Environment_Override_Is_Missing_Trailing_Slash()
+ {
+ SetMockUpstreams();
+ Environment.SetEnvironmentVariable("HTTP_GITHUB_BASE_URL", "http://wiremock:8080/github");
+ using var client = HttpClientFactory.CreateGithubClient();
+ Assert.Equal(new Uri("http://wiremock:8080/github/"), client.BaseAddress);
+ }
+
+ [Fact]
+ public void CreateGithubClient_Should_Use_Default_BaseAddress_When_Environment_Variable_Is_Not_Set()
+ {
+ using var client = HttpClientFactory.CreateGithubClient();
+ Assert.Equal(new Uri("https://api.github.com/"), client.BaseAddress);
+ }
+
+ [Fact]
+ public void CreateGithubClient_Should_Reject_Environment_Override_When_Invalid()
+ {
+ Environment.SetEnvironmentVariable("HTTP_GITHUB_BASE_URL", "not-a-uri");
+
+ var exception = Assert.Throws(HttpClientFactory.CreateGithubClient);
+
+ Assert.Contains("HTTP_GITHUB_BASE_URL", exception.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void CreateNuGetClient_Should_Reject_Public_Http_When_Mode_Is_Live()
+ {
+ Environment.SetEnvironmentVariable("HTTP_NUGET_BASE_URL", "http://api.example.com/nuget/");
+
+ var exception = Assert.Throws(HttpClientFactory.CreateNuGetClient);
+
+ Assert.Contains("HTTPS", exception.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void CreateNuGetClient_Should_Require_Both_Overrides_When_Mode_Is_Mock()
+ {
+ Environment.SetEnvironmentVariable(UpstreamModeEnvironmentVariable, UpstreamModeMock);
+ Environment.SetEnvironmentVariable("HTTP_NUGET_BASE_URL", "http://wiremock:8080/nuget/");
+
+ var exception = Assert.Throws(HttpClientFactory.CreateNuGetClient);
+
+ Assert.Contains("both", exception.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void CreateNuGetClient_Should_Reject_Invalid_Upstream_Mode()
+ {
+ Environment.SetEnvironmentVariable(UpstreamModeEnvironmentVariable, "invalid");
+
+ var exception = Assert.Throws(HttpClientFactory.CreateNuGetClient);
+
+ Assert.Contains(UpstreamModeEnvironmentVariable, exception.Message, StringComparison.Ordinal);
+ }
+
+ private static void SetMockUpstreams()
+ {
+ Environment.SetEnvironmentVariable(UpstreamModeEnvironmentVariable, UpstreamModeMock);
+ Environment.SetEnvironmentVariable("HTTP_NUGET_BASE_URL", "http://wiremock:8080/nuget/");
+ Environment.SetEnvironmentVariable("HTTP_GITHUB_BASE_URL", "http://wiremock:8080/github/");
+ }
+}
diff --git a/tests/BadgeSmith.Api.Tests/README.md b/tests/BadgeSmith.Api.Tests/README.md
new file mode 100644
index 0000000..e6b042b
--- /dev/null
+++ b/tests/BadgeSmith.Api.Tests/README.md
@@ -0,0 +1,25 @@
+# BadgeSmith.Api.Tests
+
+The test project uses xUnit v3 on VSTest.
+
+## Categories
+
+- `Category=Unit`: in-process unit tests.
+- `Category=Integration`: tests requiring Aspire, LocalStack, WireMock, or other infrastructure.
+- `Category=Functional`: HTTP contract tests that exercise BadgeSmith routes.
+
+`Category=AotContract` is reserved for a future RIE-free AOT artifact smoke tier. The Aspire-backed contract tests do not use this category.
+
+## Contract Tests
+
+Contract tests start `src/BadgeSmith.Host` through Aspire Testing and call `APIGatewayEmulator` over HTTP. They do not use Lambda RIE or the Lambda invocation endpoint.
+
+### Emulator Culture
+
+`APIGatewayEmulator` is started with invariant/C culture in `src/BadgeSmith.Host` so header normalization matches API Gateway HTTP API v2. This prevents Turkish-culture lowercasing from converting `If-None-Match` to `Δ±f-none-match` and keeps the 304 contract tests executable locally.
+
+## Benchmark Tests
+
+k6 benchmark scripts are not contract tests. Local benchmark runs target LocalStack and seed DynamoDB plus Secrets Manager before invoking package routes.
+
+LocalStack Community 4.6 does not deploy API Gateway v2 resources through CloudFormation in this workflow, so the local CDK performance stack exposes a Lambda Function URL fallback. The production stack still uses API Gateway HTTP v2.
diff --git a/tests/BadgeSmith.Api.Tests/Routing/ApiRouterTests.cs b/tests/BadgeSmith.Api.Tests/Routing/ApiRouterTests.cs
new file mode 100644
index 0000000..f2d21fc
--- /dev/null
+++ b/tests/BadgeSmith.Api.Tests/Routing/ApiRouterTests.cs
@@ -0,0 +1,47 @@
+using Amazon.Lambda.APIGatewayEvents;
+using BadgeSmith.Api.Core.Routing;
+using BadgeSmith.Api.Core.Routing.Contracts;
+using BadgeSmith.Api.Tests.Testing;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace BadgeSmith.Api.Tests.Routing;
+
+[Trait("Category", TestCategories.Unit)]
+public sealed class ApiRouterTests
+{
+ [Fact]
+ public async Task RouteAsync_Should_Return_Generic_Error_Message_When_Handler_Throws()
+ {
+ const string secretLeak = "SECRET-INTERNAL-STACKTRACE-DETAILS";
+
+ var corsHandler = new Mock(MockBehavior.Strict);
+ corsHandler
+ .Setup(c => c.HandlePreflight(It.IsAny?>(), It.IsAny()))
+ .Throws(new InvalidOperationException(secretLeak));
+
+ var sut = new ApiRouter(
+ Mock.Of>(),
+ Mock.Of(),
+ corsHandler.Object);
+
+ var request = new APIGatewayHttpApiV2ProxyRequest
+ {
+ RequestContext = new APIGatewayHttpApiV2ProxyRequest.ProxyRequestContext
+ {
+ Http = new APIGatewayHttpApiV2ProxyRequest.HttpDescription
+ {
+ Method = "OPTIONS",
+ Path = "/anything",
+ },
+ },
+ };
+
+ var response = await sut.RouteAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.Equal(500, response.StatusCode);
+ Assert.Equal("An error occurred processing the request", response.Body);
+ Assert.DoesNotContain(secretLeak, response.Body ?? string.Empty, StringComparison.Ordinal);
+ }
+}
diff --git a/tests/BadgeSmith.Api.Tests/Routing/CorsHandler/ApplyResponseHeaders.cs b/tests/BadgeSmith.Api.Tests/Routing/CorsHandler/ApplyResponseHeaders.cs
index 7823823..27a2cbf 100644
--- a/tests/BadgeSmith.Api.Tests/Routing/CorsHandler/ApplyResponseHeaders.cs
+++ b/tests/BadgeSmith.Api.Tests/Routing/CorsHandler/ApplyResponseHeaders.cs
@@ -1,12 +1,15 @@
ο»Ώusing BadgeSmith.Api.Core.Routing.Contracts;
using BadgeSmith.Api.Core.Routing.Cors;
+using BadgeSmith.Api.Tests.Testing;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
+using static BadgeSmith.Api.Tests.TestBase;
namespace BadgeSmith.Api.Tests.Routing.CorsHandler;
-public class ApplyResponseHeadersTests : TestBase
+[Trait("Category", TestCategories.Unit)]
+public class ApplyResponseHeadersTests
{
private readonly Mock _mockRouteResolver;
private readonly Mock> _mockLogger;
@@ -18,7 +21,7 @@ public ApplyResponseHeadersTests()
}
[Fact]
- public void ApplyResponseHeaders_Should_AddWildcardForPublicAPI()
+ public void ApplyResponseHeaders_Should_Add_Wildcard_When_Api_Is_Public()
{
var options = CorsOptions.Default;
var handler = new Core.Routing.Cors.CorsHandler(_mockRouteResolver.Object, _mockLogger.Object, options);
@@ -31,7 +34,7 @@ public void ApplyResponseHeaders_Should_AddWildcardForPublicAPI()
}
[Fact]
- public void ApplyResponseHeaders_Should_EchoOriginWhenUseWildcardIsFalse()
+ public void ApplyResponseHeaders_Should_Echo_Origin_When_UseWildcard_Is_False()
{
var options = new CorsOptions
{
@@ -47,7 +50,7 @@ public void ApplyResponseHeaders_Should_EchoOriginWhenUseWildcardIsFalse()
}
[Fact]
- public void ApplyResponseHeaders_Should_HandleCredentialsWithTrustedOrigin()
+ public void ApplyResponseHeaders_Should_Handle_Credentials_When_Origin_Is_Trusted()
{
var allowedOrigins = new HashSet
(StringComparer.OrdinalIgnoreCase)
@@ -70,7 +73,7 @@ public void ApplyResponseHeaders_Should_HandleCredentialsWithTrustedOrigin()
}
[Fact]
- public void ApplyResponseHeaders_Should_RejectUntrustedOriginWithCredentials()
+ public void ApplyResponseHeaders_Should_Reject_Untrusted_Origin_When_Credentials_Are_Enabled()
{
var allowedOrigins = new HashSet
(StringComparer.OrdinalIgnoreCase)
@@ -92,7 +95,7 @@ public void ApplyResponseHeaders_Should_RejectUntrustedOriginWithCredentials()
}
[Fact]
- public void ApplyResponseHeaders_Should_AddExposeHeaders()
+ public void ApplyResponseHeaders_Should_Add_Expose_Headers()
{
var exposeHeaders = new HashSet
(StringComparer.OrdinalIgnoreCase)
@@ -116,7 +119,7 @@ public void ApplyResponseHeaders_Should_AddExposeHeaders()
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
- public void ApplyResponseHeaders_Should_HandleMissingOrigin(string? origin)
+ public void ApplyResponseHeaders_Should_Handle_Missing_Origin(string? origin)
{
var options = CorsOptions.Default;
var handler = new Core.Routing.Cors.CorsHandler(_mockRouteResolver.Object, _mockLogger.Object, options);
diff --git a/tests/BadgeSmith.Api.Tests/Routing/CorsHandler/CorsOptionsTests.cs b/tests/BadgeSmith.Api.Tests/Routing/CorsHandler/CorsOptionsTests.cs
index dc2b4c2..8e1fe9f 100644
--- a/tests/BadgeSmith.Api.Tests/Routing/CorsHandler/CorsOptionsTests.cs
+++ b/tests/BadgeSmith.Api.Tests/Routing/CorsHandler/CorsOptionsTests.cs
@@ -1,12 +1,14 @@
using BadgeSmith.Api.Core.Routing.Cors;
+using BadgeSmith.Api.Tests.Testing;
using Xunit;
namespace BadgeSmith.Api.Tests.Routing.CorsHandler;
+[Trait("Category", TestCategories.Unit)]
public class CorsOptionsTests
{
[Fact]
- public void CorsOptions_Default_Should_HaveCorrectValues()
+ public void CorsOptions_Should_Have_Correct_Values_When_Default()
{
var options = CorsOptions.Default;
@@ -20,7 +22,7 @@ public void CorsOptions_Default_Should_HaveCorrectValues()
}
[Fact]
- public void CorsOptions_Should_SupportCustomConfiguration()
+ public void CorsOptions_Should_Support_Custom_Configuration()
{
var customOrigins = new HashSet(StringComparer.OrdinalIgnoreCase)
{
diff --git a/tests/BadgeSmith.Api.Tests/Routing/CorsHandler/HandlePreflightTests.cs b/tests/BadgeSmith.Api.Tests/Routing/CorsHandler/HandlePreflightTests.cs
index a22735a..b8b3f98 100644
--- a/tests/BadgeSmith.Api.Tests/Routing/CorsHandler/HandlePreflightTests.cs
+++ b/tests/BadgeSmith.Api.Tests/Routing/CorsHandler/HandlePreflightTests.cs
@@ -1,13 +1,16 @@
ο»Ώusing BadgeSmith.Api.Core.Routing.Contracts;
using BadgeSmith.Api.Core.Routing.Cors;
using BadgeSmith.Api.Tests.TestHelpers;
+using BadgeSmith.Api.Tests.Testing;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
+using static BadgeSmith.Api.Tests.TestBase;
namespace BadgeSmith.Api.Tests.Routing.CorsHandler;
-public class HandlePreflightTests : TestBase
+[Trait("Category", TestCategories.Unit)]
+public class HandlePreflightTests
{
private readonly Mock _mockRouteResolver;
private readonly Mock> _mockLogger;
@@ -19,7 +22,7 @@ public HandlePreflightTests()
}
[Fact]
- public void HandlePreflight_Should_ReturnBasicCorsHeaders_ForSimpleRequest()
+ public void HandlePreflight_Should_Return_Basic_Cors_Headers_When_Request_Is_Simple()
{
var options = CorsOptions.Default;
var handler = new Core.Routing.Cors.CorsHandler(_mockRouteResolver.Object, _mockLogger.Object, options);
@@ -47,7 +50,7 @@ public void HandlePreflight_Should_ReturnBasicCorsHeaders_ForSimpleRequest()
}
[Fact]
- public void HandlePreflight_Should_HandleSpecificMethodRequest()
+ public void HandlePreflight_Should_Handle_Specific_Method_Request()
{
var options = CorsOptions.Default;
var handler = new Core.Routing.Cors.CorsHandler(_mockRouteResolver.Object, _mockLogger.Object, options);
@@ -75,7 +78,7 @@ public void HandlePreflight_Should_HandleSpecificMethodRequest()
}
[Fact]
- public void HandlePreflight_Should_FilterRequestHeaders()
+ public void HandlePreflight_Should_Filter_Request_Headers()
{
var options = CorsOptions.Default;
var handler = new Core.Routing.Cors.CorsHandler(_mockRouteResolver.Object, _mockLogger.Object, options);
@@ -105,7 +108,7 @@ public void HandlePreflight_Should_FilterRequestHeaders()
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
- public void HandlePreflight_Should_HandleMissingRequestHeaders(string? requestHeaders)
+ public void HandlePreflight_Should_Handle_Missing_Request_Headers(string? requestHeaders)
{
var options = CorsOptions.Default;
var handler = new Core.Routing.Cors.CorsHandler(_mockRouteResolver.Object, _mockLogger.Object, options);
@@ -134,7 +137,7 @@ public void HandlePreflight_Should_HandleMissingRequestHeaders(string? requestHe
}
[Fact]
- public void HandlePreflight_Should_HandleCredentialsWithSpecificOrigin()
+ public void HandlePreflight_Should_Handle_Credentials_When_Origin_Is_Specific()
{
var allowedOrigins = new HashSet(StringComparer.OrdinalIgnoreCase)
{
@@ -169,7 +172,7 @@ public void HandlePreflight_Should_HandleCredentialsWithSpecificOrigin()
}
[Fact]
- public void HandlePreflight_Should_RejectUntrustedOriginWithCredentials()
+ public void HandlePreflight_Should_Reject_Untrusted_Origin_When_Credentials_Are_Enabled()
{
var allowedOrigins = new HashSet(StringComparer.OrdinalIgnoreCase)
{
@@ -202,7 +205,7 @@ public void HandlePreflight_Should_RejectUntrustedOriginWithCredentials()
}
[Fact]
- public void HandlePreflight_Should_UseOriginPredicateWhenProvided()
+ public void HandlePreflight_Should_Use_Origin_Predicate_When_Provided()
{
var options = new CorsOptions
{
@@ -231,7 +234,7 @@ public void HandlePreflight_Should_UseOriginPredicateWhenProvided()
}
[Fact]
- public void HandlePreflight_Should_HandleNoOriginHeader()
+ public void HandlePreflight_Should_Handle_No_Origin_Header()
{
var options = CorsOptions.Default;
var handler = new Core.Routing.Cors.CorsHandler(_mockRouteResolver.Object, _mockLogger.Object, options);
@@ -255,7 +258,7 @@ public void HandlePreflight_Should_HandleNoOriginHeader()
}
[Fact]
- public void HandlePreflight_Should_HandleNullHeaders()
+ public void HandlePreflight_Should_Handle_Null_Headers()
{
var options = CorsOptions.Default;
var handler = new Core.Routing.Cors.CorsHandler(_mockRouteResolver.Object, _mockLogger.Object, options);
@@ -274,7 +277,7 @@ public void HandlePreflight_Should_HandleNullHeaders()
}
[Fact]
- public void HandlePreflight_Should_IntegrateWithRouteResolver()
+ public void HandlePreflight_Should_Integrate_With_RouteResolver()
{
var routes = new[]
{
@@ -299,7 +302,7 @@ public void HandlePreflight_Should_IntegrateWithRouteResolver()
}
[Fact]
- public void HandlePreflight_Should_HandleNonExistentRoute()
+ public void HandlePreflight_Should_Handle_Nonexistent_Route()
{
var routes = new[] { RouteTestBuilder.CreateRouteDescriptor("Health", "GET", RouteTestBuilder.CreateExactPattern("/health")), };
var resolver = RouteTestBuilder.CreateRouteResolver(routes);
@@ -318,7 +321,7 @@ public void HandlePreflight_Should_HandleNonExistentRoute()
}
[Fact]
- public void HandlePreflight_Should_AlwaysIncludeContentTypeHeader()
+ public void HandlePreflight_Should_Always_Include_Content_Type_Header()
{
var options = CorsOptions.Default;
var handler = new Core.Routing.Cors.CorsHandler(_mockRouteResolver.Object, _mockLogger.Object, options);
diff --git a/tests/BadgeSmith.Api.Tests/Routing/Patterns/ExactPatternTests.cs b/tests/BadgeSmith.Api.Tests/Routing/Patterns/ExactPatternTests.cs
index 15d8ea7..be54ff0 100644
--- a/tests/BadgeSmith.Api.Tests/Routing/Patterns/ExactPatternTests.cs
+++ b/tests/BadgeSmith.Api.Tests/Routing/Patterns/ExactPatternTests.cs
@@ -1,9 +1,11 @@
using BadgeSmith.Api.Core.Routing.Patterns;
using BadgeSmith.Api.Tests.TestHelpers;
+using BadgeSmith.Api.Tests.Testing;
using Xunit;
namespace BadgeSmith.Api.Tests.Routing.Patterns;
+[Trait("Category", TestCategories.Unit)]
public sealed class ExactPatternTests
{
[Theory]
@@ -11,7 +13,7 @@ public sealed class ExactPatternTests
[InlineData("/tests/results")]
[InlineData("/status")]
[InlineData("/api/v1/endpoint")]
- public void Constructor_Should_StoreLiteralCorrectly(string literal)
+ public void Constructor_Should_Store_Literal_Correctly(string literal)
{
var pattern = new ExactPattern(literal);
@@ -30,7 +32,7 @@ public void Constructor_Should_StoreLiteralCorrectly(string literal)
[InlineData("/health", "", false)]
[InlineData("/tests/results", "/tests", false)]
[InlineData("/tests/results", "/results", false)]
- public void TryMatch_Should_HandleCaseInsensitiveMatching(string literal, string path, bool expectedMatch)
+ public void TryMatch_Should_Handle_Case_Insensitive_Matching(string literal, string path, bool expectedMatch)
{
var pattern = RouteTestBuilder.CreateExactPattern(literal);
var values = RouteTestBuilder.CreateRouteValues(path);
@@ -41,7 +43,7 @@ public void TryMatch_Should_HandleCaseInsensitiveMatching(string literal, string
}
[Fact]
- public void TryMatch_Should_ReturnTrueForExactHealthMatch()
+ public void TryMatch_Should_Return_True_When_Health_Matches_Exactly()
{
var pattern = RouteTestBuilder.CreateExactPattern("/health");
const string path = "/health";
@@ -53,7 +55,7 @@ public void TryMatch_Should_ReturnTrueForExactHealthMatch()
}
[Fact]
- public void TryMatch_Should_ReturnTrueForTestResultsMatch()
+ public void TryMatch_Should_Return_True_When_Test_Results_Matches_Exactly()
{
var pattern = RouteTestBuilder.CreateExactPattern("/tests/results");
const string path = "/tests/results";
@@ -71,7 +73,7 @@ public void TryMatch_Should_ReturnTrueForTestResultsMatch()
[InlineData("/tests/results", "/tests/results/")]
[InlineData("/tests/results", "/tests/result")]
[InlineData("/tests/results", "/test/results")]
- public void TryMatch_Should_ReturnFalseForNonExactMatches(string literal, string path)
+ public void TryMatch_Should_Return_False_When_Match_Is_Not_Exact(string literal, string path)
{
var pattern = RouteTestBuilder.CreateExactPattern(literal);
var values = RouteTestBuilder.CreateRouteValues(path);
@@ -82,7 +84,7 @@ public void TryMatch_Should_ReturnFalseForNonExactMatches(string literal, string
}
[Fact]
- public void TryMatch_Should_NotModifyRouteValues()
+ public void TryMatch_Should_Not_Modify_RouteValues()
{
var pattern = RouteTestBuilder.CreateExactPattern("/health");
const string path = "/health";
@@ -102,7 +104,7 @@ public void TryMatch_Should_NotModifyRouteValues()
[InlineData("/tests/results")]
[InlineData("/api/v1/status")]
[InlineData("/badges/clear-cache")]
- public void TryMatch_Should_WorkWithVariousExactPaths(string exactPath)
+ public void TryMatch_Should_Work_When_Path_Is_Various_Exact_Path(string exactPath)
{
var pattern = RouteTestBuilder.CreateExactPattern(exactPath);
var values = RouteTestBuilder.CreateRouteValues(exactPath);
@@ -127,7 +129,7 @@ public void TryMatch_Should_WorkWithVariousExactPaths(string exactPath)
[InlineData(" ")] // Space
[InlineData("\t")] // Tab
[InlineData("\n")] // Newline
- public void TryMatch_Should_NotMatchSimilarOrMalformedPaths(string path)
+ public void TryMatch_Should_Not_Match_When_Path_Is_Similar_Or_Malformed(string path)
{
var pattern = RouteTestBuilder.CreateExactPattern("/health");
var values = RouteTestBuilder.CreateRouteValues(path);
@@ -139,7 +141,7 @@ public void TryMatch_Should_NotMatchSimilarOrMalformedPaths(string path)
[Theory]
[MemberData(nameof(GetRealWorldExactPatterns))]
- public void TryMatch_Should_HandleRealWorldExactPatterns(string literal, string testPath, bool shouldMatch)
+ public void TryMatch_Should_Handle_Real_World_ExactPattern(string literal, string testPath, bool shouldMatch)
{
var pattern = RouteTestBuilder.CreateExactPattern(literal);
var values = RouteTestBuilder.CreateRouteValues(testPath);
diff --git a/tests/BadgeSmith.Api.Tests/Routing/Patterns/RegexPatternTests.cs b/tests/BadgeSmith.Api.Tests/Routing/Patterns/RegexPatternTests.cs
index b46d589..bb30328 100644
--- a/tests/BadgeSmith.Api.Tests/Routing/Patterns/RegexPatternTests.cs
+++ b/tests/BadgeSmith.Api.Tests/Routing/Patterns/RegexPatternTests.cs
@@ -2,14 +2,16 @@
using System.Text.RegularExpressions;
using BadgeSmith.Api.Core.Routing.Patterns;
using BadgeSmith.Api.Tests.TestHelpers;
+using BadgeSmith.Api.Tests.Testing;
using Xunit;
namespace BadgeSmith.Api.Tests.Routing.Patterns;
+[Trait("Category", TestCategories.Unit)]
public sealed class RegexPatternTests
{
[Fact]
- public void Constructor_Should_InitializeWithSimpleRegex()
+ public void Constructor_Should_Initialize_When_Regex_Is_Simple()
{
var pattern = new RegexPattern(() => new Regex("^/health$", RegexOptions.IgnoreCase | RegexOptions.Compiled));
@@ -17,7 +19,7 @@ public void Constructor_Should_InitializeWithSimpleRegex()
}
[Fact]
- public void Constructor_Should_InitializeWithNamedGroups()
+ public void Constructor_Should_Initialize_When_Regex_Has_Named_Groups()
{
var pattern = new RegexPattern(() => new Regex(@"^/badges/packages/(?\w+)/(?[\w.-]+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled));
@@ -32,7 +34,7 @@ public void Constructor_Should_InitializeWithNamedGroups()
[InlineData("/health/check", false)]
[InlineData("health", false)] // Missing slash
[InlineData("", false)]
- public void TryMatch_Should_HandleSimpleRegexPatterns(string path, bool expectedMatch)
+ public void TryMatch_Should_Handle_Simple_RegexPattern(string path, bool expectedMatch)
{
var pattern = new RegexPattern(() => new Regex("^/health$", RegexOptions.IgnoreCase | RegexOptions.Compiled));
var values = RouteTestBuilder.CreateRouteValues(path);
@@ -50,7 +52,7 @@ public void TryMatch_Should_HandleSimpleRegexPatterns(string path, bool expected
[InlineData("/badges/packages/", false, null, null)]
[InlineData("/badges/packages/nuget", false, null, null)]
[InlineData("/different/path", false, null, null)]
- public void TryMatch_Should_ExtractNamedGroupsCorrectly(string path, bool expectedMatch, string? expectedProvider, string? expectedPackage)
+ public void TryMatch_Should_Extract_Named_Groups_Correctly(string path, bool expectedMatch, string? expectedProvider, string? expectedPackage)
{
var pattern = new RegexPattern(() => new Regex(@"^/badges/packages/(?\w+)/(?[\w.-]+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled));
var values = RouteTestBuilder.CreateRouteValues(path);
@@ -70,7 +72,7 @@ public void TryMatch_Should_ExtractNamedGroupsCorrectly(string path, bool expect
[InlineData("/badges/tests/linux/owner/repo/main", "linux", "owner", "repo", "main")]
[InlineData("/badges/tests/windows/microsoft/vscode/release-1.2", "windows", "microsoft", "vscode", "release-1.2")]
[InlineData("/badges/tests/macos/facebook/react/feature_branch", "macos", "facebook", "react", "feature_branch")]
- public void TryMatch_Should_ExtractMultipleNamedGroups(string path, string expectedPlatform, string expectedOwner, string expectedRepo, string expectedBranch)
+ public void TryMatch_Should_Extract_Multiple_Named_Groups(string path, string expectedPlatform, string expectedOwner, string expectedRepo, string expectedBranch)
{
var pattern = new RegexPattern(() => new Regex(
@"^/badges/tests/(?\w+)/(?[\w-]+)/(?[\w.-]+)/(?[\w.-]+)$",
@@ -90,7 +92,7 @@ public void TryMatch_Should_ExtractMultipleNamedGroups(string path, string expec
}
[Fact]
- public void TryMatch_Should_HandleOptionalGroups()
+ public void TryMatch_Should_Handle_Optional_Groups()
{
var pattern = new RegexPattern(() => new Regex(
@"^/badges/packages/(?\w+)(?:/(?[\w-]+))?/(?[\w.-]+)$",
@@ -127,7 +129,7 @@ public void TryMatch_Should_HandleOptionalGroups()
[InlineData(@"^/badges/packages/(?\w+)/(?[\w.-]+)$", "/badges/packages/nuget/Package.With.Dots")]
[InlineData(@"^/badges/packages/(?\w+)/(?[\w.-]+)$", "/badges/packages/nuget/Package-With-Dashes")]
[InlineData(@"^/badges/tests/(?\w+)/(?[\w-]+)/(?[\w.-]+)/(?[\w.-_]+)$", "/badges/tests/linux/owner-name/repo.name/branch_name")]
- public void TryMatch_Should_HandleSpecialCharactersInGroups(string regexPattern, string path)
+ public void TryMatch_Should_Handle_Special_Characters_When_Groups_Contain_Them(string regexPattern, string path)
{
var pattern = new RegexPattern(() => new Regex(regexPattern, RegexOptions.IgnoreCase | RegexOptions.Compiled));
var values = RouteTestBuilder.CreateRouteValues(path);
@@ -148,7 +150,7 @@ public void TryMatch_Should_HandleSpecialCharactersInGroups(string regexPattern,
[InlineData("/badges/packages/nuget/Package%3FWith%3FQuestion", "nuget", "Package?With?Question")]
[InlineData("/badges/packages/nuget/Package%23With%23Hash", "nuget", "Package#With#Hash")]
[InlineData("/badges/packages/nuget/Microsoft%2EExtensions%2EHttp", "nuget", "Microsoft.Extensions.Http")]
- public void TryMatch_Should_HandleUrlEncodedPackageNames(string path, string expectedProvider, string expectedPackage)
+ public void TryMatch_Should_Handle_Url_Encoded_Package_Names(string path, string expectedProvider, string expectedPackage)
{
var pattern = new RegexPattern(() => new Regex(@"^/badges/packages/(?\w+)/(?[\w.%+-]+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled));
var values = RouteTestBuilder.CreateRouteValues(path);
@@ -166,7 +168,7 @@ public void TryMatch_Should_HandleUrlEncodedPackageNames(string path, string exp
[InlineData("/badges/tests/macos/org/repo/release%2F2024%2D01%2D15", "macos", "org", "repo", "release/2024-01-15")]
[InlineData("/badges/tests/linux/org/repo/hotfix%2Fissue%23123", "linux", "org", "repo", "hotfix/issue#123")]
[InlineData("/badges/tests/windows/org/repo/feature%2Fadd%2Bsupport", "windows", "org", "repo", "feature/add+support")]
- public void TryMatch_Should_HandleUrlEncodedBranchNames(string path, string expectedPlatform, string expectedOwner, string expectedRepo, string expectedBranch)
+ public void TryMatch_Should_Handle_Url_Encoded_Branch_Names(string path, string expectedPlatform, string expectedOwner, string expectedRepo, string expectedBranch)
{
var pattern = new RegexPattern(() => new Regex(
@"^/badges/tests/(?\w+)/(?[\w-]+)/(?[\w.-]+)/(?[\w.%+-]+)$",
@@ -183,7 +185,7 @@ public void TryMatch_Should_HandleUrlEncodedBranchNames(string path, string expe
}
[Fact]
- public void TryMatch_Should_HandleComplexPackageNames()
+ public void TryMatch_Should_Handle_Complex_Package_Names()
{
var pattern = new RegexPattern(() => new Regex(
@"^/badges/packages/(?\w+)/(?[\w.-]+(?:\.[\w.-]+)*)$",
@@ -215,7 +217,7 @@ public void TryMatch_Should_HandleComplexPackageNames()
[InlineData("/badges/packages/nuget/Package With Spaces")] // Spaces not allowed in \w
[InlineData("/badges/packages//package")] // Empty provider
[InlineData("/badges/packages/provider/")] // Empty package
- public void TryMatch_Should_RejectInvalidPatterns(string path)
+ public void TryMatch_Should_Reject_Invalid_Patterns(string path)
{
var pattern = new RegexPattern(() => new Regex(@"^/badges/packages/(?\w+)/(?[\w.-]+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled));
var values = RouteTestBuilder.CreateRouteValues(path);
@@ -226,7 +228,7 @@ public void TryMatch_Should_RejectInvalidPatterns(string path)
}
[Fact]
- public void TryMatch_Should_NotSetParametersForUnsuccessfulGroups()
+ public void TryMatch_Should_Not_Set_Parameters_When_Groups_Are_Unsuccessful()
{
var pattern = new RegexPattern(() => new Regex(
@"^/badges/packages/(?\w+)/(?[\w.-]+)(?:/(?\d+\.\d+\.\d+))?$",
@@ -248,7 +250,7 @@ public void TryMatch_Should_NotSetParametersForUnsuccessfulGroups()
[Theory]
[MemberData(nameof(GetSourceGeneratedRegexData))]
- public void TryMatch_Should_WorkWithSourceGeneratedRegex(Func regexFactory, string path, bool expectedMatch, IDictionary expectedParameters)
+ public void TryMatch_Should_Work_When_Regex_Is_Source_Generated(Func regexFactory, string path, bool expectedMatch, IDictionary expectedParameters)
{
ArgumentNullException.ThrowIfNull(regexFactory);
ArgumentNullException.ThrowIfNull(expectedParameters);
@@ -271,7 +273,10 @@ public void TryMatch_Should_WorkWithSourceGeneratedRegex(Func regexFactor
}
}
- [SuppressMessage("Design", "CA1024:Use properties where appropriate")]
+ [SuppressMessage(
+ "Design",
+ "CA1024:Use properties where appropriate",
+ Justification = "This iterator is an xUnit MemberData source and is clearer as a method.")]
public static IEnumerable