|
| 1 | +name: Enterprise E2E (Playwright) |
| 2 | + |
| 3 | +# Enterprise Playwright suite — exercises premium-key gated features (audit, |
| 4 | +# teams, analytics) plus full OAuth + SAML logins via the Keycloak compose |
| 5 | +# stacks under testing/compose. Slow and secret-gated, so it runs in three |
| 6 | +# situations: |
| 7 | +# |
| 8 | +# - PRs that touch proprietary / premium / SSO compose / enterprise tests |
| 9 | +# (path-filtered against .github/config/.files.yaml `proprietary`), |
| 10 | +# - every push to main (post-merge safety net), |
| 11 | +# - on a nightly cron schedule (catches Keycloak image drift, license |
| 12 | +# expiry, upstream proprietary changes), |
| 13 | +# - manual workflow_dispatch. |
| 14 | +# |
| 15 | +# Auto-skipped when secrets.PREMIUM_KEY_ENTERPRISE is missing (forks, dependabot). |
| 16 | + |
| 17 | +on: |
| 18 | + push: |
| 19 | + branches: ["main"] |
| 20 | + pull_request: |
| 21 | + branches: ["main"] |
| 22 | + schedule: |
| 23 | + - cron: "0 4 * * *" |
| 24 | + workflow_dispatch: |
| 25 | + |
| 26 | +concurrency: |
| 27 | + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.ref }} |
| 28 | + cancel-in-progress: true |
| 29 | + |
| 30 | +permissions: |
| 31 | + contents: read |
| 32 | + |
| 33 | +jobs: |
| 34 | + files-changed: |
| 35 | + name: detect what files changed |
| 36 | + runs-on: ubuntu-latest |
| 37 | + timeout-minutes: 3 |
| 38 | + outputs: |
| 39 | + proprietary: ${{ steps.changes.outputs.proprietary }} |
| 40 | + steps: |
| 41 | + - name: Harden Runner |
| 42 | + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 |
| 43 | + with: |
| 44 | + egress-policy: audit |
| 45 | + - name: Checkout repository |
| 46 | + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 47 | + - name: Check for file changes |
| 48 | + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 |
| 49 | + id: changes |
| 50 | + with: |
| 51 | + filters: .github/config/.files.yaml |
| 52 | + |
| 53 | + playwright-e2e-enterprise: |
| 54 | + # Run on PRs only if relevant files changed; always run on push-to-main, |
| 55 | + # cron, and manual dispatch. Fork PRs without the secret will fail at |
| 56 | + # the compose step (PREMIUM_KEY empty) — that's intentional, not silent. |
| 57 | + if: github.event_name != 'pull_request' || needs.files-changed.outputs.proprietary == 'true' |
| 58 | + needs: files-changed |
| 59 | + runs-on: ubuntu-latest |
| 60 | + timeout-minutes: 45 |
| 61 | + env: |
| 62 | + PREMIUM_KEY: ${{ secrets.PREMIUM_KEY_ENTERPRISE }} |
| 63 | + PREMIUM_ENABLED: "true" |
| 64 | + SYSTEM_ENABLEANALYTICS: "false" |
| 65 | + steps: |
| 66 | + - name: Harden Runner |
| 67 | + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 |
| 68 | + with: |
| 69 | + egress-policy: audit |
| 70 | + - name: Checkout repository |
| 71 | + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 72 | + - name: Set up JDK 25 |
| 73 | + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 |
| 74 | + with: |
| 75 | + java-version: "25" |
| 76 | + distribution: "temurin" |
| 77 | + - name: Set up Node.js |
| 78 | + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 |
| 79 | + with: |
| 80 | + node-version: "22" |
| 81 | + cache: "npm" |
| 82 | + cache-dependency-path: frontend/package-lock.json |
| 83 | + - name: Install Task |
| 84 | + uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0 |
| 85 | + - name: Install Playwright (chromium only) |
| 86 | + run: task frontend:test:e2e:install -- chromium |
| 87 | + |
| 88 | + - name: Resolve kubernetes.docker.internal to localhost |
| 89 | + # The compose stacks set KC_HOSTNAME=kubernetes.docker.internal so |
| 90 | + # Keycloak issues redirect URIs against that host. Docker Desktop |
| 91 | + # auto-resolves it; GHA runners don't. Map it to 127.0.0.1 so the |
| 92 | + # browser-driven OAuth flow lands back on Stirling-PDF correctly. |
| 93 | + run: | |
| 94 | + echo "127.0.0.1 kubernetes.docker.internal" | sudo tee -a /etc/hosts |
| 95 | +
|
| 96 | + # Helper function used by all phases — boots `:stirling-pdf:bootRun` |
| 97 | + # with the React frontend baked in (-PbuildWithFrontend=true) so the |
| 98 | + # SPA serves on :8080 and OAuth/SAML callbacks land on the same host |
| 99 | + # that the browser is interacting with. |
| 100 | + - name: Define helpers |
| 101 | + run: | |
| 102 | + { |
| 103 | + echo 'wait_for_backend() {' |
| 104 | + echo ' start=$SECONDS' |
| 105 | + echo ' for i in $(seq 1 300); do' |
| 106 | + echo ' if curl -fsS http://localhost:8080/api/v1/info/status >/dev/null 2>&1; then' |
| 107 | + echo ' echo "Backend up after $((SECONDS - start))s"; return 0' |
| 108 | + echo ' fi; sleep 2' |
| 109 | + echo ' done' |
| 110 | + echo ' tail -200 /tmp/backend.log || true; return 1' |
| 111 | + echo '}' |
| 112 | + echo 'stop_backend() {' |
| 113 | + echo ' if [ -f /tmp/backend.pid ]; then' |
| 114 | + echo ' kill "$(cat /tmp/backend.pid)" 2>/dev/null || true' |
| 115 | + echo ' rm -f /tmp/backend.pid' |
| 116 | + echo ' fi' |
| 117 | + echo ' pkill -f "gradlew :stirling-pdf:bootRun" 2>/dev/null || true' |
| 118 | + echo ' for i in $(seq 1 30); do' |
| 119 | + echo ' curl -fsS http://localhost:8080/api/v1/info/status >/dev/null 2>&1 || return 0' |
| 120 | + echo ' sleep 1' |
| 121 | + echo ' done' |
| 122 | + echo '}' |
| 123 | + } > /tmp/helpers.sh |
| 124 | + chmod +x /tmp/helpers.sh |
| 125 | +
|
| 126 | + # ───────── OAuth round-trip ───────── |
| 127 | + - name: Bring up Keycloak (OAuth realm) |
| 128 | + working-directory: testing/compose |
| 129 | + run: docker compose -f docker-compose-keycloak-oauth.yml up -d --no-deps keycloak-oauth-db keycloak-oauth |
| 130 | + - name: Wait for Keycloak (OAuth) ready |
| 131 | + working-directory: testing/compose |
| 132 | + run: | |
| 133 | + for i in $(seq 1 60); do |
| 134 | + bash validate-oauth-test.sh 2>/dev/null && exit 0 || true |
| 135 | + # validate script also pings stirling on :8080 — accept just the |
| 136 | + # keycloak realm as our gate here, stirling boots in the next step |
| 137 | + curl -fsS http://localhost:9080/realms/stirling-oauth >/dev/null 2>&1 && exit 0 |
| 138 | + sleep 5 |
| 139 | + done |
| 140 | + docker compose -f docker-compose-keycloak-oauth.yml logs --tail=200 keycloak-oauth |
| 141 | + exit 1 |
| 142 | + - name: Boot Stirling-PDF (frontend baked in, OAuth env) |
| 143 | + env: |
| 144 | + SECURITY_ENABLELOGIN: "true" |
| 145 | + SECURITY_LOGINMETHOD: "all" |
| 146 | + SECURITY_OAUTH2_ENABLED: "true" |
| 147 | + SECURITY_OAUTH2_AUTOCREATEUSER: "true" |
| 148 | + # Keycloak issues redirect URIs against KC_HOSTNAME, which the |
| 149 | + # compose default sets to kubernetes.docker.internal. Match here |
| 150 | + # (resolves to localhost via /etc/hosts mapping above). |
| 151 | + SECURITY_OAUTH2_CLIENT_KEYCLOAK_ISSUER: "http://kubernetes.docker.internal:9080/realms/stirling-oauth" |
| 152 | + SECURITY_OAUTH2_CLIENT_KEYCLOAK_CLIENTID: "stirling-pdf-client" |
| 153 | + SECURITY_OAUTH2_CLIENT_KEYCLOAK_CLIENTSECRET: "test-client-secret-change-in-production" |
| 154 | + SECURITY_OAUTH2_CLIENT_KEYCLOAK_USEASUSERNAME: "email" |
| 155 | + SECURITY_OAUTH2_CLIENT_KEYCLOAK_SCOPES: "openid,profile,email" |
| 156 | + run: | |
| 157 | + source /tmp/helpers.sh |
| 158 | + nohup ./gradlew :stirling-pdf:bootRun -PbuildWithFrontend=true > /tmp/backend.log 2>&1 & |
| 159 | + echo $! > /tmp/backend.pid |
| 160 | + wait_for_backend |
| 161 | + - name: Run enterprise OAuth Playwright tests |
| 162 | + id: oauth-tests |
| 163 | + run: task frontend:test:e2e -- --project=enterprise --grep "OAuth" |
| 164 | + - name: Stop backend + tear down OAuth Keycloak |
| 165 | + if: always() |
| 166 | + run: | |
| 167 | + source /tmp/helpers.sh |
| 168 | + stop_backend |
| 169 | + (cd testing/compose && docker compose -f docker-compose-keycloak-oauth.yml down -v) |
| 170 | +
|
| 171 | + # ───────── SAML round-trip ───────── |
| 172 | + - name: Bring up Keycloak (SAML realm) |
| 173 | + working-directory: testing/compose |
| 174 | + run: docker compose -f docker-compose-keycloak-saml.yml up -d --no-deps keycloak-saml-db keycloak-saml |
| 175 | + - name: Wait for Keycloak (SAML) ready |
| 176 | + working-directory: testing/compose |
| 177 | + run: | |
| 178 | + for i in $(seq 1 60); do |
| 179 | + curl -fsS http://localhost:9080/realms/stirling-saml >/dev/null 2>&1 && exit 0 |
| 180 | + sleep 5 |
| 181 | + done |
| 182 | + docker compose -f docker-compose-keycloak-saml.yml logs --tail=200 keycloak-saml |
| 183 | + exit 1 |
| 184 | + - name: Generate SAML SP certs + fetch Keycloak IdP cert |
| 185 | + # The .pem/.crt/.key files are gitignored (test-only certs); the |
| 186 | + # docker-based start-saml-test.sh generates them at runtime, so do |
| 187 | + # the same in CI before bootRun reads them. |
| 188 | + working-directory: testing/compose |
| 189 | + run: | |
| 190 | + openssl req -x509 -newkey rsa:2048 \ |
| 191 | + -keyout saml-private-key.key \ |
| 192 | + -out saml-public-cert.crt \ |
| 193 | + -days 3650 -nodes \ |
| 194 | + -subj "/CN=stirling-pdf-saml-sp" >/dev/null 2>&1 |
| 195 | + # Fetch Keycloak's SAML signing cert from the realm descriptor |
| 196 | + CERT_BODY=$(curl -sf http://localhost:9080/realms/stirling-saml/protocol/saml/descriptor \ |
| 197 | + | awk 'BEGIN{RS="<[^>]*X509Certificate>|</[^>]*X509Certificate>"} NR==2{gsub(/[[:space:]]+/,""); print; exit}') |
| 198 | + { |
| 199 | + echo "-----BEGIN CERTIFICATE-----" |
| 200 | + echo "$CERT_BODY" |
| 201 | + echo "-----END CERTIFICATE-----" |
| 202 | + } > keycloak-saml-cert.pem |
| 203 | + test -s saml-private-key.key |
| 204 | + test -s saml-public-cert.crt |
| 205 | + test -s keycloak-saml-cert.pem |
| 206 | + echo "✓ SAML certs prepared" |
| 207 | + - name: Boot Stirling-PDF (frontend baked in, SAML env) |
| 208 | + env: |
| 209 | + SECURITY_ENABLELOGIN: "true" |
| 210 | + SECURITY_LOGINMETHOD: "all" |
| 211 | + SECURITY_SAML2_ENABLED: "true" |
| 212 | + SECURITY_SAML2_AUTOCREATEUSER: "true" |
| 213 | + SECURITY_SAML2_PROVIDER: "keycloak" |
| 214 | + SECURITY_SAML2_REGISTRATIONID: "keycloak" |
| 215 | + SECURITY_SAML2_IDP_ISSUER: "http://localhost:9080/realms/stirling-saml" |
| 216 | + SECURITY_SAML2_IDP_ENTITYID: "http://localhost:9080/realms/stirling-saml" |
| 217 | + SECURITY_SAML2_IDP_METADATAURI: "http://localhost:9080/realms/stirling-saml/protocol/saml/descriptor" |
| 218 | + SECURITY_SAML2_IDPSINGLELOGINURL: "http://localhost:9080/realms/stirling-saml/protocol/saml" |
| 219 | + SECURITY_SAML2_IDPSINGLELOGOUTURL: "http://localhost:9080/realms/stirling-saml/protocol/saml" |
| 220 | + SECURITY_SAML2_IDP_CERT: "${{ github.workspace }}/testing/compose/keycloak-saml-cert.pem" |
| 221 | + SECURITY_SAML2_PRIVATEKEY: "${{ github.workspace }}/testing/compose/saml-private-key.key" |
| 222 | + SECURITY_SAML2_SP_CERT: "${{ github.workspace }}/testing/compose/saml-public-cert.crt" |
| 223 | + # Realm registers the SP entity as the metadata URL — see |
| 224 | + # keycloak-realm-saml.json `clientId`. Match it here so Keycloak |
| 225 | + # accepts the AuthnRequest issuer. |
| 226 | + SECURITY_SAML2_SP_ENTITYID: "http://localhost:8080/saml2/service-provider-metadata/keycloak" |
| 227 | + SECURITY_SAML2_SP_ACS: "http://localhost:8080/login/saml2/sso/keycloak" |
| 228 | + SECURITY_SAML2_SP_SLS: "http://localhost:8080/logout/saml2/slo" |
| 229 | + run: | |
| 230 | + source /tmp/helpers.sh |
| 231 | + nohup ./gradlew :stirling-pdf:bootRun -PbuildWithFrontend=true > /tmp/backend.log 2>&1 & |
| 232 | + echo $! > /tmp/backend.pid |
| 233 | + wait_for_backend |
| 234 | + - name: Run enterprise SAML Playwright tests |
| 235 | + id: saml-tests |
| 236 | + run: task frontend:test:e2e -- --project=enterprise --grep "SAML" |
| 237 | + - name: Stop backend + tear down SAML Keycloak |
| 238 | + if: always() |
| 239 | + run: | |
| 240 | + source /tmp/helpers.sh |
| 241 | + stop_backend |
| 242 | + (cd testing/compose && docker compose -f docker-compose-keycloak-saml.yml down -v) |
| 243 | +
|
| 244 | + # ───────── License-gated feature tests (no IdP needed) ───────── |
| 245 | + - name: Wipe DB so InitialSecuritySetup re-runs with admin/adminadmin |
| 246 | + # Earlier phases (OAuth, SAML) create the default admin/stirling user. |
| 247 | + # InitialSecuritySetup only honours SECURITY_INITIALLOGIN_* when the |
| 248 | + # admin user doesn't already exist, so the persisted DB has to be |
| 249 | + # cleared between phases for the feature env vars to take effect. |
| 250 | + run: | |
| 251 | + rm -f app/core/configs/stirling-pdf-DB*.mv.db |
| 252 | + rm -rf app/core/configs/backup |
| 253 | + - name: Boot Stirling-PDF (frontend baked in, premium only) |
| 254 | + env: |
| 255 | + SECURITY_INITIALLOGIN_USERNAME: admin |
| 256 | + SECURITY_INITIALLOGIN_PASSWORD: adminadmin |
| 257 | + SECURITY_ENABLELOGIN: "true" |
| 258 | + SECURITY_LOGINMETHOD: "all" |
| 259 | + run: | |
| 260 | + source /tmp/helpers.sh |
| 261 | + nohup ./gradlew :stirling-pdf:bootRun -PbuildWithFrontend=true > /tmp/backend.log 2>&1 & |
| 262 | + echo $! > /tmp/backend.pid |
| 263 | + wait_for_backend |
| 264 | + - name: Run enterprise feature Playwright tests |
| 265 | + id: feature-tests |
| 266 | + run: task frontend:test:e2e -- --project=enterprise --grep "Enterprise license" |
| 267 | + - name: Print backend log on failure |
| 268 | + if: failure() |
| 269 | + run: | |
| 270 | + echo "::group::Enterprise backend log" |
| 271 | + tail -500 /tmp/backend.log || true |
| 272 | + echo "::endgroup::" |
| 273 | + - name: Stop backend (final) |
| 274 | + if: always() |
| 275 | + run: | |
| 276 | + source /tmp/helpers.sh |
| 277 | + stop_backend |
| 278 | + - name: Upload Playwright report |
| 279 | + if: always() |
| 280 | + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 |
| 281 | + with: |
| 282 | + name: playwright-report-enterprise-${{ github.run_id }} |
| 283 | + path: frontend/playwright-report/ |
| 284 | + retention-days: 7 |
0 commit comments