diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 7c0b57db..15f692ef 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -523,55 +523,12 @@ jobs: GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }} run: pnpm run check:locale - # ── Quality Gates (informational, don't block pipeline) ─────────────── - - name: 🌐 HTML DOM Validation + - name: 🌐 External API Smoke Test (Umami & Sentry) if: always() && steps.deps.outcome == 'success' - continue-on-error: true env: - NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }} - GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }} - run: pnpm check:html - - name: 🔒 Security Headers Scan - if: always() && steps.deps.outcome == 'success' - continue-on-error: true - env: - NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }} - GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }} - run: pnpm check:security - - name: 🔗 Lychee Deep Link Crawl - if: always() && steps.deps.outcome == 'success' - continue-on-error: true - env: - NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }} - GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }} - run: pnpm check:links - - name: 🖼️ Dynamic Asset & Image Integrity Scan - if: always() && steps.deps.outcome == 'success' - continue-on-error: true - env: - NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }} - GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }} - PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium - CHROME_PATH: /usr/bin/chromium - run: pnpm check:assets - - name: ⚡ Lighthouse CI - if: always() && steps.deps.outcome == 'success' - continue-on-error: true - env: - NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }} - GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }} - CHROME_PATH: /usr/bin/chromium - PAGESPEED_LIMIT: 8 - run: pnpm run pagespeed:test - - name: ♿ WCAG Audit - if: always() && steps.deps.outcome == 'success' - continue-on-error: true - env: - NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }} - GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }} - CHROME_PATH: /usr/bin/chromium - PAGESPEED_LIMIT: 8 - run: pnpm run check:wcag + UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }} + run: pnpm run check:apis # ────────────────────────────────────────────────────────────────────────────── # JOB 7: Notifications diff --git a/.gitea/workflows/qa.yml b/.gitea/workflows/qa.yml new file mode 100644 index 00000000..c90ae3ea --- /dev/null +++ b/.gitea/workflows/qa.yml @@ -0,0 +1,12 @@ +name: Nightly QA + +on: + schedule: + - cron: '0 3 * * *' + workflow_dispatch: + +jobs: + run-qa: + name: 🛡️ Nightly Quality Assurance + uses: mmintel/at-mintel/.gitea/workflows/quality-assurance-template.yml@main + secrets: inherit diff --git a/package.json b/package.json index acc44653..a8d7df3c 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,8 @@ "check:security": "tsx ./scripts/check-security.ts", "check:links": "bash ./scripts/check-links.sh", "check:assets": "tsx ./scripts/check-broken-assets.ts", + "check:forms": "tsx ./scripts/check-forms.ts", + "check:apis": "tsx ./scripts/check-apis.ts", "pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts", "pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts", "cms:migrate": "payload migrate", diff --git a/scripts/check-apis.ts b/scripts/check-apis.ts new file mode 100644 index 00000000..87dafffa --- /dev/null +++ b/scripts/check-apis.ts @@ -0,0 +1,119 @@ +import axios from 'axios'; +import dns from 'dns'; +import { promisify } from 'util'; +import url from 'url'; + +const resolve4 = promisify(dns.resolve4); + +// This script verifies that external logging and analytics APIs are reachable +// from the deployment environment (which could be behind corporate firewalls or VPNs). + +const umamiEndpoint = process.env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me'; +const sentryDsn = process.env.SENTRY_DSN || ''; + +async function checkUmami() { + console.log(`\n🔍 Checking Umami Analytics API Availability...`); + console.log(` Endpoint: ${umamiEndpoint}`); + + try { + // Umami usually exposes a /api/heartbeat or /api/health if we know the route. + // Trying root or /api/auth/verify (which will give 401 but proves routing works). + // A simple GET to the configured endpoint should return a 200 or 401, not a 5xx/timeout. + const response = await axios.get(`${umamiEndpoint.replace(/\/$/, '')}/api/health`, { + timeout: 5000, + validateStatus: () => true, // Accept any status, we just want to know it's reachable and not 5xx + }); + + // As long as it's not a 502/503/504 Bad Gateway/Timeout, the service is "up" from our perspective + if (response.status >= 500) { + throw new Error(`Umami API responded with server error HTTP ${response.status}`); + } + + console.log(` ✅ Umami Analytics is reachable (HTTP ${response.status})`); + return true; + } catch (err: any) { + // If /api/health fails completely, maybe try a DNS check as a fallback + try { + console.warn(` ⚠️ HTTP check failed, falling back to DNS resolution...`); + const umamiHost = new url.URL(umamiEndpoint).hostname; + await resolve4(umamiHost); + console.log(` ✅ Umami Analytics DNS resolved successfully (${umamiHost})`); + return true; + } catch (dnsErr: any) { + console.error( + ` ❌ CRITICAL: Umami Analytics is completely unreachable! ${err.message} | DNS: ${dnsErr.message}`, + ); + return false; + } + } +} + +async function checkSentry() { + console.log(`\n🔍 Checking Glitchtip/Sentry Error Tracking Availability...`); + + if (!sentryDsn) { + console.log(` ℹ️ No SENTRY_DSN provided in environment. Skipping.`); + return true; + } + + try { + const parsedDsn = new url.URL(sentryDsn); + const host = parsedDsn.hostname; + console.log(` Host: ${host}`); + + // We do a DNS lookup to ensure the runner can actually resolve the tracking server + const addresses = await resolve4(host); + + if (addresses && addresses.length > 0) { + console.log(` ✅ Glitchtip/Sentry domain resolved: ${addresses[0]}`); + + // Optional: Quick TCP/HTTP check to the host root (Glitchtip usually runs on 80/443 root) + try { + const proto = parsedDsn.protocol || 'https:'; + await axios.get(`${proto}//${host}/api/0/`, { + timeout: 5000, + validateStatus: () => true, + }); + console.log(` ✅ Glitchtip/Sentry API root responds to HTTP.`); + } catch (ignore) { + console.log( + ` ⚠️ Glitchtip/Sentry HTTP ping failed or timed out, but DNS is valid. Proceeding.`, + ); + } + + return true; + } + throw new Error('No IP addresses found for DSN host'); + } catch (err: any) { + console.error( + ` ❌ CRITICAL: Glitchtip/Sentry DSN is invalid or hostname is unresolvable! ${err.message}`, + ); + return false; + } +} + +async function main() { + console.log('🚀 Starting External API Connectivity Smoke Test...'); + + let hasErrors = false; + + const umamiOk = await checkUmami(); + if (!umamiOk) hasErrors = true; + + const sentryOk = await checkSentry(); + if (!sentryOk) hasErrors = true; + + if (hasErrors) { + console.error( + `\n🚨 POST-DEPLOY CHECK FAILED: One or more critical external APIs are unreachable.`, + ); + console.error(` This might mean the deployment environment lacks outbound internet access, `); + console.error(` DNS is misconfigured, or the upstream services are down.`); + process.exit(1); + } + + console.log(`\n🎉 SUCCESS: All required external APIs are reachable!`); + process.exit(0); +} + +main();