diff --git a/.claude/skills/format/SKILL.md b/.claude/skills/format/SKILL.md index f4e4eb6b49..cfeabcce19 100644 --- a/.claude/skills/format/SKILL.md +++ b/.claude/skills/format/SKILL.md @@ -6,7 +6,7 @@ description: Auto-format code via the developer CLI - backend (.NET via JetBrain # Format ```bash -dotnet run --project developer-cli -- format [--backend] [--frontend] [--cli] [--self-contained-system ] [--no-build] --quiet +dotnet run --project developer-cli -- format [--backend] [--frontend] [--cli] [--self-contained-system ] [--no-build] [--all-files] --quiet ``` Use `developer-cli` exactly as written - do not expand to an absolute worktree path. @@ -16,8 +16,9 @@ Use `developer-cli` exactly as written - do not expand to an absolute worktree p - `--cli` - the developer CLI itself - `--self-contained-system ` - narrows backend formatting to one SCS (e.g. `account`, `main`) - `--no-build` - skip the `dotnet tool restore` step (faster after a recent run) +- `--all-files` - format every file in the solution. Default is to format only `.cs` files changed against `origin/main` (faster). -No arguments formats everything. Unformatted code fails CI - commit all changes, never revert. +No arguments formats everything (changed-only by default). Unformatted code fails CI - commit all changes, never revert. After `build` succeeds, run `format`, `lint`, `test` in parallel with `--no-build`. diff --git a/.claude/skills/lint/SKILL.md b/.claude/skills/lint/SKILL.md index 48bffa3b8e..78a0a34d31 100644 --- a/.claude/skills/lint/SKILL.md +++ b/.claude/skills/lint/SKILL.md @@ -6,7 +6,7 @@ description: Lint code via the developer CLI - backend (.NET via JetBrains inspe # Lint ```bash -dotnet run --project developer-cli -- lint [--backend] [--frontend] [--cli] [--self-contained-system ] [--no-build] --quiet +dotnet run --project developer-cli -- lint [--backend] [--frontend] [--cli] [--self-contained-system ] [--no-build] [--changed-only] --quiet ``` Use `developer-cli` exactly as written - do not expand to an absolute worktree path. @@ -16,18 +16,28 @@ Use `developer-cli` exactly as written - do not expand to an absolute worktree p - `--cli` - the developer CLI itself - `--self-contained-system ` - narrows backend linting to one SCS (e.g. `account`, `main`) - `--no-build` - skip the rebuild step (faster after a recent build) +- `--changed-only` - lint only `.cs` files changed against `origin/main` (much faster; see guidance below) -No arguments lints everything. Every finding fails CI regardless of severity - fix all of them. +No arguments lints the whole solution. Every finding fails CI regardless of severity - fix all of them. After `build` succeeds, run `format`, `lint`, `test` in parallel with `--no-build`. Backend lint is slow - run last. Frontend lint often needs code rewrites - run after each bigger change. +## When to use `--changed-only` + +Inspectcode has cross-file rules ("unused public method", "member can be private", flow analysis across method calls). `--changed-only` only inspects the listed files - it doesn't catch issues in untouched files that became invalid because of edits elsewhere. + +- **Routine work:** use `--changed-only`. Most lint findings are local (style, naming, hints) and the saving is large (~4m → ~30s). +- **Larger changes that affect other files** (refactoring a public API, deleting a method's only caller, changing a widely-used type): omit `--changed-only` and lint the full solution. + +CI always lints the full solution, so anything missed by a local `--changed-only` run gets caught before merge. + ## Examples ```bash -dotnet run --project developer-cli -- lint --quiet # everything -dotnet run --project developer-cli -- lint --backend --quiet # all backend -dotnet run --project developer-cli -- lint --frontend --quiet # frontend -dotnet run --project developer-cli -- lint --backend --self-contained-system main --quiet # one SCS +dotnet run --project developer-cli -- lint --quiet # everything (full solution) +dotnet run --project developer-cli -- lint --backend --changed-only --quiet # backend, changed files only (recommended for routine work) +dotnet run --project developer-cli -- lint --frontend --quiet # frontend +dotnet run --project developer-cli -- lint --backend --self-contained-system main --quiet # one SCS, full ``` ## Always pass --quiet diff --git a/.github/workflows/_deploy-container.yml b/.github/workflows/_deploy-container.yml index 486d429b65..d2dd952d26 100644 --- a/.github/workflows/_deploy-container.yml +++ b/.github/workflows/_deploy-container.yml @@ -38,6 +38,10 @@ on: required: true type: string +concurrency: + group: ${{ inputs.image_name }}-${{ inputs.azure_environment }}-deploy + cancel-in-progress: false + jobs: deploy: name: Deploy diff --git a/.github/workflows/_deploy-infrastructure.yml b/.github/workflows/_deploy-infrastructure.yml index d94c21f084..3c5e144bee 100644 --- a/.github/workflows/_deploy-infrastructure.yml +++ b/.github/workflows/_deploy-infrastructure.yml @@ -49,6 +49,10 @@ on: type: string default: "-" +concurrency: + group: ${{ inputs.unique_prefix }}-${{ inputs.azure_environment }}-infrastructure + cancel-in-progress: false + jobs: plan: name: Plan @@ -67,6 +71,7 @@ jobs: should_deploy="false" fi echo "should_deploy=$should_deploy" >> $GITHUB_OUTPUT + - name: Checkout Code uses: actions/checkout@v6 diff --git a/.github/workflows/_migrate-database.yml b/.github/workflows/_migrate-database.yml index cb354e20d6..74e18a84a0 100644 --- a/.github/workflows/_migrate-database.yml +++ b/.github/workflows/_migrate-database.yml @@ -32,6 +32,10 @@ on: apply_migrations: required: true type: boolean + build_artifact_name: + description: "Name of the artifact uploaded by the caller's build-and-test job that contains application/**/bin and application/**/obj" + required: true + type: string outputs: has_migrations_to_apply: @@ -68,9 +72,18 @@ jobs: working-directory: application run: dotnet tool restore - - name: Build Backend Solution + - name: Download Backend Build Artifacts + uses: actions/download-artifact@v8 + with: + name: ${{ inputs.build_artifact_name }} + path: application + + # The artifact carries bin/ and obj/ but not the global NuGet package cache. + # `dotnet ef --no-build` loads compiled assemblies that reference packages by + # absolute path under ~/.nuget/packages — those must exist on this runner. + - name: Restore .NET Dependencies working-directory: application - run: dotnet build ${{ inputs.relative_startup_project }} + run: dotnet restore ${{ inputs.relative_startup_project }} - name: Login to Azure uses: azure/login@v3 @@ -93,7 +106,7 @@ jobs: run: | ENTRA_USER=$(az postgres flexible-server microsoft-entra-admin list --resource-group ${{ env.CLUSTER_RESOURCE_GROUP_NAME }} --server-name ${{ env.POSTGRES_SERVER_NAME }} --query "[0].principalName" --output tsv) CONNECTION_STRING="Host=${{ env.POSTGRES_HOST }};Database=${{ inputs.database_name }};Username=$ENTRA_USER;Password=$(az account get-access-token --resource-type oss-rdbms --query accessToken --output tsv);Ssl Mode=VerifyFull;" - + echo "Checking for pending migrations..." MIGRATION_INFO=$(dotnet ef migrations list \ --project ${{ inputs.relative_project_path }} \ @@ -102,12 +115,12 @@ jobs: --connection "$CONNECTION_STRING" \ --no-build \ --json) - + MIGRATION_JSON=$(echo "$MIGRATION_INFO" | sed -n '/^[{[]/,$p') PENDING_MIGRATIONS_JSON=$(echo "$MIGRATION_JSON" | jq '[.[] | select(.applied == false)]') PENDING_MIGRATIONS_COUNT=$(echo "$PENDING_MIGRATIONS_JSON" | jq '. | length') LAST_APPLIED_MIGRATION=$(echo "$MIGRATION_JSON" | jq -r '[.[] | select(.applied == true) | .id] | sort | last // "0"') - + if [ "$PENDING_MIGRATIONS_COUNT" -gt "0" ]; then LAST_PENDING_MIGRATION=$(echo "$PENDING_MIGRATIONS_JSON" | jq -r '.[-1].id') echo "$PENDING_MIGRATIONS_COUNT pending migration(s) detected:" @@ -122,13 +135,13 @@ jobs: --idempotent \ --no-build \ --output migration.sql - + echo "has_migrations_to_apply=true" >> $GITHUB_OUTPUT - + echo "migration_script<> $GITHUB_OUTPUT cat migration.sql >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - + echo "migration_json<> $GITHUB_OUTPUT echo "$PENDING_MIGRATIONS_JSON" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT @@ -165,22 +178,22 @@ jobs: with: script: | const migrationJson = JSON.parse(process.env.MIGRATION_JSON); - + const migrationsList = migrationJson.map(m => `- ${m.name} (${m.id})`).join('\n'); - + const migrationInfo = `## Approve Database Migration \`${{ inputs.database_name }}\` database on \`${{ inputs.azure_environment }}\` - + The following pending migration(s) will be applied to the database when approved: ${migrationsList} - + ### Migration Script \`\`\`sql ${process.env.MIGRATION_SCRIPT} \`\`\` `; - + console.log(migrationInfo); - + core.setOutput('markdown', migrationInfo); - name: Add Migration Information to Pull Request diff --git a/.github/workflows/account.yml b/.github/workflows/account.yml index ad54332a29..04a4ad7397 100644 --- a/.github/workflows/account.yml +++ b/.github/workflows/account.yml @@ -12,7 +12,6 @@ on: - ".github/workflows/account.yml" - ".github/workflows/_deploy-container.yml" - ".github/workflows/_migrate-database.yml" - - ".github/workflows/_preview-migrations.yml" - "!**.md" pull_request: paths: @@ -23,10 +22,13 @@ on: - ".github/workflows/account.yml" - ".github/workflows/_deploy-container.yml" - ".github/workflows/_migrate-database.yml" - - ".github/workflows/_preview-migrations.yml" - "!**.md" workflow_dispatch: +concurrency: + group: account-${{ github.ref }} + cancel-in-progress: true + permissions: id-token: write contents: read @@ -40,10 +42,28 @@ jobs: version: ${{ steps.generate_version.outputs.version }} deploy_staging: ${{ steps.determine_deployment.outputs.deploy_staging }} deploy_production: ${{ steps.determine_deployment.outputs.deploy_production }} + migrations_changed: ${{ steps.detect_migrations.outputs.migrations_changed }} steps: - name: Checkout Code uses: actions/checkout@v6 + with: + # Full history is required to diff base..head for migration-change detection. + fetch-depth: 0 + + - name: Detect Migration Changes + id: detect_migrations + env: + BASE_REF: ${{ github.event.pull_request.base.sha || format('{0}~1', github.sha) }} + HEAD_REF: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + if git diff --name-only "$BASE_REF" "$HEAD_REF" | grep -q "^application/account/Core/Database/Migrations/"; then + echo "migrations_changed=true" >> $GITHUB_OUTPUT + echo "Migration changes detected — Database Staging will run." + else + echo "migrations_changed=false" >> $GITHUB_OUTPUT + echo "No migration changes — Database Staging will be skipped on pull requests." + fi - name: Generate Version id: generate_version @@ -93,32 +113,25 @@ jobs: # Generate a 512-bit key and set it as a user secret that can be use for token signing when running tests dotnet user-secrets set "authentication-token-signing-key" "$(openssl rand -base64 64)" --id $USER_SECRETS_ID - - name: Setup Java JDK for SonarScanner - uses: actions/setup-java@v5 - with: - distribution: "microsoft" - java-version: "17" - - name: Build Email Templates working-directory: application run: npx turbo run build --filter=@repo/emails - - name: Run Tests with SonarScanner Analysis + - name: Run Tests working-directory: application - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} run: | - if [[ "${{ vars.SONAR_PROJECT_KEY }}" == "" ]] || [[ "${{ vars.SONAR_ORGANIZATION }}" == "" ]] || [[ "${{ secrets.SONAR_TOKEN }}" == "" ]]; then - echo "SonarCloud is not enabled. Skipping SonarCloud analysis." - dotnet build account/Account.slnf --no-restore /p:Version=${{ steps.generate_version.outputs.version }} /p:DeploymentCommitHash=${{ github.event.pull_request.head.sha || github.sha }} /p:DeploymentGithubActionId=${{ github.run_id }} && - dotnet test account/Account.slnf --no-build - else - dotnet sonarscanner begin /k:"${{ vars.SONAR_PROJECT_KEY }}" /o:"${{ vars.SONAR_ORGANIZATION }}" /d:sonar.host.url="https://sonarcloud.io" && - dotnet build account/Account.slnf --no-restore /p:Version=${{ steps.generate_version.outputs.version }} /p:DeploymentCommitHash=${{ github.event.pull_request.head.sha || github.sha }} /p:DeploymentGithubActionId=${{ github.run_id }} && - dotnet test account/Account.slnf --no-build && - dotnet sonarscanner end - fi + dotnet build account/Account.slnf --no-restore /p:Version=${{ steps.generate_version.outputs.version }} /p:DeploymentCommitHash=${{ github.event.pull_request.head.sha || github.sha }} /p:DeploymentGithubActionId=${{ github.run_id }} && + dotnet test account/Account.slnf --no-build + + - name: Save Backend Build Artifacts for Migration Plan + if: ${{ vars.STAGING_CLUSTER_ENABLED == 'true' && (github.event_name != 'pull_request' || steps.detect_migrations.outputs.migrations_changed == 'true') }} + uses: actions/upload-artifact@v7 + with: + name: account-build + path: | + application/**/bin + application/**/obj + retention-days: 1 - name: Build Frontend Artifacts if: ${{ steps.determine_deployment.outputs.deploy_staging == 'true' }} @@ -139,7 +152,8 @@ jobs: if: ${{ steps.determine_deployment.outputs.deploy_staging == 'true' }} working-directory: application/account run: | - dotnet publish ./Api/Account.Api.csproj --no-restore --configuration Release --output ./Api/publish /p:Version=${{ steps.generate_version.outputs.version }} /p:DeploymentCommitHash=${{ github.event.pull_request.head.sha || github.sha }} /p:DeploymentGithubActionId=${{ github.run_id }} + dotnet publish ./Api/Account.Api.csproj --configuration Release --no-restore --output ./Api/publish /p:Version=${{ steps.generate_version.outputs.version }} /p:DeploymentCommitHash=${{ github.event.pull_request.head.sha || github.sha }} /p:DeploymentGithubActionId=${{ github.run_id }} + - name: Save API Artifacts if: ${{ steps.determine_deployment.outputs.deploy_staging == 'true' }} uses: actions/upload-artifact@v7 @@ -151,7 +165,8 @@ jobs: if: ${{ steps.determine_deployment.outputs.deploy_staging == 'true' }} working-directory: application/account run: | - dotnet publish ./Workers/Account.Workers.csproj --no-restore --configuration Release --output ./Workers/publish /p:Version=${{ steps.generate_version.outputs.version }} /p:DeploymentCommitHash=${{ github.event.pull_request.head.sha || github.sha }} /p:DeploymentGithubActionId=${{ github.run_id }} + dotnet publish ./Workers/Account.Workers.csproj --configuration Release --no-restore --output ./Workers/publish /p:Version=${{ steps.generate_version.outputs.version }} /p:DeploymentCommitHash=${{ github.event.pull_request.head.sha || github.sha }} /p:DeploymentGithubActionId=${{ github.run_id }} + - name: Save Workers Artifacts if: ${{ steps.determine_deployment.outputs.deploy_staging == 'true' }} uses: actions/upload-artifact@v7 @@ -159,84 +174,9 @@ jobs: name: account-workers path: application/account/Workers/publish/**/* - code-style-and-linting: - name: Code Style and Linting - if: github.event_name == 'pull_request' - runs-on: ubuntu-24.04 - - steps: - - name: Checkout Code - uses: actions/checkout@v6 - - - name: Setup Node.js Environment - uses: actions/setup-node@v6 - with: - node-version: 24 - - - name: Install Node Modules - working-directory: application - run: npm ci - - - name: Setup .NET Core SDK - uses: actions/setup-dotnet@v5 - with: - global-json-file: application/global.json - - - name: Restore .NET Tools - working-directory: application - run: dotnet tool restore - - - name: Restore .NET Dependencies - working-directory: application - run: dotnet restore - - - name: Build Backend Solution - working-directory: application - run: dotnet build account/Account.slnf --no-restore - - - name: Run Code Linting - working-directory: developer-cli - run: | - dotnet run lint --backend --self-contained-system account | tee lint-output.log - - if ! grep -q "No backend issues found!" lint-output.log; then - echo "Code linting issues found." - exit 1 - fi - - - name: Check for Code Formatting Issues - working-directory: developer-cli - run: | - dotnet run format --backend --self-contained-system account - - # Check for any changes made by the code formatter - git diff --exit-code || { - echo "Formatting issues detected. Please run 'dotnet run format --backend --self-contained-system account' from /developer-cli folder locally and commit the formatted code." - exit 1 - } - - - name: Build Frontend Artifacts - working-directory: application - run: npm run build - - - name: Run Lint - working-directory: developer-cli - run: dotnet run -- lint --frontend - - - name: Run Format - working-directory: developer-cli - run: | - dotnet run -- format --frontend - - # Check for any changes made by the code formatter - git diff --exit-code || { - echo "Formatting issues detected. Please run 'dotnet run --project developer-cli -- format --frontend' locally and commit the formatted code." - exit 1 - } - database-migrations-stage: name: Database Staging - if: ${{ vars.STAGING_CLUSTER_ENABLED == 'true' }} + if: ${{ vars.STAGING_CLUSTER_ENABLED == 'true' && (github.event_name != 'pull_request' || needs.build-and-test.outputs.migrations_changed == 'true') }} needs: build-and-test uses: ./.github/workflows/_migrate-database.yml secrets: inherit @@ -250,6 +190,7 @@ jobs: relative_startup_project: account/Api/Account.Api.csproj db_context: AccountDbContext apply_migrations: ${{ needs.build-and-test.outputs.deploy_staging == 'true' }} + build_artifact_name: account-build api-stage: name: API Staging @@ -322,6 +263,7 @@ jobs: relative_startup_project: account/Api/Account.Api.csproj db_context: AccountDbContext apply_migrations: true + build_artifact_name: account-build api-prod1: name: API Production diff --git a/.github/workflows/app-gateway.yml b/.github/workflows/app-gateway.yml index c9eec8c347..3b816b5e8e 100644 --- a/.github/workflows/app-gateway.yml +++ b/.github/workflows/app-gateway.yml @@ -21,6 +21,10 @@ on: - "!**.md" workflow_dispatch: +concurrency: + group: app-gateway-${{ github.ref }} + cancel-in-progress: true + permissions: id-token: write contents: read @@ -51,7 +55,7 @@ jobs: run: | deploy_staging="${{ github.ref == 'refs/heads/main' && vars.STAGING_CLUSTER_ENABLED == 'true' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'Deploy to Staging')) }}" echo "deploy_staging=$deploy_staging" >> $GITHUB_OUTPUT - + deploy_production="${{ github.ref == 'refs/heads/main' && vars.PRODUCTION_CLUSTER1_ENABLED == 'true' }}" echo "deploy_production=$deploy_production" >> $GITHUB_OUTPUT @@ -77,16 +81,33 @@ jobs: working-directory: application run: dotnet restore + - name: Generate and Set User Secret for Token Signing Key + working-directory: application/shared-kernel/SharedKernel + run: | + # Extract UserSecretsId from the .csproj file + USER_SECRETS_ID=$(grep -oP '(?<=).*?(?=)' SharedKernel.csproj) + + # Generate a 512-bit key and set it as a user secret that can be use for token signing when running tests + dotnet user-secrets set "authentication-token-signing-key" "$(openssl rand -base64 64)" --id $USER_SECRETS_ID + + - name: Run Tests + working-directory: application + run: | + dotnet build AppGateway.Tests/AppGateway.Tests.csproj --no-restore && + dotnet test AppGateway.Tests/AppGateway.Tests.csproj --no-build + - name: Build Backend Solution if: ${{ steps.determine_deployment.outputs.deploy_staging == 'true' }} working-directory: application run: | dotnet build PlatformPlatform.slnx --no-restore /p:Version=${{ steps.generate_version.outputs.version }} /p:DeploymentCommitHash=${{ github.event.pull_request.head.sha || github.sha }} /p:DeploymentGithubActionId=${{ github.run_id }} + - name: Publish Build if: ${{ steps.determine_deployment.outputs.deploy_staging == 'true' }} working-directory: application run: | - dotnet publish ./AppGateway/AppGateway.csproj --no-restore --configuration Release --output ./AppGateway/publish /p:Version=${{ steps.generate_version.outputs.version }} /p:DeploymentCommitHash=${{ github.event.pull_request.head.sha || github.sha }} /p:DeploymentGithubActionId=${{ github.run_id }} + dotnet publish ./AppGateway/AppGateway.csproj --configuration Release --no-restore --output ./AppGateway/publish /p:Version=${{ steps.generate_version.outputs.version }} /p:DeploymentCommitHash=${{ github.event.pull_request.head.sha || github.sha }} /p:DeploymentGithubActionId=${{ github.run_id }} + - name: Save Artifacts if: ${{ steps.determine_deployment.outputs.deploy_staging == 'true' }} uses: actions/upload-artifact@v7 @@ -94,62 +115,6 @@ jobs: name: app-gateway path: application/AppGateway/publish/**/* - code-style-and-linting: - name: Code Style and Linting - if: github.event_name == 'pull_request' - runs-on: ubuntu-24.04 - - steps: - - name: Checkout Code - uses: actions/checkout@v6 - - - name: Setup Node.js Environment - uses: actions/setup-node@v6 - with: - node-version: 24 - - - name: Install Node Modules - working-directory: application - run: npm ci - - - name: Setup .NET Core SDK - uses: actions/setup-dotnet@v5 - with: - global-json-file: application/global.json - - - name: Restore .NET Tools - working-directory: application - run: dotnet tool restore - - - name: Restore .NET Dependencies - working-directory: application - run: dotnet restore - - - name: Build Backend Solution - working-directory: application - run: dotnet build PlatformPlatform.slnx --no-restore - - - name: Run Code Linting - working-directory: developer-cli - run: | - dotnet run lint --backend | tee lint-output.log - - if ! grep -q "No backend issues found!" lint-output.log; then - echo "Code linting issues found." - exit 1 - fi - - - name: Check for Code Formatting Issues - working-directory: developer-cli - run: | - dotnet run format --backend - - # Check for any changes made by the code formatter - git diff --exit-code || { - echo "Formatting issues detected. Please run 'dotnet run format --backend' from /developer-cli folder locally and commit the formatted code." - exit 1 - } - api-stage: name: Staging if: ${{ needs.build-and-test.outputs.deploy_staging == 'true' }} diff --git a/.github/workflows/cloud-infrastructure.yml b/.github/workflows/cloud-infrastructure.yml index 31629956cd..5b737aea3d 100644 --- a/.github/workflows/cloud-infrastructure.yml +++ b/.github/workflows/cloud-infrastructure.yml @@ -17,6 +17,10 @@ on: - "!**.md" workflow_dispatch: +concurrency: + group: cloud-infrastructure-${{ github.ref }} + cancel-in-progress: true + permissions: id-token: write contents: read diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml new file mode 100644 index 0000000000..30fb2a1653 --- /dev/null +++ b/.github/workflows/code-style.yml @@ -0,0 +1,307 @@ +name: Code Style + +on: + push: + branches: + - main + paths: + - "application/**" + - "developer-cli/**" + - ".github/workflows/code-style.yml" + - "!**.md" + pull_request: + paths: + - "application/**" + - "developer-cli/**" + - ".github/workflows/code-style.yml" + - "!**.md" + workflow_dispatch: + +concurrency: + group: code-style-${{ github.ref }} + cancel-in-progress: true + +permissions: + id-token: write + contents: read + pull-requests: write + +jobs: + detect-scope: + name: Detect Scope + runs-on: ubuntu-24.04 + outputs: + backend_scope: ${{ steps.scope.outputs.backend_scope }} + backend_label: ${{ steps.scope.outputs.backend_label }} + format_all_files_flag: ${{ steps.scope.outputs.format_all_files_flag }} + format_label: ${{ steps.scope.outputs.format_label }} + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + # Need history to diff against the base ref + fetch-depth: 0 + + - name: Resolve Backend Scope + id: scope + env: + BASE_REF: ${{ github.event.pull_request.base.sha || format('{0}~1', github.sha) }} + HEAD_REF: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + # Run scoped commands only when exactly one SCS's backend changed and nothing shared. + # Anything shared (shared-kernel, AppGateway, root, or developer-cli) forces a full pass, + # since per-SCS slnf scopes don't cover those projects. + changed=$(git diff --name-only "$BASE_REF" "$HEAD_REF") + + account=false + main=false + other=false + + while IFS= read -r path; do + case "$path" in + application/account/Api/*|application/account/Core/*|application/account/Workers/*|application/account/Tests/*) + account=true ;; + application/main/Api/*|application/main/Core/*|application/main/Workers/*|application/main/Tests/*) + main=true ;; + application/AppGateway/*|application/shared-kernel/*|developer-cli/*) + other=true ;; + application/*) + # Files directly under application/ (slnx, package.json, etc.) — treat as shared. + # Subdirectories matched the cases above. + other=true ;; + esac + done <<< "$changed" + + if [[ "$other" == "true" ]] || { [[ "$account" == "true" ]] && [[ "$main" == "true" ]]; }; then + echo "backend_scope=" >> "$GITHUB_OUTPUT" + echo "backend_label=full backend (PlatformPlatform.slnx)" >> "$GITHUB_OUTPUT" + elif [[ "$account" == "true" ]]; then + echo "backend_scope=--self-contained-system account" >> "$GITHUB_OUTPUT" + echo "backend_label=account only" >> "$GITHUB_OUTPUT" + elif [[ "$main" == "true" ]]; then + echo "backend_scope=--self-contained-system main" >> "$GITHUB_OUTPUT" + echo "backend_label=main only" >> "$GITHUB_OUTPUT" + else + echo "backend_scope=" >> "$GITHUB_OUTPUT" + echo "backend_label=full backend (default — no backend paths matched)" >> "$GITHUB_OUTPUT" + fi + + # Force --all-files when the JetBrains tool manifest moves. Tool upgrades change the + # inspectcode + cleanupcode rule sets, so latent issues in untouched files need to be + # caught in the same PR. Otherwise the next PR that happens to touch one of those files + # fails CI on issues it didn't introduce. + if echo "$changed" | grep -q '^application/dotnet-tools.json$'; then + echo "format_all_files_flag=--all-files" >> "$GITHUB_OUTPUT" + echo "format_label=all files (JetBrains tools upgraded)" >> "$GITHUB_OUTPUT" + else + echo "format_all_files_flag=" >> "$GITHUB_OUTPUT" + echo "format_label=changed .cs files only" >> "$GITHUB_OUTPUT" + fi + + - name: Print Scope + run: | + echo "Backend scope - ${{ steps.scope.outputs.backend_label }}" + echo "Format mode - ${{ steps.scope.outputs.format_label }}" + + code-linting: + name: Code Linting + needs: detect-scope + runs-on: ubuntu-24.04 + + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Setup Node.js Environment + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install Node Modules + working-directory: application + run: npm ci + + - name: Setup .NET Core SDK + uses: actions/setup-dotnet@v5 + with: + global-json-file: application/global.json + + - name: Restore .NET Tools + working-directory: application + run: dotnet tool restore + + - name: Restore .NET Dependencies + working-directory: application + run: dotnet restore + + - name: Build Backend Solution + working-directory: developer-cli + run: dotnet run -- build --backend ${{ needs.detect-scope.outputs.backend_scope }} --quiet + + - name: Run Backend Linting + working-directory: developer-cli + run: | + set +e + dotnet run lint --backend ${{ needs.detect-scope.outputs.backend_scope }} --no-build + LINT_EXIT_CODE=$? + set -e + + if [ $LINT_EXIT_CODE -ne 0 ]; then + echo "" + echo "::error::Code linting issues found." + echo "" + + RESULT_FILE=$(find ${GITHUB_WORKSPACE}/application -maxdepth 2 -name result.json -type f 2>/dev/null | head -n 1) + + if [ -n "$RESULT_FILE" ] && [ -f "$RESULT_FILE" ]; then + cat "$RESULT_FILE" + echo "" + echo "### Code Inspection Issues" >> $GITHUB_STEP_SUMMARY + echo '```json' >> $GITHUB_STEP_SUMMARY + cat "$RESULT_FILE" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + else + echo "Result file not found in application/" + fi + exit 1 + fi + + - name: Build Frontend Artifacts + working-directory: application + run: npm run build + + - name: Run Frontend Linting + working-directory: developer-cli + run: dotnet run -- lint --frontend + + code-formatting: + name: Code Formatting + needs: detect-scope + runs-on: ubuntu-24.04 + + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Setup Node.js Environment + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install Node Modules + working-directory: application + run: npm ci + + - name: Setup .NET Core SDK + uses: actions/setup-dotnet@v5 + with: + global-json-file: application/global.json + + - name: Restore .NET Tools + working-directory: application + run: dotnet tool restore + + - name: Restore .NET Dependencies + working-directory: application + run: dotnet restore + + - name: Build Backend Solution + working-directory: developer-cli + run: dotnet run -- build --backend ${{ needs.detect-scope.outputs.backend_scope }} --quiet + + - name: Check for Backend Formatting Issues + working-directory: developer-cli + run: | + dotnet run format --backend ${{ needs.detect-scope.outputs.backend_scope }} ${{ needs.detect-scope.outputs.format_all_files_flag }} --no-build + + git diff --exit-code || { + echo "Formatting issues detected. Please run 'dotnet run --project developer-cli -- format --backend ${{ needs.detect-scope.outputs.backend_scope }} ${{ needs.detect-scope.outputs.format_all_files_flag }}' locally and commit the formatted code." + exit 1 + } + + - name: Build Frontend Artifacts + working-directory: application + run: npm run build + + - name: Check for Frontend Formatting Issues + working-directory: developer-cli + run: | + dotnet run -- format --frontend + + git diff --exit-code || { + echo "Formatting issues detected. Please run 'dotnet run --project developer-cli -- format --frontend' locally and commit the formatted code." + exit 1 + } + + sonarcloud: + name: SonarCloud Analysis + needs: detect-scope + runs-on: ubuntu-24.04 + + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Setup Node.js Environment + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install Node Modules + working-directory: application + run: npm ci + + - name: Setup .NET Core SDK + uses: actions/setup-dotnet@v5 + with: + global-json-file: application/global.json + + - name: Restore .NET Tools + working-directory: application + run: dotnet tool restore + + - name: Restore .NET Dependencies + working-directory: application + run: dotnet restore + + - name: Generate and Set User Secret for Token Signing Key + working-directory: application/shared-kernel/SharedKernel + run: | + # Extract UserSecretsId from the .csproj file + USER_SECRETS_ID=$(grep -oP '(?<=).*?(?=)' SharedKernel.csproj) + + # Generate a 512-bit key and set it as a user secret that can be use for token signing when running tests + dotnet user-secrets set "authentication-token-signing-key" "$(openssl rand -base64 64)" --id $USER_SECRETS_ID + + - name: Setup Java JDK for SonarScanner + uses: actions/setup-java@v5 + with: + distribution: "microsoft" + java-version: "17" + + - name: Build Email Templates + working-directory: application + run: npx turbo run build --filter=@repo/emails + + - name: Run SonarCloud Analysis + working-directory: application + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + if [[ "${{ vars.SONAR_PROJECT_KEY }}" == "" ]] || [[ "${{ vars.SONAR_ORGANIZATION }}" == "" ]] || [[ "${{ secrets.SONAR_TOKEN }}" == "" ]]; then + echo "SonarCloud is not enabled. Skipping SonarCloud analysis." + exit 0 + fi + + # Run test assemblies sequentially via per-SCS slnf files. Running `dotnet test + # PlatformPlatform.slnx` parallelizes test assemblies across processes, which races on + # PortAllocation.Load() (`.workspace/port.txt` half-created between O_CREAT and write). + # AppGateway.Tests is covered by app-gateway.yml. SharedKernel.Tests is included in both + # slnf files and will run twice; that is acceptable. + dotnet sonarscanner begin /k:"${{ vars.SONAR_PROJECT_KEY }}" /o:"${{ vars.SONAR_ORGANIZATION }}" /d:sonar.host.url="https://sonarcloud.io" && + dotnet build PlatformPlatform.slnx --no-restore && + dotnet test account/Account.slnf --no-build && + dotnet test main/Main.slnf --no-build && + dotnet sonarscanner end diff --git a/.github/workflows/developer-cli.yml b/.github/workflows/developer-cli.yml index ab4775a3dc..f585a8c2f1 100644 --- a/.github/workflows/developer-cli.yml +++ b/.github/workflows/developer-cli.yml @@ -15,6 +15,10 @@ on: - "!**.md" workflow_dispatch: +concurrency: + group: developer-cli-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -51,10 +55,29 @@ jobs: - name: Run Code Linting working-directory: developer-cli run: | - dotnet run --no-build -- lint --cli --no-build | tee lint-output.log + set +e + dotnet run --no-build -- lint --cli --no-build + LINT_EXIT_CODE=$? + set -e + + if [ $LINT_EXIT_CODE -ne 0 ]; then + echo "" + echo "::error::Code linting issues found." + echo "" + + RESULT_FILE="result.json" - if ! grep -q "No developer-cli issues found!" lint-output.log; then - echo "Code linting issues found." + if [ -f "$RESULT_FILE" ]; then + cat "$RESULT_FILE" + echo "" + echo "### Code Inspection Issues" >> $GITHUB_STEP_SUMMARY + echo '```json' >> $GITHUB_STEP_SUMMARY + cat "$RESULT_FILE" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + else + echo "Result file not found in developer-cli/" + fi exit 1 fi diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2939359338..f8c0f0c999 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,7 +12,6 @@ on: - ".github/workflows/main.yml" - ".github/workflows/_deploy-container.yml" - ".github/workflows/_migrate-database.yml" - - ".github/workflows/_preview-migrations.yml" - "!**.md" pull_request: paths: @@ -23,10 +22,13 @@ on: - ".github/workflows/main.yml" - ".github/workflows/_deploy-container.yml" - ".github/workflows/_migrate-database.yml" - - ".github/workflows/_preview-migrations.yml" - "!**.md" workflow_dispatch: +concurrency: + group: main-${{ github.ref }} + cancel-in-progress: true + permissions: id-token: write contents: read @@ -40,10 +42,28 @@ jobs: version: ${{ steps.generate_version.outputs.version }} deploy_staging: ${{ steps.determine_deployment.outputs.deploy_staging }} deploy_production: ${{ steps.determine_deployment.outputs.deploy_production }} + migrations_changed: ${{ steps.detect_migrations.outputs.migrations_changed }} steps: - name: Checkout Code uses: actions/checkout@v6 + with: + # Full history is required to diff base..head for migration-change detection. + fetch-depth: 0 + + - name: Detect Migration Changes + id: detect_migrations + env: + BASE_REF: ${{ github.event.pull_request.base.sha || format('{0}~1', github.sha) }} + HEAD_REF: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + if git diff --name-only "$BASE_REF" "$HEAD_REF" | grep -q "^application/main/Core/Database/Migrations/"; then + echo "migrations_changed=true" >> $GITHUB_OUTPUT + echo "Migration changes detected — Database Staging will run." + else + echo "migrations_changed=false" >> $GITHUB_OUTPUT + echo "No migration changes — Database Staging will be skipped on pull requests." + fi - name: Generate Version id: generate_version @@ -89,36 +109,29 @@ jobs: run: | # Extract UserSecretsId from the .csproj file USER_SECRETS_ID=$(grep -oP '(?<=).*?(?=)' SharedKernel.csproj) - + # Generate a 512-bit key and set it as a user secret that can be use for token signing when running tests dotnet user-secrets set "authentication-token-signing-key" "$(openssl rand -base64 64)" --id $USER_SECRETS_ID - - name: Setup Java JDK for SonarScanner - uses: actions/setup-java@v5 - with: - distribution: "microsoft" - java-version: "17" - - name: Build Email Templates working-directory: application run: npx turbo run build --filter=@repo/emails - - name: Run Tests with SonarScanner Analysis + - name: Run Tests working-directory: application - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} run: | - if [[ "${{ vars.SONAR_PROJECT_KEY }}" == "" ]] || [[ "${{ vars.SONAR_ORGANIZATION }}" == "" ]] || [[ "${{ secrets.SONAR_TOKEN }}" == "" ]]; then - echo "SonarCloud is not enabled. Skipping SonarCloud analysis." - dotnet build main/Main.slnf --no-restore /p:Version=${{ steps.generate_version.outputs.version }} /p:DeploymentCommitHash=${{ github.event.pull_request.head.sha || github.sha }} /p:DeploymentGithubActionId=${{ github.run_id }} && - dotnet test main/Main.slnf --no-build - else - dotnet sonarscanner begin /k:"${{ vars.SONAR_PROJECT_KEY }}" /o:"${{ vars.SONAR_ORGANIZATION }}" /d:sonar.host.url="https://sonarcloud.io" && - dotnet build main/Main.slnf --no-restore /p:Version=${{ steps.generate_version.outputs.version }} /p:DeploymentCommitHash=${{ github.event.pull_request.head.sha || github.sha }} /p:DeploymentGithubActionId=${{ github.run_id }} && - dotnet test main/Main.slnf --no-build && - dotnet sonarscanner end - fi + dotnet build main/Main.slnf --no-restore /p:Version=${{ steps.generate_version.outputs.version }} /p:DeploymentCommitHash=${{ github.event.pull_request.head.sha || github.sha }} /p:DeploymentGithubActionId=${{ github.run_id }} && + dotnet test main/Main.slnf --no-build + + - name: Save Backend Build Artifacts for Migration Plan + if: ${{ vars.STAGING_CLUSTER_ENABLED == 'true' && (github.event_name != 'pull_request' || steps.detect_migrations.outputs.migrations_changed == 'true') }} + uses: actions/upload-artifact@v7 + with: + name: main-build + path: | + application/**/bin + application/**/obj + retention-days: 1 - name: Build Frontend Artifacts if: ${{ steps.determine_deployment.outputs.deploy_staging == 'true' }} @@ -134,7 +147,8 @@ jobs: if: ${{ steps.determine_deployment.outputs.deploy_staging == 'true' }} working-directory: application/main run: | - dotnet publish ./Api/Main.Api.csproj --no-restore --configuration Release --output ./Api/publish /p:Version=${{ steps.generate_version.outputs.version }} /p:DeploymentCommitHash=${{ github.event.pull_request.head.sha || github.sha }} /p:DeploymentGithubActionId=${{ github.run_id }} + dotnet publish ./Api/Main.Api.csproj --configuration Release --no-restore --output ./Api/publish /p:Version=${{ steps.generate_version.outputs.version }} /p:DeploymentCommitHash=${{ github.event.pull_request.head.sha || github.sha }} /p:DeploymentGithubActionId=${{ github.run_id }} + - name: Save API Artifacts if: ${{ steps.determine_deployment.outputs.deploy_staging == 'true' }} uses: actions/upload-artifact@v7 @@ -146,7 +160,8 @@ jobs: if: ${{ steps.determine_deployment.outputs.deploy_staging == 'true' }} working-directory: application/main run: | - dotnet publish ./Workers/Main.Workers.csproj --no-restore --configuration Release --output ./Workers/publish /p:Version=${{ steps.generate_version.outputs.version }} /p:DeploymentCommitHash=${{ github.event.pull_request.head.sha || github.sha }} /p:DeploymentGithubActionId=${{ github.run_id }} + dotnet publish ./Workers/Main.Workers.csproj --configuration Release --no-restore --output ./Workers/publish /p:Version=${{ steps.generate_version.outputs.version }} /p:DeploymentCommitHash=${{ github.event.pull_request.head.sha || github.sha }} /p:DeploymentGithubActionId=${{ github.run_id }} + - name: Save Workers Artifacts if: ${{ steps.determine_deployment.outputs.deploy_staging == 'true' }} uses: actions/upload-artifact@v7 @@ -154,84 +169,9 @@ jobs: name: main-workers path: application/main/Workers/publish/**/* - code-style-and-linting: - name: Code Style and Linting - if: github.event_name == 'pull_request' - runs-on: ubuntu-24.04 - - steps: - - name: Checkout Code - uses: actions/checkout@v6 - - - name: Setup Node.js Environment - uses: actions/setup-node@v6 - with: - node-version: 24 - - - name: Install Node Modules - working-directory: application - run: npm ci - - - name: Setup .NET Core SDK - uses: actions/setup-dotnet@v5 - with: - global-json-file: application/global.json - - - name: Restore .NET Tools - working-directory: application - run: dotnet tool restore - - - name: Restore .NET Dependencies - working-directory: application - run: dotnet restore - - - name: Build Backend Solution - working-directory: application - run: dotnet build main/Main.slnf --no-restore - - - name: Run Code Linting - working-directory: developer-cli - run: | - dotnet run lint --backend --self-contained-system main | tee lint-output.log - - if ! grep -q "No backend issues found!" lint-output.log; then - echo "Code linting issues found." - exit 1 - fi - - - name: Check for Code Formatting Issues - working-directory: developer-cli - run: | - dotnet run format --backend --self-contained-system main - - # Check for any changes made by the code formatter - git diff --exit-code || { - echo "Formatting issues detected. Please run 'dotnet run format --backend --self-contained-system main' from /developer-cli folder locally and commit the formatted code." - exit 1 - } - - - name: Build Frontend Artifacts - working-directory: application - run: npm run build - - - name: Run Lint - working-directory: developer-cli - run: dotnet run -- lint --frontend - - - name: Run Format - working-directory: developer-cli - run: | - dotnet run -- format --frontend - - # Check for any changes made by the code formatter - git diff --exit-code || { - echo "Formatting issues detected. Please run 'dotnet run --project developer-cli -- format --frontend' locally and commit the formatted code." - exit 1 - } - database-migrations-stage: name: Database Staging - if: ${{ vars.STAGING_CLUSTER_ENABLED == 'true' }} + if: ${{ vars.STAGING_CLUSTER_ENABLED == 'true' && (github.event_name != 'pull_request' || needs.build-and-test.outputs.migrations_changed == 'true') }} needs: build-and-test uses: ./.github/workflows/_migrate-database.yml secrets: inherit @@ -245,6 +185,7 @@ jobs: relative_startup_project: main/Api/Main.Api.csproj db_context: MainDbContext apply_migrations: ${{ needs.build-and-test.outputs.deploy_staging == 'true' }} + build_artifact_name: main-build api-stage: name: API Staging @@ -298,6 +239,7 @@ jobs: relative_startup_project: main/Api/Main.Api.csproj db_context: MainDbContext apply_migrations: true + build_artifact_name: main-build api-prod1: name: API Production diff --git a/.github/workflows/pull-request-conventions.yml b/.github/workflows/pull-request-conventions.yml index beff18c4e9..1671357658 100644 --- a/.github/workflows/pull-request-conventions.yml +++ b/.github/workflows/pull-request-conventions.yml @@ -4,6 +4,10 @@ on: pull_request: types: [opened, synchronize, reopened, edited, labeled, unlabeled] +concurrency: + group: pull-request-conventions-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read pull-requests: write diff --git a/application/account/Tests/AccountTestContext.cs b/application/account/Tests/AccountTestContext.cs new file mode 100644 index 0000000000..76f6c8d3ef --- /dev/null +++ b/application/account/Tests/AccountTestContext.cs @@ -0,0 +1,20 @@ +using Account.Integrations.Stripe; +using Microsoft.Data.Sqlite; +using SharedKernel.Integrations.Email; +using SharedKernel.Tests.Telemetry; + +namespace Account.Tests; + +// Per-test state surfaced to the shared AccountWebApplicationFactory via AsyncLocal so that each +// test sees its own database, telemetry collector, email client substitute, and Stripe state while +// the host stays shared across the test class. +public sealed class AccountTestContext +{ + public required SqliteConnection Connection { get; init; } + + public required TelemetryEventsCollectorSpy TelemetryCollector { get; init; } + + public required IEmailClient EmailClient { get; init; } + + public required MockStripeState StripeState { get; init; } +} diff --git a/application/account/Tests/AccountWebApplicationFactory.cs b/application/account/Tests/AccountWebApplicationFactory.cs new file mode 100644 index 0000000000..0b02ebca01 --- /dev/null +++ b/application/account/Tests/AccountWebApplicationFactory.cs @@ -0,0 +1,136 @@ +using Account.Database; +using Account.Integrations.Stripe; +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.Channel; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using NSubstitute; +using SharedKernel.ExecutionContext; +using SharedKernel.Integrations.Email; +using SharedKernel.SinglePageApp; +using SharedKernel.Telemetry; + +namespace Account.Tests; + +// Shared host for all Account API endpoint tests in a class. Constructed once via xUnit's +// IClassFixture, the host wires its DbContext, telemetry collector, email client, and Stripe +// state to a per-test AccountTestContext stored in an AsyncLocal — so the same host can serve +// every test in the class while each test still has isolated state. +public class AccountWebApplicationFactory : WebApplicationFactory +{ + // Tests use the in-memory test server (WebApplicationFactory); no real listener is bound. + // SinglePageAppConfiguration only consumes this as a URI for CSP construction. + private const string TestPublicUrl = "https://localhost"; + + private readonly AsyncLocal _currentContext = new(); + + public AccountWebApplicationFactory() + { + Environment.SetEnvironmentVariable(SinglePageAppConfiguration.PublicUrlKey, TestPublicUrl); + Environment.SetEnvironmentVariable(SinglePageAppConfiguration.CdnUrlKey, $"{TestPublicUrl}/account"); + Environment.SetEnvironmentVariable( + "APPLICATIONINSIGHTS_CONNECTION_STRING", + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://localhost;LiveEndpoint=https://localhost" + ); + Environment.SetEnvironmentVariable("Stripe__AllowMockProvider", "true"); + Environment.SetEnvironmentVariable("Stripe__PublishableKey", "pk_test_mock_publishable_key"); + // ASP.NET uses a cryptographic double-submit antiforgery pattern that encrypts the user's + // ClaimUid in the token, which is complex to replicate in tests; the middleware honors this + // env var to bypass validation. + Environment.SetEnvironmentVariable("BypassAntiforgeryValidation", "true"); + } + + private AccountTestContext CurrentContext => _currentContext.Value + ?? throw new InvalidOperationException("AccountTestContext is not set. Call BeginTest before resolving services."); + + // Sets the per-test context for the calling logical-call context. The returned scope clears the + // context on Dispose so the AsyncLocal does not leak past the test instance lifetime. + public IDisposable BeginTest(AccountTestContext context) + { + _currentContext.Value = context; + return new TestScope(this); + } + + // TestServer.PreserveExecutionContext defaults to false, which means the calling test's + // ExecutionContext (and therefore the AsyncLocal-stored AccountTestContext) does not flow into + // request handling. Enabling it preserves the flow so per-request DI resolutions see the + // current test's context. + protected override IHost CreateHost(IHostBuilder builder) + { + var host = base.CreateHost(builder); + if (host.Services.GetRequiredService() is TestServer testServer) + { + testServer.PreserveExecutionContext = true; + } + + return host; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureLogging(logging => logging.AddFilter(_ => false)); + + builder.ConfigureAppConfiguration((_, configuration) => + { + // Account-api hosts both the user-facing and back-office SPAs scoped via RequireHost + // on each MapFallback. The TestServer sends requests to "localhost" by default, so + // configure Hostnames:App to match for the user-facing SPA shell. + configuration.AddInMemoryCollection(new Dictionary + { + ["Hostnames:App"] = "localhost" + } + ); + } + ); + + builder.ConfigureTestServices(services => + { + services.Remove(services.Single(d => d.ServiceType == typeof(IDbContextOptionsConfiguration))); + services.AddDbContext((_, options) => options.UseSqlite(CurrentContext.Connection).UseSnakeCaseNamingConvention()); + + services.AddScoped(_ => CurrentContext.TelemetryCollector); + + // Replace the production singleton with a transient that resolves the per-test + // instance from the current AccountTestContext. MockStripeClient is keyed-scoped + // and captures MockStripeState per scope, so this delegates state mutations to the + // active test. + services.RemoveAll(typeof(MockStripeState)); + services.AddTransient(_ => CurrentContext.StripeState); + + services.Remove(services.Single(d => d.ServiceType == typeof(IEmailClient))); + services.AddTransient(_ => CurrentContext.EmailClient); + + services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = Substitute.For() })); + services.AddScoped(); + + ConfigureAdditionalTestServices(services); + } + ); + } + + protected virtual void ConfigureAdditionalTestServices(IServiceCollection services) + { + } + + private void EndTest() + { + _currentContext.Value = null; + } + + private sealed class TestScope(AccountWebApplicationFactory factory) : IDisposable + { + public void Dispose() + { + factory.EndTest(); + } + } +} diff --git a/application/account/Tests/ArchitectureTests/InternalApiRoutesRemovedTests.cs b/application/account/Tests/ArchitectureTests/InternalApiRoutesRemovedTests.cs index ad28db92e2..84ed226a68 100644 --- a/application/account/Tests/ArchitectureTests/InternalApiRoutesRemovedTests.cs +++ b/application/account/Tests/ArchitectureTests/InternalApiRoutesRemovedTests.cs @@ -11,7 +11,7 @@ namespace Account.Tests.ArchitectureTests; // The InternalApiEndpoints_ShouldEitherRequireAuthorizationOrBeOnAllowlist arch test enforces the // auth-or-allowlist invariant going forward; this Theory locks in that the specific routes deleted // by PP-1251 stay deleted (a future contributor cannot accidentally remap them). -public sealed class InternalApiRoutesRemovedTests : EndpointBaseTest +public sealed class InternalApiRoutesRemovedTests(AccountWebApplicationFactory factory) : EndpointBaseTest(factory), IClassFixture { [Theory] [InlineData("GET", "/internal-api/account/feature-flags")] diff --git a/application/account/Tests/Authentication/GetUserSessionsTests.cs b/application/account/Tests/Authentication/GetUserSessionsTests.cs index 20428a5705..324e3d9ff7 100644 --- a/application/account/Tests/Authentication/GetUserSessionsTests.cs +++ b/application/account/Tests/Authentication/GetUserSessionsTests.cs @@ -12,7 +12,7 @@ namespace Account.Tests.Authentication; -public sealed class GetUserSessionsTests : EndpointBaseTest +public sealed class GetUserSessionsTests(AccountWebApplicationFactory factory) : EndpointBaseTest(factory), IClassFixture { [Fact] public async Task GetUserSessions_WhenUserHasSessions_ShouldReturnSessions() diff --git a/application/account/Tests/Authentication/LogoutTests.cs b/application/account/Tests/Authentication/LogoutTests.cs index 6675fa41d8..8e2aff2cc6 100644 --- a/application/account/Tests/Authentication/LogoutTests.cs +++ b/application/account/Tests/Authentication/LogoutTests.cs @@ -9,7 +9,7 @@ namespace Account.Tests.Authentication; -public sealed class LogoutTests : EndpointBaseTest +public sealed class LogoutTests(AccountWebApplicationFactory factory) : EndpointBaseTest(factory), IClassFixture { [Fact] public async Task Logout_WhenAuthenticatedAsOwner_ShouldRevokeSessionAndCollectLogoutEvent() diff --git a/application/account/Tests/Authentication/RefreshAuthenticationTokensTests.cs b/application/account/Tests/Authentication/RefreshAuthenticationTokensTests.cs index 5d8d5796a5..f60e8d94fc 100644 --- a/application/account/Tests/Authentication/RefreshAuthenticationTokensTests.cs +++ b/application/account/Tests/Authentication/RefreshAuthenticationTokensTests.cs @@ -12,11 +12,11 @@ namespace Account.Tests.Authentication; -public sealed class RefreshAuthenticationTokensTests : EndpointBaseTest +public sealed class RefreshAuthenticationTokensTests : EndpointBaseTest, IClassFixture { private readonly RefreshTokenGenerator _refreshTokenGenerator; - public RefreshAuthenticationTokensTests() + public RefreshAuthenticationTokensTests(AccountWebApplicationFactory factory) : base(factory) { using var serviceScope = Provider.CreateScope(); _refreshTokenGenerator = serviceScope.ServiceProvider.GetRequiredService(); diff --git a/application/account/Tests/Authentication/RevokeSessionTests.cs b/application/account/Tests/Authentication/RevokeSessionTests.cs index 1a4ad8c021..a5642711ac 100644 --- a/application/account/Tests/Authentication/RevokeSessionTests.cs +++ b/application/account/Tests/Authentication/RevokeSessionTests.cs @@ -9,7 +9,7 @@ namespace Account.Tests.Authentication; -public sealed class RevokeSessionTests : EndpointBaseTest +public sealed class RevokeSessionTests(AccountWebApplicationFactory factory) : EndpointBaseTest(factory), IClassFixture { [Fact] public async Task RevokeSession_WhenValid_ShouldRevokeSession() diff --git a/application/account/Tests/Authentication/SwitchTenantTests.cs b/application/account/Tests/Authentication/SwitchTenantTests.cs index cb84b62307..0160b1e63b 100644 --- a/application/account/Tests/Authentication/SwitchTenantTests.cs +++ b/application/account/Tests/Authentication/SwitchTenantTests.cs @@ -14,7 +14,7 @@ namespace Account.Tests.Authentication; -public sealed class SwitchTenantTests : EndpointBaseTest +public sealed class SwitchTenantTests(AccountWebApplicationFactory factory) : EndpointBaseTest(factory), IClassFixture { [Fact] public async Task SwitchTenant_WhenUserExistsInTargetTenant_ShouldSwitchSuccessfully() diff --git a/application/account/Tests/BackOffice/AcknowledgeBillingDriftTests.cs b/application/account/Tests/BackOffice/AcknowledgeBillingDriftTests.cs index e98078621f..eb355255fc 100644 --- a/application/account/Tests/BackOffice/AcknowledgeBillingDriftTests.cs +++ b/application/account/Tests/BackOffice/AcknowledgeBillingDriftTests.cs @@ -9,7 +9,7 @@ namespace Account.Tests.BackOffice; -public sealed class AcknowledgeBillingDriftTests : BackOfficeEndpointBaseTest +public sealed class AcknowledgeBillingDriftTests(BackOfficeWebApplicationFactory factory) : BackOfficeEndpointBaseTest(factory), IClassFixture { [Fact] public async Task AcknowledgeBillingDrift_WhenSubscriptionHasDrift_ShouldClearDrift() diff --git a/application/account/Tests/BackOffice/BackOfficeBlobProxyTests.cs b/application/account/Tests/BackOffice/BackOfficeBlobProxyTests.cs index b48f25cd42..fcfa891834 100644 --- a/application/account/Tests/BackOffice/BackOfficeBlobProxyTests.cs +++ b/application/account/Tests/BackOffice/BackOfficeBlobProxyTests.cs @@ -8,9 +8,10 @@ namespace Account.Tests.BackOffice; -public sealed class BackOfficeBlobProxyTests : BackOfficeEndpointBaseTest +public sealed class BackOfficeBlobProxyTests(BackOfficeBlobProxyFactory factory) + : BackOfficeEndpointBaseTest(factory), IClassFixture { - private readonly IBlobStorageClient _blobStorageClient = Substitute.For(); + private readonly IBlobStorageClient _blobStorageClient = factory.BlobStorageClient; [Fact] public async Task BackOfficeBlobProxy_WhenServingBlob_ShouldSetNoSniffHeader() @@ -30,10 +31,18 @@ public async Task BackOfficeBlobProxy_WhenServingBlob_ShouldSetNoSniffHeader() response.StatusCode.Should().Be(HttpStatusCode.OK); response.Headers.GetValues("X-Content-Type-Options").Should().Contain("nosniff"); } +} + +public sealed class BackOfficeBlobProxyFactory : BackOfficeWebApplicationFactory +{ + // Shared across every test in the class (IClassFixture lifetime). If more tests are added, + // call ClearSubstitute() / ClearReceivedCalls() at the top of each so configured behaviours + // and ReceivedCalls() do not leak between tests. + public IBlobStorageClient BlobStorageClient { get; } = Substitute.For(); protected override void ConfigureAdditionalTestServices(IServiceCollection services) { services.RemoveAll(typeof(IBlobStorageClient)); - services.AddKeyedSingleton("account-storage", _blobStorageClient); + services.AddKeyedSingleton("account-storage", BlobStorageClient); } } diff --git a/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs b/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs index 4f9af40d4b..b61c5b3ac3 100644 --- a/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs +++ b/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs @@ -2,104 +2,51 @@ using Account.Integrations.Stripe; using Bogus; using JetBrains.Annotations; -using Microsoft.ApplicationInsights; -using Microsoft.ApplicationInsights.Channel; -using Microsoft.ApplicationInsights.Extensibility; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.AspNetCore.TestHost; using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using NSubstitute; using SharedKernel.Authentication.BackOfficeIdentity; using SharedKernel.Authentication.MockEasyAuth; -using SharedKernel.ExecutionContext; -using SharedKernel.Integrations.Email; -using SharedKernel.SinglePageApp; using SharedKernel.Telemetry; using SharedKernel.Tests.Telemetry; namespace Account.Tests.BackOffice; -// Base class for back-office endpoint tests. Configures the BackOffice host (so RequireHost matches) -// and provides helpers to build HTTP clients with the right Host header and X-MS-CLIENT-PRINCIPAL-* headers. +// Base class for back-office endpoint tests. Each derived class declares +// IClassFixture (or a subclass) to share a single host across +// its tests; per-test isolation is preserved by the BackOfficeTestContext routed through the +// fixture's AsyncLocal slot. public abstract class BackOfficeEndpointBaseTest : IDisposable { - protected const string BackOfficeHost = "back-office.test.localhost"; + protected const string BackOfficeHost = BackOfficeWebApplicationFactory.BackOfficeHost; - private const string TestPublicUrl = "https://localhost"; - - private static readonly Lock SpaShellLock = new(); protected readonly Faker Faker = new(); - private readonly WebApplicationFactory _webApplicationFactory; + private readonly BackOfficeWebApplicationFactory _factory; + private readonly IDisposable _testScope; - protected BackOfficeEndpointBaseTest() + protected BackOfficeEndpointBaseTest(BackOfficeWebApplicationFactory factory) { - Environment.SetEnvironmentVariable(SinglePageAppConfiguration.PublicUrlKey, TestPublicUrl); - Environment.SetEnvironmentVariable(SinglePageAppConfiguration.CdnUrlKey, $"{TestPublicUrl}/account"); - Environment.SetEnvironmentVariable( - "APPLICATIONINSIGHTS_CONNECTION_STRING", - "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://localhost;LiveEndpoint=https://localhost" - ); - Environment.SetEnvironmentVariable("Stripe__AllowMockProvider", "true"); - Environment.SetEnvironmentVariable("Stripe__PublishableKey", "pk_test_mock_publishable_key"); - - EnsureBackOfficeSpaShell(); - - TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector()); + _factory = factory; Connection = new SqliteConnection($"Data Source=TestDb_{Guid.NewGuid():N};Mode=Memory;Cache=Shared"); Connection.Open(); - _webApplicationFactory = new WebApplicationFactory().WithWebHostBuilder(builder => + TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector()); + StripeState = new MockStripeState(); + + // BeginTest must run before any service resolution so the host's startup hosted services + // (PlatformCurrencyStartupResolver) and the EnsureCreated call below see the per-test state. + _testScope = factory.BeginTest(new BackOfficeTestContext { - builder.ConfigureLogging(logging => logging.AddFilter(_ => false)); - - builder.ConfigureAppConfiguration((_, configuration) => - { - var backOfficeSettings = new Dictionary - { - ["BackOffice:Host"] = BackOfficeHost, - // Match the AppHost wiring: mock admin identity carries this group id, so - // configuring it here lets BackOfficeAdminAuthorizationHandler and GetMe.IsAdmin - // resolve admin status the same way they do in dev. - ["BackOffice:AdminsGroupId"] = MockEasyAuthIdentities.MockAdminsGroupId, - // The user-facing SPA shell is scoped to Hostnames:App via UseHostScopedSinglePageAppFallback. - // Tests that target the user-facing host use app.test.localhost. - ["Hostnames:App"] = "app.test.localhost" - }; - - configuration.AddInMemoryCollection(backOfficeSettings); - } - ); - - builder.ConfigureTestServices(services => - { - services.Remove(services.Single(d => d.ServiceType == typeof(IDbContextOptionsConfiguration))); - services.AddDbContext(options => options.UseSqlite(Connection).UseSnakeCaseNamingConvention()); - - services.AddScoped(_ => TelemetryEventsCollectorSpy); - - services.Remove(services.Single(d => d.ServiceType == typeof(IEmailClient))); - services.AddTransient(_ => Substitute.For()); - - services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = Substitute.For() })); - services.AddScoped(); - - ConfigureAdditionalTestServices(services); - } - ); + Connection = Connection, + TelemetryCollector = TelemetryEventsCollectorSpy, + StripeState = StripeState } ); - using var scope = _webApplicationFactory.Services.CreateScope(); + using var scope = factory.Services.CreateScope(); scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); DatabaseSeeder = ActivatorUtilities.CreateInstance(scope.ServiceProvider); - - Environment.SetEnvironmentVariable("BypassAntiforgeryValidation", "true"); } protected SqliteConnection Connection { get; } @@ -108,7 +55,7 @@ protected BackOfficeEndpointBaseTest() protected TelemetryEventsCollectorSpy TelemetryEventsCollectorSpy { get; } - protected MockStripeState StripeState => _webApplicationFactory.Services.GetRequiredService(); + protected MockStripeState StripeState { get; } public void Dispose() { @@ -116,47 +63,9 @@ public void Dispose() GC.SuppressFinalize(this); } - protected virtual void ConfigureAdditionalTestServices(IServiceCollection services) - { - } - - // SinglePageAppConfiguration.GetHtmlTemplate() reads BackOffice/dist/index.html on every SPA-shell - // request. Locally that file is generated by `rsbuild dev`; in CI the test step runs before any frontend - // build, so the file is missing and the fallback returns 500. The dist's index.html is just the public - // template plus rsbuild's bundle