diff --git a/.gitea/workflows/continuous_integration.yaml b/.gitea/workflows/continuous_integration.yaml index 18ee877..5c06e5e 100644 --- a/.gitea/workflows/continuous_integration.yaml +++ b/.gitea/workflows/continuous_integration.yaml @@ -109,7 +109,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v3 with: node-version: ${{ env.NODE_VERSION }} - uses: pnpm/action-setup@v4 @@ -130,16 +130,16 @@ jobs: # --- Gate 4: frontend build --- # Validates the frontend compiles on its own, independent of any environment. The actual - # per-environment deployable artifact is produced later by `dotnet publish` (publish-test / - # publish-production jobs below), which triggers the same frontend build internally via - # SlpModularCms.Api.csproj's BuildAndCopyAdminFrontend MSBuild target, with the environment's - # Vite variables set on the process so that target picks them up. + # per-environment deployable artifact is produced later by each publish job's own "Build admin + # frontend" step (publish-test / publish-production, below), which builds frontend/ again with + # that environment's Vite variables set and copies the result into wwwroot/admin before + # `dotnet publish` runs — see that step's comment for why this isn't an MSBuild target instead. frontend-build: needs: frontend-prepare runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v3 with: node-version: ${{ env.NODE_VERSION }} - uses: pnpm/action-setup@v4 @@ -167,7 +167,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v3 with: node-version: ${{ env.NODE_VERSION }} - uses: pnpm/action-setup@v4 @@ -195,7 +195,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v3 with: node-version: ${{ env.NODE_VERSION }} - uses: pnpm/action-setup@v4 @@ -217,10 +217,8 @@ jobs: working-directory: frontend run: pnpm run lint - # Publishes the Api project for the test environment. The admin SPA is built as part of this - # publish (BuildAndCopyAdminFrontend, BeforeTargets="Publish"), so the VITE_* variables below are - # set on this step's environment, not passed as CLI arguments — pnpm build (invoked by MSBuild) - # reads them the same way `vite build` always does. + # Publishes the Api project for the test environment. The admin SPA is built by this job's own + # "Build admin frontend" step below, ahead of `dotnet publish` — see that step's comment for why. # # REF-U5-01: the Umami-origin drift check this gate performs exists because U3's own startup check # (BR-U3-22) can't work — the backend can never see VITE_UMAMI_WEBSITE_ID at runtime. It compares @@ -236,7 +234,7 @@ jobs: - uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v3 with: node-version: ${{ env.NODE_VERSION }} - uses: pnpm/action-setup@v4 @@ -253,13 +251,29 @@ jobs: exit 1 fi - - name: Publish (test) - working-directory: src/SlpModularCms.Api + # Builds the admin SPA and lands it in wwwroot/admin *before* dotnet publish runs, as its own + # step rather than an MSBuild target hooked to Build/Publish. That was tried and reliably + # failed two different ways (see SlpModularCms.Api.csproj's comment on the topic): the SDK's + # wwwroot static-web-asset item set is fixed at project evaluation time, before any target + # runs, so files a target creates afterward never make it into the publish output; and forcing + # them in as an explicit Content item collided with the SDK's own static-web-asset resolution. + # Physically existing on disk before dotnet publish/build ever runs is the only thing that + # worked, verified locally against a clean obj/bin and a fresh node_modules. + - name: Build admin frontend + working-directory: frontend env: VITE_APP_ENV: test VITE_UMAMI_SCRIPT_URL: ${{ vars.VITE_UMAMI_SCRIPT_URL }} VITE_UMAMI_WEBSITE_ID: ${{ vars.VITE_UMAMI_WEBSITE_ID_TEST }} VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} + run: | + pnpm install --frozen-lockfile + pnpm build + mkdir -p ../src/SlpModularCms.Api/wwwroot/admin + cp -r dist/. ../src/SlpModularCms.Api/wwwroot/admin/ + + - name: Publish (test) + working-directory: src/SlpModularCms.Api run: > dotnet publish -c Release -r ${{ env.PUBLISH_RID }} --self-contained false -o ${{ github.workspace }}/${{ env.ARTIFACT_NAME_TEST }} @@ -283,7 +297,7 @@ jobs: - uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v3 with: node-version: ${{ env.NODE_VERSION }} - uses: pnpm/action-setup@v4 @@ -300,13 +314,23 @@ jobs: exit 1 fi - - name: Publish (production) - working-directory: src/SlpModularCms.Api + # See publish-test's "Build admin frontend" step for why this runs as its own step ahead of + # dotnet publish rather than an MSBuild target. + - name: Build admin frontend + working-directory: frontend env: VITE_APP_ENV: production VITE_UMAMI_SCRIPT_URL: ${{ vars.VITE_UMAMI_SCRIPT_URL }} VITE_UMAMI_WEBSITE_ID: ${{ vars.VITE_UMAMI_WEBSITE_ID_PRODUCTION }} VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} + run: | + pnpm install --frozen-lockfile + pnpm build + mkdir -p ../src/SlpModularCms.Api/wwwroot/admin + cp -r dist/. ../src/SlpModularCms.Api/wwwroot/admin/ + + - name: Publish (production) + working-directory: src/SlpModularCms.Api run: > dotnet publish -c Release -r ${{ env.PUBLISH_RID }} --self-contained false -o ${{ github.workspace }}/${{ env.ARTIFACT_NAME_PRODUCTION }} diff --git a/.gitea/workflows/deploy-scp.yaml b/.gitea/workflows/deploy-scp.yaml index 67bce96..d46c5e0 100644 --- a/.gitea/workflows/deploy-scp.yaml +++ b/.gitea/workflows/deploy-scp.yaml @@ -56,14 +56,23 @@ jobs: # The backup script itself lives on the host (created once during Operations host setup) and # is only invoked here — no database connection string or credential is ever known to this # workflow, keeping D-16 ("runtime secrets live in host environment variables") intact. - - name: Back up database (${{ inputs.environment }}) + # Gitea Actions does not resolve `${{ inputs.* }}` inside a step's `name:` (unlike inside + # `run:`, where it works fine — see the echo below) — kept static rather than showing the + # literal, unresolved `${{ inputs.environment }}` text in the log. + - name: Back up database if: ${{ inputs.run_db_backup }} + env: + SSH_USER: ${{ secrets.PI_MAIN_USERNAME }} + SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }} + SSH_PORT: ${{ secrets.PI_MAIN_PORT }} + SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }} run: | + echo "Environment: ${{ inputs.environment }}" sudo apt-get update && sudo apt-get install -y sshpass - sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ - -p ${{ secrets.PI_MAIN_PORT }} \ + sshpass -p "$SSH_PASS" ssh \ + -p "$SSH_PORT" \ -o StrictHostKeyChecking=no \ - ${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \ + "$SSH_USER@$SSH_HOST" \ "bash ~/scripts/backup-slpsoftware-db.sh ${{ inputs.environment }}" # Uploads the published output to a fresh, timestamped release directory rather than @@ -74,20 +83,27 @@ jobs: # runner with "failed to attach to container: unable to upgrade to tcp, received 409", a known # limitation of Podman's Docker-compatible API for the attach/log-streaming that container # actions rely on (D-05). A plain scp command needs no nested container. - - name: Upload release to ${{ inputs.environment }} (${{ inputs.transport }}) + # Same Gitea Actions limitation as the step above — static name, value logged via echo instead. + - name: Upload release + env: + SSH_USER: ${{ secrets.PI_MAIN_USERNAME }} + SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }} + SSH_PORT: ${{ secrets.PI_MAIN_PORT }} + SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }} run: | + echo "Environment: ${{ inputs.environment }}, transport: ${{ inputs.transport }}" sudo apt-get update && sudo apt-get install -y sshpass RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}" - sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ - -p ${{ secrets.PI_MAIN_PORT }} \ + sshpass -p "$SSH_PASS" ssh \ + -p "$SSH_PORT" \ -o StrictHostKeyChecking=no \ - ${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \ + "$SSH_USER@$SSH_HOST" \ "mkdir -p $RELEASE_DIR" - sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" scp \ - -P ${{ secrets.PI_MAIN_PORT }} \ + sshpass -p "$SSH_PASS" scp \ + -P "$SSH_PORT" \ -o StrictHostKeyChecking=no \ -r ${{ inputs.artifact_name }}/* \ - ${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }}:"$RELEASE_DIR"/ + "$SSH_USER@$SSH_HOST:$RELEASE_DIR/" # The customer's public website (wwwroot/web) must survive every CMS deploy (FR-08, ASM-01). # It lives outside the swapped release directory in a persistent shared/ folder, and is @@ -95,29 +111,71 @@ jobs: # first-ever deploy, before any website workspace has published anything there (U1 already # tolerates a missing wwwroot/web at startup). The publish output's own wwwroot/web (empty, or # containing only the placeholder page) is removed before the symlink is created, so it never - # shadows the persistent content. + # shadows the persistent content. `mkdir -p $RELEASE_DIR/wwwroot` guards against `ln -s` failing + # with "No such file or directory": dotnet publish only emits a wwwroot/ folder at all when the + # source project has one with actual content, so a release built before any frontend content + # exists can land with no wwwroot/ directory whatsoever, not merely an empty wwwroot/web/. - name: Link persistent website content + env: + SSH_USER: ${{ secrets.PI_MAIN_USERNAME }} + SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }} + SSH_PORT: ${{ secrets.PI_MAIN_PORT }} + SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }} run: | RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}" - sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ - -p ${{ secrets.PI_MAIN_PORT }} \ + sshpass -p "$SSH_PASS" ssh \ + -p "$SSH_PORT" \ -o StrictHostKeyChecking=no \ - ${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \ + "$SSH_USER@$SSH_HOST" \ "mkdir -p ${{ inputs.deploy_path }}/shared/wwwroot-web && \ + mkdir -p $RELEASE_DIR/wwwroot && \ rm -rf $RELEASE_DIR/wwwroot/web && \ ln -s ../../../shared/wwwroot-web $RELEASE_DIR/wwwroot/web" + # Optional plugin modules (future modular support): a compiled SlpModularCms.Modules.*.dll + # dropped into shared/modules survives every release, the same persistence pattern as + # shared/wwwroot-web above — except there's no cross-account permission dance here, since + # shared/modules lives entirely under gitea-workflow's own tree (unlike wwwroot-web, which + # reaches into webadmin's). ModuleOrchestrator.DiscoverModules() already scans its own base + # directory on disk for matching assemblies at startup (SlpModularCms.Core/Hosting/ + # ModuleOrchestrator.cs) and loads whatever it finds via reflection — no code change needed, + # only somewhere for the DLL to still be after the next deploy replaces releases/{timestamp}. + # `ls ... 2>/dev/null` piped to a `while read` (rather than a bare glob loop) keeps this + # POSIX-sh safe and correct when shared/modules is empty, which is the common case today. + - name: Link persistent modules + env: + SSH_USER: ${{ secrets.PI_MAIN_USERNAME }} + SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }} + SSH_PORT: ${{ secrets.PI_MAIN_PORT }} + SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }} + run: | + RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}" + sshpass -p "$SSH_PASS" ssh \ + -p "$SSH_PORT" \ + -o StrictHostKeyChecking=no \ + "$SSH_USER@$SSH_HOST" \ + "mkdir -p ${{ inputs.deploy_path }}/shared/modules && \ + ls ${{ inputs.deploy_path }}/shared/modules/*.dll 2>/dev/null | while read -r f; do \ + name=\$(basename \"\$f\"); \ + ln -sf \"../../shared/modules/\$name\" \"$RELEASE_DIR/\$name\"; \ + done" + # Atomic release switch (FR-06, D-27): `ln -sfn` replaces the `current` symlink target in a # single filesystem operation, so there is no moment where `current` points at a half-written # directory. The process is then restarted so it picks up the new assemblies — a running .NET # process holds on to the ones it already loaded. - name: Switch current release and restart service + env: + SSH_USER: ${{ secrets.PI_MAIN_USERNAME }} + SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }} + SSH_PORT: ${{ secrets.PI_MAIN_PORT }} + SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }} run: | RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}" - sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ - -p ${{ secrets.PI_MAIN_PORT }} \ + sshpass -p "$SSH_PASS" ssh \ + -p "$SSH_PORT" \ -o StrictHostKeyChecking=no \ - ${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \ + "$SSH_USER@$SSH_HOST" \ "ln -sfn $RELEASE_DIR ${{ inputs.deploy_path }}/current && \ systemctl --user restart ${{ inputs.service_name }}" @@ -142,9 +200,14 @@ jobs: # a re-point-and-restart away without rebuilding. Only runs after a passing health check — # pruning after a failed check could leave the only other release as the sole survivor. - name: Prune old releases + env: + SSH_USER: ${{ secrets.PI_MAIN_USERNAME }} + SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }} + SSH_PORT: ${{ secrets.PI_MAIN_PORT }} + SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }} run: | - sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \ - -p ${{ secrets.PI_MAIN_PORT }} \ + sshpass -p "$SSH_PASS" ssh \ + -p "$SSH_PORT" \ -o StrictHostKeyChecking=no \ - ${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \ + "$SSH_USER@$SSH_HOST" \ "cd ${{ inputs.deploy_path }}/releases && ls -1t | tail -n +3 | xargs -r rm -rf" diff --git a/aidlc-docs/features/gitea-deployment-workflow/aidlc-state.md b/aidlc-docs/features/gitea-deployment-workflow/aidlc-state.md index f45a7b2..20ef335 100644 --- a/aidlc-docs/features/gitea-deployment-workflow/aidlc-state.md +++ b/aidlc-docs/features/gitea-deployment-workflow/aidlc-state.md @@ -179,8 +179,15 @@ Execution rounds (Q4 = B): **R1** = U1 + U2 · **R2** = U3 + U4 · **R3** = U5 + - [x] Build and Test — **EXECUTE** — Complete 2026-07-28. 609 unit tests (372 backend + 237 frontend), 0 vulnerable packages, 0 build errors. Plus live-host integration verification: static content/SPA-fallback/header-scoping, real master↔slave communication proving the persistent key ring survives a process restart, and W3C trace-id correlation in real log output. Artifacts in `construction/build-and-test/` ### OPERATIONS -- [ ] Deployment Setup — **EXECUTE** -- [ ] Monitoring Setup — **EXECUTE** +- [x] Deployment Setup — **EXECUTE** — Complete 2026-07-28, extended 2026-07-29 (MariaDB migration, + reverse proxy topology correction, artifact-upload 413 fix in `deployment-instructions.md` § 1.7.3, + missing `Observability__Environment` host var added § 1.5). Artifacts: `operations/deployment/` +- [x] Monitoring Setup — **EXECUTE** — Complete 2026-07-29. Plan answered (Sentry project reused, + UptimeRobot account reused, Umami website entries created by user, 20-events/5-min default alert + threshold). User has applied the real values in Gitea variables, UptimeRobot and Sentry directly + (DSN, Umami website IDs, alert rules, 6 monitors) — not verified by execution from this session + (no access to those external accounts), taken on the user's report. Artifacts: + `operations/plans/monitoring-setup-plan.md`, `operations/monitoring/monitoring-instructions.md` - [ ] Production Readiness Validation — **EXECUTE** (includes the `dotnet-appsettings` compliance gate) ## Execution Plan Summary @@ -190,9 +197,13 @@ Execution rounds (Q4 = B): **R1** = U1 + U2 · **R2** = U3 + U4 · **R3** = U5 + ## Current Status - **Lifecycle Phase**: OPERATIONS -- **Current Stage**: Build and Test **complete** 2026-07-28 (all 7 units, full-suite + live-host integration verification). CONSTRUCTION phase is now closed -- **Next Stage**: Deployment Setup (Operations Configuration = Yes, decided at Requirements Analysis) -- **Status**: Rounds 1–4 all done, nothing pushed. Entering Operations phase +- **Current Stage**: Deployment Setup complete (2026-07-28, extended 2026-07-29). Monitoring Setup + complete 2026-07-29 — user applied real values in Gitea, UptimeRobot and Sentry +- **Next Stage**: Production Readiness Validation (includes the `dotnet-appsettings` compliance gate) +- **Status**: CI pipeline built and merging via an open PR; `workflow_dispatch` manual testing of + `deploy-test`/`deploy-production` (and therefore a real end-to-end run touching the monitors/alerts + just configured) blocked until that PR merges to `master` (Gitea Actions only shows manual-dispatch + workflows that exist on the default branch) ## Round 2 Design Record (2026-07-28) - Functional Design U3 + U4 complete and committed (`357d395`) diff --git a/aidlc-docs/features/gitea-deployment-workflow/operations/deployment/deployment-instructions.md b/aidlc-docs/features/gitea-deployment-workflow/operations/deployment/deployment-instructions.md index d4438fc..5339519 100644 --- a/aidlc-docs/features/gitea-deployment-workflow/operations/deployment/deployment-instructions.md +++ b/aidlc-docs/features/gitea-deployment-workflow/operations/deployment/deployment-instructions.md @@ -148,6 +148,28 @@ sudo chgrp -R webshared /mnt/storage1/www/html/slpsoftware/ and make sure webadmin's FTP server creates new uploads group-readable (`g+rx`, not just owner-readable) — a one-time permission setup, not something either pipeline touches per deploy. +**`shared/modules` is the equivalent mechanism for optional plugin modules** (future modular +support) — but simpler, since it lives entirely under `gitea-workflow`'s own tree, with no +cross-account permission dance: + +👤 **pi-main / `gitea-workflow`:** +```bash +mkdir -p ~/apps/slpsoftware//shared/modules +``` + +`deploy-scp.yaml`'s "Link persistent modules" step already runs this `mkdir -p` on every deploy, so +this line is only useful if you want the directory to exist before the very first deploy — it isn't +required. To add a module: build a `SlpModularCms.Modules..dll` against the same version of +`SlpModularCms.Core` the running app was built against (mismatched contracts are caught per-module +by `ModuleOrchestrator`'s existing try/catch — logged and skipped, not a crash), `scp`/`sftp` it as +`gitea-workflow` into `~/apps/slpsoftware//shared/modules/`, then either wait for the next +deploy or restart the service by hand: +```bash +systemctl --user restart slpsoftware-.service +``` +Either way, `ModuleOrchestrator.DiscoverModules()` picks it up from the release directory the next +time the process starts — no CI run required to add a module this way. + ### 1.5 Runtime Configuration File One file per environment, **outside** the release directory so it survives every switch: @@ -182,6 +204,14 @@ MasterModule__MasterUrl=https:// # continues (see Observability section in README.md). Observability__SentryDsn= +# REQUIRED if the DSN above is set, and must differ between this file and the other environment's. +# D-19 uses one Sentry project for both test and production, distinguished only by this tag. +# ASPNETCORE_ENVIRONMENT is deliberately "Production" for both environments (see note below), so +# without this explicit override, ObservabilityOptions.Environment falls back to +# ASPNETCORE_ENVIRONMENT and every event — test and production alike — would be tagged "Production", +# silently defeating D-19's whole point of telling them apart in Sentry. +Observability__Environment= + # Optional — both only needed if this instance uses Umami analytics (VITE_UMAMI_SCRIPT_URL set for # the frontend build). Leave both lines out entirely if you don't use Umami; there is no other # origin either one needs by default. @@ -362,6 +392,82 @@ server { } ``` +#### 1.7.3 Artifact Upload Body Size (413 on `publish-test`/`publish-production`) + +The Gitea instance itself (`gitea.slpsoftware.nl`) sits behind the same proxy Pi as every other +domain in § 1.7 — Actions' own web UI/API traffic is proxied through it exactly like +`test.slpsoftware.nl` and `slpsoftware.nl` are. Gitea Actions' artifact upload +(`actions/upload-artifact`) sends the build output in chunks; if a chunk exceeds nginx's +`client_max_body_size` (default 1m), nginx itself rejects it with `413 Request Entity Too Large` +before the request ever reaches Gitea — surfacing in the `publish-test`/`publish-production` job log +as repeated `A 413 status code has been received, will attempt to retry the upload` followed by +`Retry limit has been reached` on individual files once the action's retries are exhausted. + +**pi-main is not involved** — § 1.7 already established it runs no nginx and holds no certificates +for any of these domains; this is purely a proxy Pi setting. + +**Fix — set once, globally, not per server block.** `client_max_body_size` is inherited +(`http` → `server` → `location`); setting it in the top-level `http {}` block covers every current +and future domain's server blocks — both the `listen 80` and `listen 443` blocks certbot manages — +without needing to repeat it each time § 1.7.1's procedure is run for a new domain. + +🌐 **proxy Pi / root:** +```nginx +# /etc/nginx/nginx.conf, inside the http { } block: +http { + client_max_body_size 512m; + ... +} +``` +```bash +sudo nginx -t && sudo systemctl reload nginx +``` + +#### 1.7.4 Never Re-Run `deploy-test`/`deploy-production` Alone — Gitea Platform Limitation + +`deploy-test` and `deploy-production` in `continuous_integration.yaml` are not ordinary jobs — each +one *calls* the reusable `deploy-scp.yaml` workflow (`uses: ./.gitea/workflows/deploy-scp.yaml`). +Re-running **only** one of these two jobs after a failure (Gitea's per-job "re-run" action) is a +known Gitea Actions limitation, not a bug in either workflow file here: Gitea does not cleanly resume +a `workflow_call` job inside its original run — it re-executes the call in a way that loses access to +that run's already-uploaded artifact. The `Download build artifact` step then fails immediately with: +``` +List Artifacts - Error is not retryable +Status Code: 404 +Error: List Artifacts failed: Artifact service responded with 404 +``` +even though `publish-test`/`publish-production` genuinely succeeded and uploaded the artifact +moments earlier in the same run (retention had not expired — this is not the same failure mode as +§ 1.7.3, and not a retention issue at all). + +**Fix: re-run the entire workflow, not just this job** — but see § 1.7.5 immediately below before +doing that: Gitea's "re-run all jobs" on an *existing* run is not the same as triggering a genuinely +new run, and has its own, different failure mode. + +#### 1.7.5 "Re-Run All Jobs" Can Silently Reuse Stale Secrets/Variables — Trigger a New Run Instead + +Symptom: a secret (e.g. `PI_MAIN_PASSWORD`) was wrong, causing `sshpass`/`ssh` to fail with +`Permission denied, please try again.` (`sshpass` exit code 5 — the password itself was rejected, +not a connectivity or config problem). The secret is corrected in Gitea's UI and confirmed working +via a manual SSH test with the same value. **"Re-run all jobs" is used on the existing, already-failed +run — and it fails again, identically**, as if the fix never happened. + +This matches a documented behaviour of Actions-style re-run implementations, seen concretely in +GitHub's own tooling ([cli/cli#13522](https://github.com/cli/cli/issues/13522)): re-running an +*existing* run can replay against secrets/variables as they were **when that run was first created**, +not their current values — particularly for a job that calls a reusable workflow with +`secrets: inherit` (exactly what `deploy-test`/`deploy-production` do here). The documented contract +("secrets are fetched at the time of the re-run") does not hold in practice for this case. A genuinely +**new** run (fresh push, or `workflow_dispatch`) always fetches current values correctly — only +re-running an existing run risks the stale snapshot. + +**Net effect of § 1.7.4 + § 1.7.5 together**: neither of Gitea's two "re-run" options is fully safe +for `deploy-test`/`deploy-production` — "re-run this job alone" loses the artifact, "re-run all jobs" +can keep stale secrets. **The one reliable option is to trigger a brand new run** (an actual push, or +`workflow_dispatch`'s "Run workflow" button) rather than using either re-run action on a failed run, +whenever a secret or variable was just changed to fix that failure. Re-running an existing run is +only safe when nothing about its secrets/variables changed since it was created. + ### 1.8 Database Backup Credentials (§ 4 depends on this) 👤 **pi-main / `gitea-workflow`:** @@ -414,9 +520,9 @@ Already built (U5/U6) — this is the read-only walkthrough for whoever operates 1. Push to `master`, or a manual `workflow_dispatch` → the six gates run 2. `publish-test` runs (and `publish-production`, only if `workflow_dispatch` with the flag) 3. `deploy-test` (always, if gates pass) calls `deploy-scp.yaml`, which uploads into a new - `releases/{timestamp}/`, links `shared/wwwroot-web` in, switches `current`, restarts - `slpsoftware-test.service`, verifies `https://test.slpsoftware.nl/health`, then prunes old - releases (only test — `run_db_backup: false`) + `releases/{timestamp}/`, links `shared/wwwroot-web` and any `shared/modules/*.dll` in, switches + `current`, restarts `slpsoftware-test.service`, verifies `https://test.slpsoftware.nl/health`, + then prunes old releases (only test — `run_db_backup: false`) 4. `deploy-production` (only with the flag) does the same, plus a database backup first (`run_db_backup: true`) — see § 4 @@ -450,6 +556,21 @@ fi source "$CREDENTIALS_FILE" : "${DB_HOST:?}" "${DB_PORT:?}" "${DB_NAME:?}" "${DB_USER:?}" "${DB_PASSWORD:?}" +# A brand-new environment's very first production deploy runs this step before the app has ever +# started, so before EF Core's migrations have had a chance to create the database — there is +# nothing to back up yet, and that's fine, not a failure. Distinguish that specific case ("Unknown +# database", MariaDB error 1049) from every other failure (wrong credentials, network issue, +# permissions): only the former is safe to skip, since skipping any OTHER error would silently mask +# a real backup failure against a database that may hold real data. +USE_ERROR=$(mariadb -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASSWORD" -e "USE \`$DB_NAME\`;" 2>&1 >/dev/null) || true +if echo "$USE_ERROR" | grep -q "Unknown database"; then + echo "Database '$DB_NAME' does not exist yet — nothing to back up (expected on a first deploy). Skipping." + exit 0 +elif [[ -n "$USE_ERROR" ]]; then + echo "Could not verify database '$DB_NAME' exists: $USE_ERROR" >&2 + exit 1 +fi + BACKUP_DIR="$HOME/backups/slpsoftware/${ENVIRONMENT}" mkdir -p "$BACKUP_DIR" TIMESTAMP=$(date -u +%Y%m%d%H%M%S) diff --git a/aidlc-docs/features/gitea-deployment-workflow/operations/monitoring/monitoring-instructions.md b/aidlc-docs/features/gitea-deployment-workflow/operations/monitoring/monitoring-instructions.md new file mode 100644 index 0000000..e8c91e4 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/operations/monitoring/monitoring-instructions.md @@ -0,0 +1,134 @@ +# Monitoring Instructions + +All three services below were built into the application during Construction (U4) — this document +only wires them to the real accounts and writes down the configuration that necessarily lives +outside this repository (Sentry alert rules, UptimeRobot monitors, Umami website entries). + +**Who does what, where** — reuses the labels from `deployment-instructions.md` § "Who runs what, +where", plus the external services themselves: +- 👤 **pi-main / `gitea-workflow`** — the environment's `shared/env` file (§ 1.5 of + `deployment-instructions.md`) +- ☁️ **Gitea web UI** — repository Variables (Settings → Actions → Variables) +- ☁️ **Sentry web UI** — project settings and alert rules +- ☁️ **UptimeRobot web UI** — monitors and alert contacts +- ☁️ **Umami web UI** — website entries + +## 1. Sentry + +One project for the whole CMS (D-19); `Observability__Environment` (host-side, § 1.5) and +`VITE_APP_ENV` (build-time) are what tell test and production events apart in that one project — +there is no separate DSN per environment. + +### 1.1 Gitea variable + +☁️ **Gitea web UI** — Settings → Actions → Variables: + +| Variable | Value | +|---|---| +| `VITE_SENTRY_DSN` | the project's DSN (same value used by both `publish-test` and `publish-production`) | + +### 1.2 Host configuration + +Already documented in `deployment-instructions.md` § 1.5 — `Observability__SentryDsn` and +`Observability__Environment` in each environment's `shared/env` file. `Observability__Environment` +**must** differ between the two files (`test` / `production`); see that section's note on why +`ASPNETCORE_ENVIRONMENT` can't be reused for this. + +### 1.3 Alert rules (FR-19) + +The six `security_event` tag values already emitted by `SecurityEvents.cs` — alert rules must filter +on the **tag**, not the message text (`nfr-design-patterns.md` Pattern 6): + +| `security_event` tag value | Rule type | Threshold | +|---|---|---| +| `failed_login` | Count-based | > 20 events in 5 minutes | +| `authorization_denied` | Count-based | > 20 events in 5 minutes | +| `master_api_key_rejected` | Count-based | > 20 events in 5 minutes | +| `admin_bypass_rejected` | Count-based | > 20 events in 5 minutes | +| `rate_limit_triggered` | Count-based | > 20 events in 5 minutes | +| `migration_failure` | Always-fire | any occurrence (already `Critical` level — the process is exiting) | + +20-in-5-minutes is a starting default (no production traffic history exists yet to tune against) — +revisit once real traffic volume is known. + +☁️ **Sentry web UI** — Alerts → Create Alert Rule, once per row above: +1. Condition: `tags.security_event equals ` +2. For the five count-based rules: "when greater than 20 events, in 5 minutes" (or the platform's + equivalent rate-window phrasing) +3. For `migration_failure`: no count condition — fire on every matching event +4. Action: notify via whichever channel/integration alerts should reach (same destination used + elsewhere for this project is fine — nothing here requires a dedicated channel) + +**Note (DEV-01, accepted deviation)**: Sentry's plan in use retains events for roughly 30 days, +short of the 90-day SECURITY-14 minimum. Already accepted as a cost decision at Requirements +Analysis — nothing to configure here, just to be aware of if investigating an old event. + +## 2. UptimeRobot + +Six monitors — `/health`, `/`, `/admin` × test/production (D-23). What each does and does not prove +(FR-17): + +| URL | Proves | Does NOT prove | +|---|---|---| +| `https://test.slpsoftware.nl/health` | The test process is alive (liveness only, D-21) | Database connectivity, migrations applied — no DB check by design (Q17=A) | +| `https://test.slpsoftware.nl/` | Proxy Pi → pi-main routing and static hosting work for test | The API or admin SPA work at all | +| `https://test.slpsoftware.nl/admin` | The admin SPA is served and its assets aren't blocked by CSP | Login/auth actually succeeds — this only checks the shell loads | +| `https://slpsoftware.nl/health` | Same as above, production | Same as above, production | +| `https://slpsoftware.nl/` | Same as above, production | Same as above, production | +| `https://slpsoftware.nl/admin` | Same as above, production | Same as above, production | + +☁️ **UptimeRobot web UI** — Add New Monitor, once per row above: +- Monitor Type: **HTTP(s)** (not Keyword/Port/Ping) — checks only the response status code (2xx/3xx), + the same liveness signal `deploy-scp.yaml`'s own `curl -f` health check already relies on +- Interval: 5 minutes is enough for all six — `/health` recovers within a process restart + (`Restart=on-failure`, `RestartSec=5` in the systemd units), no need for a tighter interval +- Alert Contact: the existing contact already configured on this account + +**Free-plan note**: 6 monitors at 5-minute intervals fits comfortably within the free plan's 50-monitor +cap and 5-minute minimum interval — no paid tier needed for this feature's monitoring. One gap the +free plan leaves open: no SSL-certificate-expiry monitoring. Certbot's systemd timer renews +automatically (`deployment-instructions.md` § 1.7.1), so the risk is low, but a silently failed +renewal would only be caught reactively — once an HTTP(s) check itself starts failing — rather than +warned about in advance. Accepted as a known gap, not built around, consistent with this project's +other documented deviations (e.g. DEV-01's log-retention gap). + +## 3. Umami + +Reuses the existing self-hosted instance at `analytics.slpsoftware.nl` (D-24/ASM-05). This section +covers only the **admin SPA's** two website entries — the public website's Umami wiring (also +required by D-25) belongs to whichever website workspace serves `wwwroot/web/` (FR-09/FR-16), not to +this deploy pipeline. + +☁️ **Umami web UI** — Websites → Add website, twice (once per environment), domain set to the +admin SPA's real URL (`test.slpsoftware.nl/admin`, `slpsoftware.nl/admin`) — note the resulting +website ID for each. + +☁️ **Gitea web UI** — Settings → Actions → Variables: + +| Variable | Value | +|---|---| +| `VITE_UMAMI_SCRIPT_URL` | `https://analytics.slpsoftware.nl/script.js` (shared — same instance, both environments) | +| `VITE_UMAMI_WEBSITE_ID_TEST` | the test website's ID from the step above | +| `VITE_UMAMI_WEBSITE_ID_PRODUCTION` | the production website's ID from the step above | + +**These must stay in sync with two other Gitea variables that already exist from U5** +(`SECURITY_ALLOWED_SCRIPT_ORIGINS_TEST` / `_PRODUCTION`) and with the matching +`SecurityHeaders__AllowedScriptOrigins__0` / `_AllowedConnectOrigins__0` lines in each environment's +host `shared/env` file (`deployment-instructions.md` § 1.5) — all four must name the same +`https://analytics.slpsoftware.nl` origin, or either the CI gate (REF-U5-01) fails the build, or the +script loads in CI but is silently blocked by CSP on the real host (the gate cannot see the host +file, only the Gitea variable side of this). + +## 4. Gitea Variables — Full Summary + +For reference, every Actions variable this document and `deployment-instructions.md` § 1.9 together +require: + +| Variable | Scope | Set by | +|---|---|---| +| `VITE_SENTRY_DSN` | shared | § 1.1 above | +| `VITE_UMAMI_SCRIPT_URL` | shared | § 3 above | +| `VITE_UMAMI_WEBSITE_ID_TEST` | test | § 3 above | +| `VITE_UMAMI_WEBSITE_ID_PRODUCTION` | production | § 3 above | +| `SECURITY_ALLOWED_SCRIPT_ORIGINS_TEST` / `_PRODUCTION` | per environment | already set at Deployment Setup — must match § 3's Umami origin | +| `DEPLOY_PATH_*`, `SERVICE_NAME_*`, `HEALTH_CHECK_URL_*` | per environment | `deployment-instructions.md` § 1.9 (unrelated to monitoring, listed there) | diff --git a/aidlc-docs/features/gitea-deployment-workflow/operations/plans/monitoring-setup-plan.md b/aidlc-docs/features/gitea-deployment-workflow/operations/plans/monitoring-setup-plan.md new file mode 100644 index 0000000..6ba9a38 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/operations/plans/monitoring-setup-plan.md @@ -0,0 +1,127 @@ +# Monitoring Setup Plan + +## Context already established (not re-asked) + +The observability *stack* and *what it must cover* were already decided in Requirements Analysis and +built during Construction (U4) — this stage is about configuring the real external services and +writing the operational documentation, not re-deciding the approach: + +- **Stack**: Sentry (backend `Sentry.AspNetCore` + frontend `@sentry/react`), self-hosted Umami + analytics, UptimeRobot uptime monitoring (D-18, D-24, requirements.md § "Monitoring and observability") +- **Sentry projects**: **one** project for the whole CMS, `environment` tag distinguishes + test/production (D-19) — so one DSN, reused by both environments and both frontend/backend +- **Sentry is optional by design**: an absent DSN disables it cleanly, console logging stays active + (BR-U4-08) — nothing breaks if a value is left blank temporarily +- **Alertable events already exist in code**: six `security_event` tag values are emitted today — + `failed_login`, `authorization_denied`, `master_api_key_rejected`, `admin_bypass_rejected`, + `rate_limit_triggered`, `migration_failure` (`SecurityEvents.cs`). Alert rules must filter on the + `security_event` tag, not message text (`nfr-design-patterns.md` Pattern 6) — this is explicitly + named as **Operations — Monitoring Setup** work in `logical-components.md` (FR-19) +- **UptimeRobot scope**: `/health`, `/` (public website) and `/admin`, per environment — 6 monitors + total (D-23) +- **Umami scope**: reuse the existing self-hosted instance at `analytics.slpsoftware.nl`, add new + website entries for this CMS rather than standing up a new instance (D-24/ASM-05). Both the admin + SPA (via `VITE_UMAMI_WEBSITE_ID_`, built by this pipeline) and the public website (via the + separate website-workspace, FR-09/FR-16, out of this pipeline's scope) are measured (D-25) — this + stage only configures the **admin SPA's** two website entries; the public website's Umami wiring + belongs to whichever website workspace serves it +- **Health endpoint**: liveness only, no DB check, already built and already on the + `AvailabilityMiddleware` bypass list (D-21) — nothing left to build, only to point UptimeRobot at +- **Log retention deviation**: accepted knowingly (DEV-01) — Sentry's ~30-day retention against the + 90-day SECURITY-14 minimum. No action here beyond documenting it. + +## Question 1: Include Monitoring Setup? + +Given U4 built all the alertable-event plumbing specifically so that Operations could wire up real +alert rules and monitors on top of it, declining this stage would leave that code inert — events +would be emitted into a Sentry project that doesn't exist yet, with no alert rule reading the tag, +and no UptimeRobot monitor watching `/health`. Recommended: A. + +A) Yes — configure the real services and document it (Recommended) +B) No — monitoring is handled elsewhere or not needed +C) Not sure — suggest an approach and I'll decide + +X) Other (please describe after [Answer]: tag below) + +[Answer]:A + +## Question 2: Monitoring Method + +Confirms the stack already decided in Requirements Analysis — asked per the mandatory format, not +because it's genuinely open. + +A) Sentry + self-hosted Umami + UptimeRobot, exactly as scoped above (Recommended) +B) Something else entirely — describe below + +X) Other (please describe after [Answer]: tag below) + +[Answer]:A + +--- + +The remaining questions are the genuinely open items — real account/service facts that only you +know, which nothing in Construction could decide for you. + +## Question 3: Sentry Project + +Does a Sentry project for this CMS already exist (e.g. created ad hoc while testing U4 locally), or +does this stage need to walk through creating one from scratch? + +A) Already exists — I'll provide the DSN after [Answer]: below +B) Doesn't exist yet — walk me through creating one (org, project platform picks for .NET + React, + where to find the DSN afterward) +C) Not yet, and I'd rather set it up myself later — document it as a TODO with exactly which Gitea + secrets/variables need the DSN once it exists + +X) Other (please describe after [Answer]: tag below) + +[Answer]:A — DSN to be added directly to the Gitea secret/variable once shared (kept out of this doc) + +## Question 4: Sentry Alert Rule Thresholds + +FR-19 requires alert rules for repeated auth failures and authorization violations, filtering on the +`security_event` tag (values: `failed_login`, `authorization_denied`, `master_api_key_rejected`, +`admin_bypass_rejected`, `rate_limit_triggered`; `migration_failure` is `Critical`-level and worth +its own always-fire rule regardless of count). What threshold should the repeated-failure rules use? + +A) A single reasonable default for all of them (e.g. 20 occurrences in 5 minutes) — document it as a + starting point, tune later from real traffic (Recommended) +B) I'll specify exact thresholds per event type — describe below +C) No count threshold — alert on every occurrence of each of the six (noisier, but nothing is ever + missed) + +X) Other (please describe after [Answer]: tag below) + +[Answer]:A + +## Question 5: UptimeRobot Account and Alert Contacts + +6 monitors are needed (`/health`, `/`, `/admin` × test/production). Do you have an UptimeRobot +account already (e.g. from the reference `SlpSoftware` project), and where should alerts go? + +A) Existing account, reuse it — alert contact(s): describe after [Answer]: below (email, and/or + any other channel already configured there) +B) Need a new account/monitor group set up from scratch — walk me through it +C) Not sure yet — document the 6 monitors' exact URLs and recommended check interval as a checklist, + I'll create them myself + +X) Other (please describe after [Answer]: tag below) + +[Answer]:A — existing account, alert contact to be confirmed + +## Question 6: Umami Website Entries (Admin SPA) + +Two new website entries are needed on the existing `analytics.slpsoftware.nl` instance — one per +environment — to obtain the `VITE_UMAMI_WEBSITE_ID_TEST`/`_PRODUCTION` values the CI workflow already +expects as Gitea variables (`continuous_integration.yaml` reads `vars.VITE_UMAMI_WEBSITE_ID_TEST`/ +`_PRODUCTION`). + +A) I have access to the Umami instance — I'll create the two entries and provide the website IDs + after [Answer]: below +B) Walk me through creating them (Umami's own UI/API steps) +C) Not yet — document it as a TODO with the exact variable names the pipeline expects, I'll fill + them in before the first real deploy + +X) Other (please describe after [Answer]: tag below) + +[Answer]:A — website IDs to be added directly to Gitea variables once created (kept out of this doc) diff --git a/frontend/src/components/cms/AddCmsInstanceDialog.test.tsx b/frontend/src/components/cms/AddCmsInstanceDialog.test.tsx index faa321b..7541120 100644 --- a/frontend/src/components/cms/AddCmsInstanceDialog.test.tsx +++ b/frontend/src/components/cms/AddCmsInstanceDialog.test.tsx @@ -16,7 +16,7 @@ beforeEach(() => { async function openDialog() { mockAuthenticated(); renderApp('/cms'); - await screen.findByTestId('cms-add-button', {}, { timeout: 5000 }); + await screen.findByTestId('cms-add-button', {}, { timeout: 10000 }); await userEvent.click(screen.getByTestId('cms-add-button')); expect(screen.getByTestId('add-cms-name')).toBeInTheDocument(); } diff --git a/frontend/src/components/cms/SetStatusDialog.test.tsx b/frontend/src/components/cms/SetStatusDialog.test.tsx index f819b1b..0d93064 100644 --- a/frontend/src/components/cms/SetStatusDialog.test.tsx +++ b/frontend/src/components/cms/SetStatusDialog.test.tsx @@ -13,7 +13,7 @@ beforeEach(() => { async function openSetStatusDialog() { mockAuthenticated(); renderApp('/cms'); - const triggers = await screen.findAllByTestId('cms-instance-actions-trigger', {}, { timeout: 5000 }); + const triggers = await screen.findAllByTestId('cms-instance-actions-trigger', {}, { timeout: 10000 }); await userEvent.click(triggers[0]); await userEvent.click(await screen.findByTestId('cms-instance-set-status')); expect(screen.getByTestId('set-status-select')).toBeInTheDocument(); diff --git a/frontend/src/components/users/InviteUserDialog.test.tsx b/frontend/src/components/users/InviteUserDialog.test.tsx index adc8b07..46f79a2 100644 --- a/frontend/src/components/users/InviteUserDialog.test.tsx +++ b/frontend/src/components/users/InviteUserDialog.test.tsx @@ -14,7 +14,7 @@ beforeEach(() => { async function openDialog() { mockAuthenticated(); renderApp('/users'); - await screen.findByTestId('users-invite-button', {}, { timeout: 5000 }); + await screen.findByTestId('users-invite-button', {}, { timeout: 10000 }); await userEvent.click(screen.getByTestId('users-invite-button')); expect(screen.getByTestId('invite-dialog-email')).toBeInTheDocument(); } diff --git a/frontend/src/pages/AccessDeniedPage.test.tsx b/frontend/src/pages/AccessDeniedPage.test.tsx index d5237de..772d293 100644 --- a/frontend/src/pages/AccessDeniedPage.test.tsx +++ b/frontend/src/pages/AccessDeniedPage.test.tsx @@ -12,21 +12,21 @@ describe('AccessDeniedPage', () => { it('renders the 403 title and message', async () => { renderApp('/403'); - expect(await screen.findByTestId('access-denied-title', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('access-denied-title', {}, { timeout: 10000 })).toBeInTheDocument(); expect(screen.getByTestId('access-denied-message')).toBeInTheDocument(); }); it('renders the back to dashboard button', async () => { renderApp('/403'); - expect(await screen.findByTestId('access-denied-back-button', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('access-denied-back-button', {}, { timeout: 10000 })).toBeInTheDocument(); }); it('navigates to login when back button is clicked as guest', async () => { mockGuest(); renderApp('/403'); - const button = await screen.findByTestId('access-denied-back-button', {}, { timeout: 5000 }); + const button = await screen.findByTestId('access-denied-back-button', {}, { timeout: 10000 }); await userEvent.click(button); expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument(); diff --git a/frontend/src/pages/CmsPage.test.tsx b/frontend/src/pages/CmsPage.test.tsx index fd98470..a36bca9 100644 --- a/frontend/src/pages/CmsPage.test.tsx +++ b/frontend/src/pages/CmsPage.test.tsx @@ -20,7 +20,7 @@ describe('CmsPage', () => { mockAuthenticated(); renderApp('/cms'); - expect(await screen.findByTestId('cms-title', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('cms-title', {}, { timeout: 10000 })).toBeInTheDocument(); expect(screen.getByTestId('cms-add-button')).toBeInTheDocument(); }); @@ -28,7 +28,7 @@ describe('CmsPage', () => { mockAuthenticated(); renderApp('/cms'); - const rows = await screen.findAllByTestId('cms-instance-row', {}, { timeout: 5000 }); + const rows = await screen.findAllByTestId('cms-instance-row', {}, { timeout: 10000 }); expect(rows.length).toBe(2); }); @@ -37,7 +37,7 @@ describe('CmsPage', () => { server.use(http.get(CMS_URL, () => HttpResponse.json([]))); renderApp('/cms'); - expect(await screen.findByTestId('cms-empty-state', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('cms-empty-state', {}, { timeout: 10000 })).toBeInTheDocument(); expect(screen.getByTestId('cms-empty-add-button')).toBeInTheDocument(); }); @@ -45,7 +45,7 @@ describe('CmsPage', () => { mockAuthenticated(); renderApp('/cms'); - await screen.findByTestId('cms-add-button', {}, { timeout: 5000 }); + await screen.findByTestId('cms-add-button', {}, { timeout: 10000 }); await userEvent.click(screen.getByTestId('cms-add-button')); expect(await screen.findByTestId('add-cms-name')).toBeInTheDocument(); @@ -55,7 +55,7 @@ describe('CmsPage', () => { mockAuthenticated(); renderApp('/cms'); - await screen.findByTestId('cms-add-button', {}, { timeout: 5000 }); + await screen.findByTestId('cms-add-button', {}, { timeout: 10000 }); await userEvent.click(screen.getByTestId('cms-add-button')); await userEvent.type(screen.getByTestId('add-cms-name'), 'New CMS'); @@ -63,7 +63,7 @@ describe('CmsPage', () => { await userEvent.type(screen.getByTestId('add-cms-apikey-input'), 'secret-key'); await userEvent.click(screen.getByTestId('add-cms-submit')); - const rows = await screen.findAllByTestId('cms-instance-row', {}, { timeout: 5000 }); + const rows = await screen.findAllByTestId('cms-instance-row', {}, { timeout: 10000 }); expect(rows.length).toBe(3); }); diff --git a/frontend/src/pages/NotFoundPage.test.tsx b/frontend/src/pages/NotFoundPage.test.tsx index fae2a14..7278e57 100644 --- a/frontend/src/pages/NotFoundPage.test.tsx +++ b/frontend/src/pages/NotFoundPage.test.tsx @@ -12,21 +12,21 @@ describe('NotFoundPage', () => { it('renders for an unknown route', async () => { renderApp('/this-route-does-not-exist'); - expect(await screen.findByTestId('not-found-title', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('not-found-title', {}, { timeout: 10000 })).toBeInTheDocument(); expect(screen.getByTestId('not-found-message')).toBeInTheDocument(); }); it('renders the back to dashboard button', async () => { renderApp('/some/nonexistent/path'); - expect(await screen.findByTestId('not-found-back-button', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('not-found-back-button', {}, { timeout: 10000 })).toBeInTheDocument(); }); it('navigates to login when back button is clicked as guest', async () => { mockGuest(); renderApp('/this-does-not-exist'); - const button = await screen.findByTestId('not-found-back-button', {}, { timeout: 5000 }); + const button = await screen.findByTestId('not-found-back-button', {}, { timeout: 10000 }); await userEvent.click(button); expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument(); diff --git a/frontend/src/pages/ProfilePage.test.tsx b/frontend/src/pages/ProfilePage.test.tsx index 6fa98f3..ca7484b 100644 --- a/frontend/src/pages/ProfilePage.test.tsx +++ b/frontend/src/pages/ProfilePage.test.tsx @@ -16,7 +16,7 @@ describe('ProfilePage', () => { mockAuthenticated(); renderApp('/profile'); - expect(await screen.findByTestId('profile-title', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('profile-title', {}, { timeout: 10000 })).toBeInTheDocument(); expect(screen.getByTestId('profile-name-input')).toBeInTheDocument(); expect(screen.getByTestId('profile-email-input')).toBeInTheDocument(); expect(screen.getByTestId('profile-role-badge')).toBeInTheDocument(); @@ -61,7 +61,7 @@ describe('ProfilePage', () => { await userEvent.type(nameInput, 'New Name'); await userEvent.click(screen.getByTestId('profile-save-button')); - expect(await screen.findByText(/profile updated/i, {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByText(/profile updated/i, {}, { timeout: 10000 })).toBeInTheDocument(); }); it('shows validation error for invalid email', async () => { diff --git a/frontend/src/pages/SettingsPage.test.tsx b/frontend/src/pages/SettingsPage.test.tsx index f8ba4c1..c8e3331 100644 --- a/frontend/src/pages/SettingsPage.test.tsx +++ b/frontend/src/pages/SettingsPage.test.tsx @@ -16,21 +16,21 @@ describe('SettingsPage', () => { mockAuthenticated(); renderApp('/settings'); - expect(await screen.findByTestId('settings-title', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('settings-title', {}, { timeout: 10000 })).toBeInTheDocument(); }); it('shows the current availability status badge', async () => { mockAuthenticated(); renderApp('/settings'); - expect(await screen.findByTestId('availability-badge', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('availability-badge', {}, { timeout: 10000 })).toBeInTheDocument(); }); it('renders all three availability mode buttons', async () => { mockAuthenticated(); renderApp('/settings'); - await screen.findByTestId('availability-mode-selector', {}, { timeout: 5000 }); + await screen.findByTestId('availability-mode-selector', {}, { timeout: 10000 }); expect(screen.getByTestId('mode-option-Available')).toBeInTheDocument(); expect(screen.getByTestId('mode-option-Maintenance')).toBeInTheDocument(); expect(screen.getByTestId('mode-option-NotAvailable')).toBeInTheDocument(); @@ -40,7 +40,7 @@ describe('SettingsPage', () => { mockAuthenticated(); renderApp('/settings'); - await screen.findByTestId('availability-mode-selector', {}, { timeout: 5000 }); + await screen.findByTestId('availability-mode-selector', {}, { timeout: 10000 }); const maintenanceBtn = screen.getByTestId('mode-option-Maintenance'); await userEvent.click(maintenanceBtn); @@ -51,10 +51,10 @@ describe('SettingsPage', () => { mockAuthenticated(); renderApp('/settings'); - await screen.findByTestId('availability-save-button', {}, { timeout: 5000 }); + await screen.findByTestId('availability-save-button', {}, { timeout: 10000 }); await userEvent.click(screen.getByTestId('availability-save-button')); - expect(await screen.findByText(/availability updated/i, {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByText(/availability updated/i, {}, { timeout: 10000 })).toBeInTheDocument(); }); it('shows error toast when save fails', async () => { @@ -66,10 +66,10 @@ describe('SettingsPage', () => { mockAuthenticated(); renderApp('/settings'); - await screen.findByTestId('availability-save-button', {}, { timeout: 5000 }); + await screen.findByTestId('availability-save-button', {}, { timeout: 10000 }); await userEvent.click(screen.getByTestId('availability-save-button')); - expect(await screen.findByText(/something went wrong/i, {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByText(/something went wrong/i, {}, { timeout: 10000 })).toBeInTheDocument(); }); it('disables the availability controls and shows a banner when master-controlled', async () => { @@ -86,7 +86,7 @@ describe('SettingsPage', () => { mockAuthenticated(); renderApp('/settings'); - expect(await screen.findByTestId('availability-master-controlled-banner', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('availability-master-controlled-banner', {}, { timeout: 10000 })).toBeInTheDocument(); expect(screen.getByTestId('mode-option-Available')).toBeDisabled(); expect(screen.getByTestId('mode-option-Maintenance')).toBeDisabled(); expect(screen.getByTestId('mode-option-NotAvailable')).toBeDisabled(); @@ -98,7 +98,7 @@ describe('SettingsPage', () => { mockAuthenticated(); renderApp('/settings'); - await screen.findByTestId('availability-save-button', {}, { timeout: 5000 }); + await screen.findByTestId('availability-save-button', {}, { timeout: 10000 }); expect(screen.queryByTestId('availability-master-controlled-banner')).not.toBeInTheDocument(); expect(screen.getByTestId('availability-save-button')).not.toBeDisabled(); }); @@ -112,17 +112,17 @@ describe('SettingsPage', () => { mockAuthenticated(); renderApp('/settings'); - await screen.findByTestId('availability-save-button', {}, { timeout: 5000 }); + await screen.findByTestId('availability-save-button', {}, { timeout: 10000 }); await userEvent.click(screen.getByTestId('availability-save-button')); - expect(await screen.findByText(/master cms controls this status/i, {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByText(/master cms controls this status/i, {}, { timeout: 10000 })).toBeInTheDocument(); }); it('renders all placeholder sections', async () => { mockAuthenticated(); renderApp('/settings'); - await screen.findByTestId('settings-title', {}, { timeout: 5000 }); + await screen.findByTestId('settings-title', {}, { timeout: 10000 }); expect(screen.getByTestId('placeholder-settings.modules.title')).toBeInTheDocument(); expect(screen.getByTestId('placeholder-settings.systemConfig.title')).toBeInTheDocument(); expect(screen.getByTestId('placeholder-settings.branding.title')).toBeInTheDocument(); diff --git a/frontend/src/pages/UsersPage.test.tsx b/frontend/src/pages/UsersPage.test.tsx index fa46b40..5d4a7dd 100644 --- a/frontend/src/pages/UsersPage.test.tsx +++ b/frontend/src/pages/UsersPage.test.tsx @@ -19,7 +19,7 @@ describe('UsersPage', () => { mockAuthenticated(); renderApp('/users'); - expect(await screen.findByTestId('users-page', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByTestId('users-page', {}, { timeout: 10000 })).toBeInTheDocument(); expect(screen.getByTestId('users-invite-button')).toBeInTheDocument(); }); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d04e770..6502da0 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -42,7 +42,7 @@ export default defineConfig(({ command }) => ({ globals: true, environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], - testTimeout: 15000, + testTimeout: 20000, css: true, coverage: { provider: 'v8', diff --git a/src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html b/src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html index 2b8abf8..ff261f7 100644 --- a/src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html +++ b/src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html @@ -20,7 +20,7 @@ } @media (prefers-color-scheme: dark) { body { background: #141414; color: #ededed; } - code { background: #262626; } + code { background: #262626; color: #141414; } a { color: #ff6b6b; } } main { max-width: 34rem; } @@ -28,6 +28,7 @@ p { margin: 0 0 1rem; } code { background: #ececec; + color: #1a1a1a; padding: 0.15em 0.4em; border-radius: 4px; font-family: ui-monospace, "Cascadia Code", Consolas, monospace; diff --git a/src/SlpModularCms.Api/SlpModularCms.Api.csproj b/src/SlpModularCms.Api/SlpModularCms.Api.csproj index 85c0df0..03b7bc8 100644 --- a/src/SlpModularCms.Api/SlpModularCms.Api.csproj +++ b/src/SlpModularCms.Api/SlpModularCms.Api.csproj @@ -35,22 +35,21 @@ - - $(MSBuildProjectDirectory)/../../frontend - - - - - - - - - - -