diff --git a/.github/workflows/a11y-scan.yaml b/.github/workflows/a11y-scan.yaml new file mode 100644 index 000000000..13c931eef --- /dev/null +++ b/.github/workflows/a11y-scan.yaml @@ -0,0 +1,226 @@ +name: Accessibility Scanner + +on: + workflow_dispatch: + # TODO: remove push trigger before merging — workflow_dispatch only works from the default branch + push: + branches: + - a11y-scanner-setup + +jobs: + build: + uses: ./.github/workflows/build-container-images.yaml + + accessibility_scanner: + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + env: + AUTH_INITIAL_ADMIN_USERNAME: admin@example.com + AUTH_INITIAL_ADMIN_PASSWORD: secret + steps: + - name: Create Docker network + run: docker network create a11y-network + + - name: Start database + run: | + docker run -d \ + --network a11y-network \ + --name postgres \ + -e POSTGRES_USER=admin \ + -e POSTGRES_PASSWORD=secret \ + -e POSTGRES_DB=cccc \ + pgvector/pgvector:pg16 + + - name: Wait for database + run: | + db_ready=false + for i in $(seq 1 30); do + if docker exec postgres pg_isready -U admin -d cccc; then + echo "Database is ready" + db_ready=true + break + fi + echo "Waiting for database... ($i/30)" + sleep 1 + done + + if [ "$db_ready" != "true" ]; then + echo "Database did not become ready in time" + exit 1 + fi + + - uses: actions/download-artifact@v8 + with: + name: backend + path: /tmp/ + + - name: Start backend + run: | + docker load --input /tmp/backend.tar + docker run -d \ + --network a11y-network \ + --name backend \ + -e BASE_URL="http://localhost:3080" \ + -e DB_URL="postgres://admin:secret@postgres:5432/cccc" \ + -e C4_DB_RETRY_DELAY="6000" \ + -e NODE_ENV="development" \ + -e AUTH_ENABLE_PASSWORD="true" \ + -e AUTH_INITIAL_ADMIN_USERNAME="${{ env.AUTH_INITIAL_ADMIN_USERNAME }}" \ + -e AUTH_INITIAL_ADMIN_PASSWORD="${{ env.AUTH_INITIAL_ADMIN_PASSWORD }}" \ + -e SESSION_SECRET="a11y-scanner-session-secret" \ + backend:commit-${{ github.sha }} + + - uses: actions/download-artifact@v8 + with: + name: frontend + path: /tmp/ + + - name: Start frontend + run: | + docker load --input /tmp/frontend.tar + docker run -d \ + --network a11y-network \ + --name frontend \ + frontend:commit-${{ github.sha }} + + - name: Start gateway proxy + run: | + docker run -d \ + -p 3080:3080 \ + --network a11y-network \ + --name gateway \ + -e FRONTEND_PORT=3080 \ + -e PORT=3080 \ + -e BACKEND_PORT=3000 \ + ghcr.io/codecentric/c4-genai-suite/dev-helper/caddy-gateway-proxy:latest + + - name: Wait for application + run: | + app_ready=false + for i in $(seq 1 60); do + if curl -sf http://localhost:3080/api/health > /dev/null 2>&1; then + echo "Application is ready!" + app_ready=true + break + fi + echo "Waiting for application... ($i/60)" + sleep 2 + done + + if [ "$app_ready" != "true" ]; then + echo "Application did not become ready in time" + exit 1 + fi + + - name: Authenticate via API + id: auth + run: | + # Login via backend API and capture session cookie + curl -s -c /tmp/cookies.txt \ + -X POST http://localhost:3080/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"${{ env.AUTH_INITIAL_ADMIN_USERNAME }}","password":"${{ env.AUTH_INITIAL_ADMIN_PASSWORD }}"}' + + # Convert Netscape cookie jar to Playwright auth_context JSON + # HttpOnly cookies are prefixed with #HttpOnly_ in curl's cookie jar + AUTH_CONTEXT=$(awk ' + /^#HttpOnly_/ { + sub(/^#HttpOnly_/, "", $1) + printf "{\"name\":\"%s\",\"value\":\"%s\",\"domain\":\"%s\",\"path\":\"%s\",\"secure\":%s,\"httpOnly\":true}\n", + $6, $7, $1, $3, ($4=="TRUE"?"true":"false") + next + } + !/^#/ && NF { + printf "{\"name\":\"%s\",\"value\":\"%s\",\"domain\":\"%s\",\"path\":\"%s\",\"secure\":%s,\"httpOnly\":false}\n", + $6, $7, $1, $3, ($4=="TRUE"?"true":"false") + } + ' /tmp/cookies.txt | jq -sc '{ + username: "${{ env.AUTH_INITIAL_ADMIN_USERNAME }}", + password: "${{ env.AUTH_INITIAL_ADMIN_PASSWORD }}", + cookies: ., + localStorage: {} + }') + + echo "auth_context=$AUTH_CONTEXT" >> "$GITHUB_OUTPUT" + + - name: Verify axe timing issue (immediate vs after wait) + run: | + npm install playwright @axe-core/playwright + npx playwright install chromium --with-deps + + node << 'SCRIPT' + const { chromium } = require('playwright'); + const { default: AxeBuilder } = require('@axe-core/playwright'); + const AUTH_CONTEXT = JSON.parse(process.env.AUTH_CONTEXT); + + (async () => { + const browser = await chromium.launch(); + const context = await browser.newContext({ + storageState: { + cookies: AUTH_CONTEXT.cookies.map(c => ({ ...c, sameSite: 'Lax', expires: -1 })), + origins: [] + } + }); + + const urls = [ + 'http://localhost:3080/admin/theme', + 'http://localhost:3080/admin/dashboard', + 'http://localhost:3080/chat' + ]; + + for (const url of urls) { + const page = await context.newPage(); + await page.goto(url); + + const immediate = await new AxeBuilder({ page }).analyze(); + console.log(`\n=== ${url} ===`); + console.log('Without wait:', immediate.violations.map(v => v.id)); + + await page.waitForLoadState('networkidle'); + const waited = await new AxeBuilder({ page }).analyze(); + console.log('With wait: ', waited.violations.map(v => v.id)); + + await page.close(); + } + + await browser.close(); + })(); + SCRIPT + env: + AUTH_CONTEXT: ${{ steps.auth.outputs.auth_context }} + + - uses: github/accessibility-scanner@v3 + with: + urls: | + http://localhost:3080 + http://localhost:3080/chat + http://localhost:3080/admin/dashboard + http://localhost:3080/admin/theme + http://localhost:3080/admin/files + http://localhost:3080/admin/users + http://localhost:3080/admin/user-groups + http://localhost:3080/admin/audit-log + repository: ${{ github.repository }} + token: ${{ secrets.GH_TOKEN_A11Y }} + cache_key: cached_results-c4-local.json + auth_context: ${{ steps.auth.outputs.auth_context }} + open_grouped_issues: true + skip_copilot_assignment: true + include_screenshots: true + # login_url: # Optional: URL of the login page if authentication is required + # username: # Optional: Username for authentication + # password: ${{ secrets.PASSWORD }} # Optional: Password for authentication (use secrets!) + # reduced_motion: no-preference # Optional: Playwright reduced motion configuration option + # color_scheme: light # Optional: Playwright color scheme configuration option + # scans: '["axe","reflow-scan"]' # Optional: An array of scans (or plugins) to be performed. If not provided, only Axe will be performed. + + - name: Get backend logs + if: ${{ !cancelled() }} + run: docker logs backend + + - name: Get gateway logs + if: ${{ !cancelled() }} + run: docker logs gateway diff --git a/frontend/src/pages/login/LoginPage.tsx b/frontend/src/pages/login/LoginPage.tsx index e3651c3b5..0b052191f 100644 --- a/frontend/src/pages/login/LoginPage.tsx +++ b/frontend/src/pages/login/LoginPage.tsx @@ -122,11 +122,22 @@ function LoginForm() { )}