Skip to content

Commit 5626aee

Browse files
committed
enterprise and fixes
1 parent 5997b00 commit 5626aee

21 files changed

Lines changed: 1211 additions & 177 deletions

.github/workflows/build.yml

Lines changed: 130 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -401,16 +401,17 @@ jobs:
401401
run: task frontend:test:e2e:install -- chromium
402402
- name: Start Spring Boot backend (background)
403403
env:
404-
# Match the admin/admin credentials hard-coded in the live test helpers.
405-
# Without these the backend falls back to the default admin/stirling user
406-
# (see InitialSecuritySetup.createDefaultAdminUser) and every login fails.
407-
SECURITY_INITIALLOGIN_USERNAME: admin
408-
SECURITY_INITIALLOGIN_PASSWORD: admin
409404
# Suppress the analytics opt-in modal that fires on first admin login when
410405
# enableAnalytics is null (see Onboarding.tsx). The modal renders a Mantine
411406
# overlay that intercepts pointer events on every tool page until dismissed,
412407
# which causes every "click run button" assertion in the live suite to fail.
413408
SYSTEM_ENABLEANALYTICS: "false"
409+
# NOTE: SECURITY_INITIALLOGIN_USERNAME/PASSWORD are intentionally NOT set.
410+
# The live-setup project's bootstrap spec performs the real first-login
411+
# flow against the backend's default admin/stirling user, exercising the
412+
# forced-password-change UI and leaving the DB at admin/adminadmin for
413+
# the rest of the live suite. This is both real coverage of the first-
414+
# login flow and a stronger seed than env-var-driven user creation.
414415
run: |
415416
nohup ./gradlew :stirling-pdf:bootRun > /tmp/backend.log 2>&1 &
416417
echo $! > /tmp/backend.pid
@@ -458,6 +459,130 @@ jobs:
458459
path: frontend/playwright-report/
459460
retention-days: 7
460461

462+
playwright-e2e-enterprise:
463+
# Enterprise suite — exercises premium-key gated features (audit, teams,
464+
# analytics export) plus full OAuth + SAML logins via the Keycloak compose
465+
# stacks under testing/compose. Skipped automatically when the secret is
466+
# absent (forks, dependabot) so the job is opt-in for trusted PRs.
467+
if: needs.files-changed.outputs.frontend == 'true' && secrets.PREMIUM_KEY_ENTERPRISE != ''
468+
needs: files-changed
469+
runs-on: ubuntu-latest
470+
timeout-minutes: 45
471+
env:
472+
PREMIUM_KEY: ${{ secrets.PREMIUM_KEY_ENTERPRISE }}
473+
PREMIUM_ENABLED: "true"
474+
SYSTEM_ENABLEANALYTICS: "false"
475+
steps:
476+
- name: Harden Runner
477+
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
478+
with:
479+
egress-policy: audit
480+
- name: Checkout repository
481+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
482+
- name: Set up JDK 25
483+
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
484+
with:
485+
java-version: "25"
486+
distribution: "temurin"
487+
- name: Set up Node.js
488+
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
489+
with:
490+
node-version: "22"
491+
cache: "npm"
492+
cache-dependency-path: frontend/package-lock.json
493+
- name: Install Task
494+
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
495+
- name: Install Playwright (chromium only)
496+
run: task frontend:test:e2e:install -- chromium
497+
498+
# ───────── OAuth round-trip ─────────
499+
- name: Bring up Keycloak + Stirling-PDF (OAuth)
500+
working-directory: testing/compose
501+
run: docker compose -f docker-compose-keycloak-oauth.yml up -d --build
502+
- name: Wait for OAuth stack ready
503+
working-directory: testing/compose
504+
run: |
505+
for i in $(seq 1 60); do
506+
if bash validate-oauth-test.sh; then
507+
exit 0
508+
fi
509+
sleep 5
510+
done
511+
docker compose -f docker-compose-keycloak-oauth.yml logs --tail=200
512+
exit 1
513+
- name: Run enterprise OAuth Playwright tests
514+
id: oauth-tests
515+
run: task frontend:test:e2e -- --project=enterprise --grep "OAuth"
516+
- name: Tear down OAuth stack
517+
if: always()
518+
working-directory: testing/compose
519+
run: docker compose -f docker-compose-keycloak-oauth.yml down -v
520+
521+
# ───────── SAML round-trip ─────────
522+
- name: Bring up Keycloak + Stirling-PDF (SAML)
523+
working-directory: testing/compose
524+
run: docker compose -f docker-compose-keycloak-saml.yml up -d --build
525+
- name: Wait for SAML stack ready
526+
working-directory: testing/compose
527+
run: |
528+
for i in $(seq 1 60); do
529+
if bash validate-saml-test.sh; then
530+
exit 0
531+
fi
532+
sleep 5
533+
done
534+
docker compose -f docker-compose-keycloak-saml.yml logs --tail=200
535+
exit 1
536+
- name: Run enterprise SAML Playwright tests
537+
id: saml-tests
538+
run: task frontend:test:e2e -- --project=enterprise --grep "SAML"
539+
- name: Tear down SAML stack
540+
if: always()
541+
working-directory: testing/compose
542+
run: docker compose -f docker-compose-keycloak-saml.yml down -v
543+
544+
# ───────── License-gated feature tests (no IdP needed) ─────────
545+
- name: Start backend for feature tests (premium-enabled, no SSO)
546+
env:
547+
SYSTEM_ENABLEANALYTICS: "false"
548+
run: |
549+
nohup ./gradlew :stirling-pdf:bootRun > /tmp/backend-ent.log 2>&1 &
550+
echo $! > /tmp/backend-ent.pid
551+
- name: Wait for backend ready
552+
run: |
553+
start=$SECONDS
554+
for i in $(seq 1 300); do
555+
if curl -fsS http://localhost:8080/api/v1/info/status >/dev/null 2>&1; then
556+
echo "Backend up after $((SECONDS - start))s"
557+
exit 0
558+
fi
559+
sleep 2
560+
done
561+
tail -200 /tmp/backend-ent.log || true
562+
exit 1
563+
- name: Run enterprise feature Playwright tests
564+
id: feature-tests
565+
run: task frontend:test:e2e -- --project=enterprise --grep "Enterprise license"
566+
- name: Print backend log on failure
567+
if: failure()
568+
run: |
569+
echo "::group::Enterprise backend log"
570+
tail -500 /tmp/backend-ent.log || true
571+
echo "::endgroup::"
572+
- name: Stop backend
573+
if: always()
574+
run: |
575+
if [ -f /tmp/backend-ent.pid ]; then
576+
kill "$(cat /tmp/backend-ent.pid)" 2>/dev/null || true
577+
fi
578+
- name: Upload Playwright report
579+
if: always()
580+
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
581+
with:
582+
name: playwright-report-enterprise-${{ github.run_id }}
583+
path: frontend/playwright-report/
584+
retention-days: 7
585+
461586
check-licence:
462587
if: needs.files-changed.outputs.build == 'true'
463588
needs: [files-changed, build]

frontend/playwright.config.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,35 @@ export default defineConfig({
5151
use: chromiumViewport,
5252
},
5353

54+
// Live setup — runs once before the live suite to perform the real
55+
// forced-password-change first-login flow against a freshly-booted
56+
// backend. The live project depends on it.
57+
{
58+
name: "live-setup",
59+
testDir: "./src/core/tests/live-setup",
60+
testMatch: /.*\.setup\.ts$/,
61+
use: chromiumViewport,
62+
},
63+
5464
// Live backend — auth + admin-mutation + real-tool smoke
5565
{
5666
name: "live",
5767
testDir: "./src/core/tests/live",
5868
use: chromiumViewport,
69+
dependencies: ["live-setup"],
70+
},
71+
72+
// Enterprise — license-gated SSO/SAML/audit/teams against keycloak compose
73+
// Uses port 8080 directly (the docker compose stack publishes the
74+
// backend's built-in frontend there); the Vite dev server is bypassed
75+
// because the OAuth/SAML callback URLs are registered against 8080.
76+
{
77+
name: "enterprise",
78+
testDir: "./src/core/tests/enterprise",
79+
use: {
80+
...chromiumViewport,
81+
baseURL: "http://localhost:8080",
82+
},
5983
},
6084

6185
// Cross-browser coverage for the stubbed suite (opt-in locally)
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { test, expect } from "@app/tests/helpers/test-base";
2+
import { loginAndSetup } from "@app/tests/helpers/login";
3+
4+
/**
5+
* Enterprise license validates and unlocks the corresponding feature
6+
* surfaces in the admin UI:
7+
* - License panel reports a valid key (no "invalid"/"expired" state)
8+
* - Audit-log section is reachable from settings
9+
* - Team management section is reachable from settings
10+
* - Analytics-export affordance is present for admins
11+
*
12+
* Requires the backend booted with PREMIUM_KEY set to a real key.
13+
*/
14+
test.describe("Enterprise license unlocks admin surfaces", () => {
15+
test.beforeEach(async ({ page }) => {
16+
await loginAndSetup(page);
17+
});
18+
19+
test("admin settings exposes license / audit / team / analytics sections", async ({
20+
page,
21+
}) => {
22+
test.setTimeout(60_000);
23+
24+
await page.locator('[data-testid="config-button"]').first().click();
25+
await page.waitForTimeout(500);
26+
27+
// License section — name varies (License, Premium, Subscription)
28+
await expect(
29+
page.getByText(/license|premium|subscription/i).first(),
30+
).toBeVisible({ timeout: 10_000 });
31+
32+
// No "invalid license" / "trial expired" warnings should be present
33+
await expect(
34+
page.getByText(/invalid license|expired|key required/i),
35+
).toHaveCount(0);
36+
37+
// Audit log section
38+
await expect(page.getByText(/audit/i).first()).toBeVisible({
39+
timeout: 5_000,
40+
});
41+
42+
// Teams section
43+
await expect(page.getByText(/teams/i).first()).toBeVisible({
44+
timeout: 5_000,
45+
});
46+
});
47+
48+
test("audit log surface lists at least one event after a tool action", async ({
49+
page,
50+
}) => {
51+
test.setTimeout(90_000);
52+
53+
// Generate one audit-worthy action: open settings (admin-only navigation)
54+
await page.locator('[data-testid="config-button"]').first().click();
55+
await page.waitForTimeout(500);
56+
57+
const auditNav = page.getByText(/audit/i).first();
58+
if (!(await auditNav.isVisible({ timeout: 5_000 }).catch(() => false))) {
59+
test.skip(true, "Audit section not reachable on this build");
60+
return;
61+
}
62+
await auditNav.click();
63+
await page.waitForTimeout(1_000);
64+
65+
// Either a table, a list of events, or "no events" empty state is fine —
66+
// we're asserting the surface renders without an error/blank state.
67+
const eventsSurface = page
68+
.locator(
69+
'[data-testid*="audit"], table, [class*="AuditEvent" i], [class*="audit-table" i]',
70+
)
71+
.first();
72+
await expect(eventsSurface).toBeVisible({ timeout: 10_000 });
73+
});
74+
75+
test("admin can reach team management and create-team affordance is present", async ({
76+
page,
77+
}) => {
78+
test.setTimeout(60_000);
79+
80+
await page.locator('[data-testid="config-button"]').first().click();
81+
await page.waitForTimeout(500);
82+
83+
const teamsNav = page.getByText(/^teams$/i).first();
84+
if (!(await teamsNav.isVisible({ timeout: 5_000 }).catch(() => false))) {
85+
test.skip(true, "Teams section not reachable on this build");
86+
return;
87+
}
88+
await teamsNav.click();
89+
await page.waitForTimeout(500);
90+
91+
await expect(
92+
page
93+
.getByRole("button", { name: /create team|new team|add team/i })
94+
.first(),
95+
).toBeVisible({ timeout: 10_000 });
96+
});
97+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { test, expect } from "@playwright/test";
2+
import { ensureCookieConsent } from "@app/tests/helpers/login";
3+
4+
/**
5+
* OAuth login round-trip via Keycloak.
6+
*
7+
* Requires the docker-compose-keycloak-oauth stack to be running:
8+
* - Keycloak on http://localhost:9080 with realm `stirling-oauth`
9+
* - Stirling-PDF on http://localhost:8080 with PREMIUM_KEY set
10+
*
11+
* Test user: oauthuser@example.com / oauthpassword (per
12+
* testing/compose/keycloak-realm-oauth.json).
13+
*/
14+
test.describe("Enterprise OAuth login (Keycloak)", () => {
15+
test("clicking the Keycloak provider redirects, authenticates, and lands on home", async ({
16+
page,
17+
}) => {
18+
test.setTimeout(90_000);
19+
20+
await ensureCookieConsent(page);
21+
await page.goto("/login", { waitUntil: "domcontentloaded" });
22+
23+
// The login page shows OAuth buttons rendered from app-config.oauth2
24+
const keycloakBtn = page
25+
.locator('a[href*="oauth2/authorization/keycloak"]')
26+
.or(page.getByRole("button", { name: /keycloak|continue with/i }))
27+
.first();
28+
await expect(keycloakBtn).toBeVisible({ timeout: 10_000 });
29+
await keycloakBtn.click();
30+
31+
// We are now on the Keycloak login page (different origin)
32+
await page.waitForURL(/\/realms\/stirling-oauth\/protocol\/openid-connect/);
33+
34+
await page.locator("#username").fill("oauthuser@example.com");
35+
await page.locator("#password").fill("oauthpassword");
36+
await page.locator('input[type="submit"], button[type="submit"]').click();
37+
38+
// Back on Stirling-PDF, authenticated
39+
await page.waitForURL((url) => !url.pathname.includes("/login"), {
40+
timeout: 30_000,
41+
});
42+
await expect(
43+
page.getByRole("link", { name: /^Tools$/i }).first(),
44+
).toBeVisible({ timeout: 15_000 });
45+
});
46+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { test, expect } from "@playwright/test";
2+
import { ensureCookieConsent } from "@app/tests/helpers/login";
3+
4+
/**
5+
* SAML login round-trip via Keycloak.
6+
*
7+
* Requires the docker-compose-keycloak-saml stack:
8+
* - Keycloak on http://localhost:9080 with realm `stirling-saml`
9+
* - Stirling-PDF on http://localhost:8080 with PREMIUM_KEY set and
10+
* security.saml2.enabled=true
11+
*/
12+
test.describe("Enterprise SAML login (Keycloak)", () => {
13+
test("SAML provider button completes the IdP redirect and signs the user in", async ({
14+
page,
15+
}) => {
16+
test.setTimeout(90_000);
17+
18+
await ensureCookieConsent(page);
19+
await page.goto("/login", { waitUntil: "domcontentloaded" });
20+
21+
const samlBtn = page
22+
.locator('a[href*="saml"], a[href*="saml2"]')
23+
.or(page.getByRole("button", { name: /saml|authentik|keycloak/i }))
24+
.first();
25+
await expect(samlBtn).toBeVisible({ timeout: 10_000 });
26+
await samlBtn.click();
27+
28+
await page.waitForURL(/\/realms\/stirling-saml\/protocol\/saml/, {
29+
timeout: 30_000,
30+
});
31+
32+
await page.locator("#username").fill("samluser@example.com");
33+
await page.locator("#password").fill("samlpassword");
34+
await page.locator('input[type="submit"], button[type="submit"]').click();
35+
36+
await page.waitForURL((url) => !url.pathname.includes("/login"), {
37+
timeout: 30_000,
38+
});
39+
await expect(
40+
page.getByRole("link", { name: /^Tools$/i }).first(),
41+
).toBeVisible({ timeout: 15_000 });
42+
});
43+
});

0 commit comments

Comments
 (0)