Add missing emergency token environment variable to all E2E test workflows to fix security teardown failures in CI. Without this token, the emergency reset endpoint returns 501 "not configured", causing test teardown to fail and leaving ACL enabled, which blocks 83 subsequent tests. Changes: Add CHARON_EMERGENCY_TOKEN to docker-build.yml test-image job Add CHARON_EMERGENCY_TOKEN to e2e-tests.yml e2e-tests job Add CHARON_EMERGENCY_TOKEN to playwright.yml playwright job Verified: Docker build strategy already optimal (build once, push to both GHCR + Docker Hub) Testing strategy correct (test once by digest, validates both registries) All workflows now have environment parity with local development setup Requires GitHub repository secret: Name: CHARON_EMERGENCY_TOKEN Value: 64-char hex token (e.g., from openssl rand -hex 32) Related: Emergency endpoint rate limiting removal (proper fix) Local emergency token configuration (.env, docker-compose.local.yml) Security test suite teardown mechanism Refs #550
567 lines
18 KiB
YAML
567 lines
18 KiB
YAML
# E2E Tests Workflow
|
|
# Runs Playwright E2E tests with sharding for faster execution
|
|
# and collects frontend code coverage via @bgotink/playwright-coverage
|
|
#
|
|
# Coverage Architecture:
|
|
# - Backend: Docker container at localhost:8080 (API)
|
|
# - Frontend: Vite dev server at localhost:3000 (serves source files)
|
|
# - Tests hit Vite, which proxies API calls to Docker
|
|
# - V8 coverage maps directly to source files for accurate reporting
|
|
#
|
|
# Triggers:
|
|
# - Pull requests to main/develop (with path filters)
|
|
# - Push to main branch
|
|
# - Manual dispatch with browser selection
|
|
#
|
|
# Jobs:
|
|
# 1. build: Build Docker image and upload as artifact
|
|
# 2. e2e-tests: Run tests in parallel shards with coverage
|
|
# 3. merge-reports: Combine HTML reports from all shards
|
|
# 4. comment-results: Post test results as PR comment
|
|
# 5. upload-coverage: Merge and upload E2E coverage to Codecov
|
|
# 6. e2e-results: Status check to block merge on failure
|
|
|
|
name: E2E Tests
|
|
|
|
on:
|
|
pull_request:
|
|
branches:
|
|
- main
|
|
- development
|
|
- 'feature/**'
|
|
paths:
|
|
- 'frontend/**'
|
|
- 'backend/**'
|
|
- 'tests/**'
|
|
- 'playwright.config.js'
|
|
- '.github/workflows/e2e-tests.yml'
|
|
|
|
push:
|
|
branches:
|
|
- main
|
|
- development
|
|
- 'feature/**'
|
|
paths:
|
|
- 'frontend/**'
|
|
- 'backend/**'
|
|
- 'tests/**'
|
|
- 'playwright.config.js'
|
|
|
|
workflow_dispatch:
|
|
inputs:
|
|
browser:
|
|
description: 'Browser to test'
|
|
required: false
|
|
default: 'chromium'
|
|
type: choice
|
|
options:
|
|
- chromium
|
|
- firefox
|
|
- webkit
|
|
- all
|
|
|
|
env:
|
|
NODE_VERSION: '20'
|
|
GO_VERSION: '1.25.6'
|
|
GOTOOLCHAIN: auto
|
|
REGISTRY: ghcr.io
|
|
IMAGE_NAME: ${{ github.repository_owner }}/charon
|
|
|
|
concurrency:
|
|
group: e2e-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
# Build application once, share across test shards
|
|
build:
|
|
name: Build Application
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
|
|
|
- name: Set up Go
|
|
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
|
with:
|
|
go-version: ${{ env.GO_VERSION }}
|
|
cache: true
|
|
|
|
- name: Set up Node.js
|
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
|
with:
|
|
node-version: ${{ env.NODE_VERSION }}
|
|
cache: 'npm'
|
|
|
|
- name: Cache npm dependencies
|
|
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
|
|
with:
|
|
path: ~/.npm
|
|
key: npm-${{ hashFiles('package-lock.json') }}
|
|
restore-keys: npm-
|
|
|
|
- name: Install dependencies
|
|
run: npm ci
|
|
|
|
- name: Set up Docker Buildx
|
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
|
|
|
- name: Build Docker image
|
|
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
|
with:
|
|
context: .
|
|
file: ./Dockerfile
|
|
push: false
|
|
load: true
|
|
tags: charon:e2e-test
|
|
cache-from: type=gha
|
|
cache-to: type=gha,mode=max
|
|
|
|
- name: Save Docker image
|
|
run: docker save charon:e2e-test -o charon-e2e-image.tar
|
|
|
|
- name: Upload Docker image artifact
|
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
|
with:
|
|
name: docker-image
|
|
path: charon-e2e-image.tar
|
|
retention-days: 1
|
|
|
|
# Run tests in parallel shards
|
|
e2e-tests:
|
|
name: E2E Tests (Shard ${{ matrix.shard }}/${{ matrix.total-shards }})
|
|
runs-on: ubuntu-latest
|
|
needs: build
|
|
timeout-minutes: 30
|
|
env:
|
|
# Required for security teardown (emergency reset fallback when ACL blocks API)
|
|
CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }}
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
shard: [1, 2, 3, 4]
|
|
total-shards: [4]
|
|
browser: [chromium]
|
|
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
|
|
|
- name: Set up Node.js
|
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
|
with:
|
|
node-version: ${{ env.NODE_VERSION }}
|
|
cache: 'npm'
|
|
|
|
- name: Download Docker image
|
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
|
with:
|
|
name: docker-image
|
|
|
|
- name: Load Docker image
|
|
run: |
|
|
docker load -i charon-e2e-image.tar
|
|
docker images | grep charon
|
|
|
|
- name: Generate ephemeral encryption key
|
|
run: |
|
|
# Generate a unique, ephemeral encryption key for this CI run
|
|
# Key is 32 bytes, base64-encoded as required by CHARON_ENCRYPTION_KEY
|
|
echo "CHARON_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> $GITHUB_ENV
|
|
echo "✅ Generated ephemeral encryption key for E2E tests"
|
|
|
|
- name: Start test environment
|
|
run: |
|
|
# Use the committed docker-compose.playwright.yml for E2E testing
|
|
# Note: Using pre-built image loaded from artifact - no rebuild needed
|
|
docker compose -f .docker/compose/docker-compose.playwright.yml up -d
|
|
echo "✅ Container started via docker-compose.playwright.yml"
|
|
|
|
- name: Wait for service health
|
|
run: |
|
|
echo "⏳ Waiting for Charon to be healthy..."
|
|
MAX_ATTEMPTS=30
|
|
ATTEMPT=0
|
|
|
|
while [[ ${ATTEMPT} -lt ${MAX_ATTEMPTS} ]]; do
|
|
ATTEMPT=$((ATTEMPT + 1))
|
|
echo "Attempt ${ATTEMPT}/${MAX_ATTEMPTS}..."
|
|
|
|
if curl -sf http://localhost:8080/api/v1/health > /dev/null 2>&1; then
|
|
echo "✅ Charon is healthy!"
|
|
curl -s http://localhost:8080/api/v1/health | jq .
|
|
exit 0
|
|
fi
|
|
|
|
sleep 2
|
|
done
|
|
|
|
echo "❌ Health check failed"
|
|
docker compose -f .docker/compose/docker-compose.playwright.yml logs
|
|
exit 1
|
|
|
|
- name: Install dependencies
|
|
run: npm ci
|
|
|
|
- name: Cache Playwright browsers
|
|
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
|
|
with:
|
|
path: ~/.cache/ms-playwright
|
|
key: playwright-${{ matrix.browser }}-${{ hashFiles('package-lock.json') }}
|
|
restore-keys: playwright-${{ matrix.browser }}-
|
|
|
|
- name: Install Playwright browsers
|
|
run: npx playwright install --with-deps ${{ matrix.browser }}
|
|
|
|
- name: Install Frontend Dependencies
|
|
run: |
|
|
echo "📦 Installing frontend dependencies..."
|
|
cd frontend
|
|
npm ci
|
|
|
|
- name: Start Vite dev server for coverage
|
|
run: |
|
|
echo "🚀 Starting Vite dev server for E2E coverage..."
|
|
cd frontend
|
|
|
|
# Use port 5173 (Vite default) with --strictPort to fail if busy
|
|
VITE_PORT=5173
|
|
npx vite --port ${VITE_PORT} --strictPort > /tmp/vite.log 2>&1 &
|
|
VITE_PID=$!
|
|
echo "VITE_PID=${VITE_PID}" >> $GITHUB_ENV
|
|
echo "VITE_PORT=${VITE_PORT}" >> $GITHUB_ENV
|
|
|
|
# Wait for Vite to be ready
|
|
echo "⏳ Waiting for Vite to start on port ${VITE_PORT}..."
|
|
MAX_WAIT=60
|
|
WAITED=0
|
|
while [[ ${WAITED} -lt ${MAX_WAIT} ]]; do
|
|
if curl -sf http://localhost:${VITE_PORT} > /dev/null 2>&1; then
|
|
echo "✅ Vite dev server ready at http://localhost:${VITE_PORT}"
|
|
exit 0
|
|
fi
|
|
sleep 1
|
|
WAITED=$((WAITED + 1))
|
|
done
|
|
|
|
echo "❌ Vite failed to start"
|
|
cat /tmp/vite.log
|
|
exit 1
|
|
|
|
- name: Run E2E tests (Shard ${{ matrix.shard }}/${{ matrix.total-shards }})
|
|
run: |
|
|
npx playwright test \
|
|
--project=${{ matrix.browser }} \
|
|
--shard=${{ matrix.shard }}/${{ matrix.total-shards }} \
|
|
--reporter=html,json,github
|
|
env:
|
|
# Use Vite dev server for coverage (proxies API to Docker at 8080)
|
|
PLAYWRIGHT_BASE_URL: http://localhost:${{ env.VITE_PORT }}
|
|
CI: true
|
|
TEST_WORKER_INDEX: ${{ matrix.shard }}
|
|
|
|
- name: Stop Vite dev server
|
|
if: always()
|
|
run: |
|
|
if [[ -n "${VITE_PID}" ]]; then
|
|
kill ${VITE_PID} 2>/dev/null || true
|
|
fi
|
|
|
|
- name: Upload test results
|
|
if: always()
|
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
|
with:
|
|
name: test-results-${{ matrix.browser }}-shard-${{ matrix.shard }}
|
|
path: |
|
|
playwright-report/
|
|
test-results/
|
|
retention-days: 7
|
|
|
|
- name: Upload E2E coverage artifact
|
|
if: always()
|
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
|
with:
|
|
name: e2e-coverage-shard-${{ matrix.shard }}
|
|
path: coverage/e2e/
|
|
retention-days: 7
|
|
|
|
- name: Upload test traces on failure
|
|
if: failure()
|
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
|
with:
|
|
name: traces-${{ matrix.browser }}-shard-${{ matrix.shard }}
|
|
path: test-results/**/*.zip
|
|
retention-days: 7
|
|
|
|
- name: Collect Docker logs on failure
|
|
if: failure()
|
|
run: |
|
|
echo "📋 Container logs:"
|
|
docker compose -f .docker/compose/docker-compose.playwright.yml logs > docker-logs-shard-${{ matrix.shard }}.txt 2>&1
|
|
|
|
- name: Upload Docker logs on failure
|
|
if: failure()
|
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
|
with:
|
|
name: docker-logs-shard-${{ matrix.shard }}
|
|
path: docker-logs-shard-${{ matrix.shard }}.txt
|
|
retention-days: 7
|
|
|
|
- name: Cleanup
|
|
if: always()
|
|
run: |
|
|
docker compose -f .docker/compose/docker-compose.playwright.yml down -v 2>/dev/null || true
|
|
|
|
# Merge reports from all shards
|
|
merge-reports:
|
|
name: Merge Test Reports
|
|
runs-on: ubuntu-latest
|
|
needs: e2e-tests
|
|
if: always()
|
|
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
|
|
|
- name: Set up Node.js
|
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
|
with:
|
|
node-version: ${{ env.NODE_VERSION }}
|
|
cache: 'npm'
|
|
|
|
- name: Install dependencies
|
|
run: npm ci
|
|
|
|
- name: Download all test results
|
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
|
with:
|
|
pattern: test-results-*
|
|
path: all-results
|
|
merge-multiple: false
|
|
|
|
- name: Merge Playwright HTML reports
|
|
run: |
|
|
# Create directory for merged results
|
|
mkdir -p merged-results
|
|
|
|
# Find and copy all blob reports
|
|
find all-results -name "*.zip" -exec cp {} merged-results/ \; 2>/dev/null || true
|
|
|
|
# Merge reports if blobs exist
|
|
if ls merged-results/*.zip 1> /dev/null 2>&1; then
|
|
npx playwright merge-reports --reporter html merged-results
|
|
else
|
|
echo "No blob reports found, copying individual reports"
|
|
cp -r all-results/test-results-chromium-shard-1/playwright-report playwright-report 2>/dev/null || mkdir -p playwright-report
|
|
fi
|
|
|
|
- name: Upload merged report
|
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
|
with:
|
|
name: merged-playwright-report
|
|
path: playwright-report/
|
|
retention-days: 30
|
|
|
|
- name: Generate job summary
|
|
run: |
|
|
echo "## E2E Test Results" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
|
|
# Count results from all shards
|
|
TOTAL=0
|
|
PASSED=0
|
|
FAILED=0
|
|
|
|
for dir in all-results/test-results-*/; do
|
|
if [[ -f "${dir}test-results/.last-run.json" ]]; then
|
|
SHARD_STATS=$(cat "${dir}test-results/.last-run.json" 2>/dev/null || echo '{}')
|
|
# Parse stats if available
|
|
fi
|
|
done
|
|
|
|
echo "| Shard | Status |" >> $GITHUB_STEP_SUMMARY
|
|
echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY
|
|
|
|
for i in 1 2 3 4; do
|
|
if [[ -d "all-results/test-results-chromium-shard-${i}" ]]; then
|
|
echo "| Shard ${i} | ✅ Complete |" >> $GITHUB_STEP_SUMMARY
|
|
else
|
|
echo "| Shard ${i} | ❌ Failed |" >> $GITHUB_STEP_SUMMARY
|
|
fi
|
|
done
|
|
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "[View full Playwright report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" >> $GITHUB_STEP_SUMMARY
|
|
|
|
# Comment on PR with results
|
|
comment-results:
|
|
name: Comment Test Results
|
|
runs-on: ubuntu-latest
|
|
needs: [e2e-tests, merge-reports]
|
|
if: github.event_name == 'pull_request' && always()
|
|
permissions:
|
|
pull-requests: write
|
|
|
|
steps:
|
|
- name: Download merged report
|
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
|
with:
|
|
name: merged-playwright-report
|
|
path: playwright-report
|
|
continue-on-error: true
|
|
|
|
- name: Determine test status
|
|
id: status
|
|
run: |
|
|
if [[ "${{ needs.e2e-tests.result }}" == "success" ]]; then
|
|
echo "emoji=✅" >> $GITHUB_OUTPUT
|
|
echo "status=PASSED" >> $GITHUB_OUTPUT
|
|
echo "message=All E2E tests passed!" >> $GITHUB_OUTPUT
|
|
elif [[ "${{ needs.e2e-tests.result }}" == "failure" ]]; then
|
|
echo "emoji=❌" >> $GITHUB_OUTPUT
|
|
echo "status=FAILED" >> $GITHUB_OUTPUT
|
|
echo "message=Some E2E tests failed. Please review the failures." >> $GITHUB_OUTPUT
|
|
else
|
|
echo "emoji=⚠️" >> $GITHUB_OUTPUT
|
|
echo "status=UNKNOWN" >> $GITHUB_OUTPUT
|
|
echo "message=E2E tests did not complete successfully." >> $GITHUB_OUTPUT
|
|
fi
|
|
|
|
- name: Comment on PR
|
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
|
with:
|
|
script: |
|
|
const emoji = '${{ steps.status.outputs.emoji }}';
|
|
const status = '${{ steps.status.outputs.status }}';
|
|
const message = '${{ steps.status.outputs.message }}';
|
|
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
|
|
|
const body = `## ${emoji} E2E Test Results: ${status}
|
|
|
|
${message}
|
|
|
|
| Metric | Result |
|
|
|--------|--------|
|
|
| Browser | Chromium |
|
|
| Shards | 4 |
|
|
| Status | ${status} |
|
|
|
|
[📊 View full Playwright report](${runUrl})
|
|
|
|
---
|
|
<sub>🤖 This comment was automatically generated by the E2E Tests workflow.</sub>`;
|
|
|
|
// Find existing comment
|
|
const { data: comments } = await github.rest.issues.listComments({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: context.issue.number,
|
|
});
|
|
|
|
const botComment = comments.find(comment =>
|
|
comment.user.type === 'Bot' &&
|
|
comment.body.includes('E2E Test Results')
|
|
);
|
|
|
|
if (botComment) {
|
|
await github.rest.issues.updateComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
comment_id: botComment.id,
|
|
body: body
|
|
});
|
|
} else {
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: context.issue.number,
|
|
body: body
|
|
});
|
|
}
|
|
|
|
# Upload merged E2E coverage to Codecov
|
|
upload-coverage:
|
|
name: Upload E2E Coverage
|
|
runs-on: ubuntu-latest
|
|
needs: e2e-tests
|
|
if: always() && needs.e2e-tests.result == 'success'
|
|
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
|
|
|
- name: Set up Node.js
|
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
|
with:
|
|
node-version: ${{ env.NODE_VERSION }}
|
|
cache: 'npm'
|
|
|
|
- name: Download all coverage artifacts
|
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
|
with:
|
|
pattern: e2e-coverage-*
|
|
path: all-coverage
|
|
merge-multiple: false
|
|
|
|
- name: Merge LCOV coverage files
|
|
run: |
|
|
# Install lcov for merging
|
|
sudo apt-get update && sudo apt-get install -y lcov
|
|
|
|
# Create merged coverage directory
|
|
mkdir -p coverage/e2e-merged
|
|
|
|
# Find all lcov.info files and merge them
|
|
LCOV_FILES=$(find all-coverage -name "lcov.info" -type f)
|
|
|
|
if [[ -n "$LCOV_FILES" ]]; then
|
|
# Build merge command
|
|
MERGE_ARGS=""
|
|
for file in $LCOV_FILES; do
|
|
MERGE_ARGS="$MERGE_ARGS -a $file"
|
|
done
|
|
|
|
lcov $MERGE_ARGS -o coverage/e2e-merged/lcov.info
|
|
echo "✅ Merged $(echo "$LCOV_FILES" | wc -w) coverage files"
|
|
else
|
|
echo "⚠️ No coverage files found to merge"
|
|
exit 0
|
|
fi
|
|
|
|
- name: Upload E2E coverage to Codecov
|
|
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
|
|
with:
|
|
token: ${{ secrets.CODECOV_TOKEN }}
|
|
files: ./coverage/e2e-merged/lcov.info
|
|
flags: e2e
|
|
name: e2e-coverage
|
|
fail_ci_if_error: false
|
|
|
|
- name: Upload merged coverage artifact
|
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
|
with:
|
|
name: e2e-coverage-merged
|
|
path: coverage/e2e-merged/
|
|
retention-days: 30
|
|
|
|
# Final status check - blocks merge if tests fail
|
|
e2e-results:
|
|
name: E2E Test Results
|
|
runs-on: ubuntu-latest
|
|
needs: e2e-tests
|
|
if: always()
|
|
|
|
steps:
|
|
- name: Check test results
|
|
run: |
|
|
if [[ "${{ needs.e2e-tests.result }}" == "success" ]]; then
|
|
echo "✅ All E2E tests passed"
|
|
exit 0
|
|
elif [[ "${{ needs.e2e-tests.result }}" == "skipped" ]]; then
|
|
echo "⏭️ E2E tests were skipped"
|
|
exit 0
|
|
else
|
|
echo "❌ E2E tests failed or were cancelled"
|
|
echo "Result: ${{ needs.e2e-tests.result }}"
|
|
exit 1
|
|
fi
|