diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000000..3678c02702 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,249 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Playwright Tests + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: playwright-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + matrix: + runs-on: ubuntu-latest + outputs: + server-max: ${{ steps.versions.outputs.branches-max-list }} + steps: + - name: Checkout app + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Get version matrix + id: versions + uses: icewind1991/nextcloud-version-matrix@8a7bac6300b2f0f3100088b297995a229558ddba # v1.3.2.3.1.3.2 + + changes: + runs-on: ubuntu-latest + + outputs: + src: ${{ steps.changes.outputs.src }} + + steps: + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: changes + continue-on-error: true + with: + filters: | + src: + - '.github/workflows/playwright.yml' + - 'appinfo/**' + - 'lib/**' + - 'src/**' + - 'templates/**' + - 'playwright/**' + - 'playwright.config.ts' + - 'package.json' + - 'package-lock.json' + + playwright: + runs-on: ubuntu-latest + timeout-minutes: 60 + + needs: [matrix, changes] + if: needs.changes.outputs.src != 'false' + + strategy: + matrix: + server-versions: ${{ fromJson(needs.matrix.outputs.server-max) }} + + name: Playwright E2E Tests + + services: + mailpit: + image: axllent/mailpit + ports: + - 8025:8025/tcp + - 1025:1025/tcp + + steps: + - name: Set app env + run: | + # Split and keep last + echo "APP_NAME=${GITHUB_REPOSITORY##*/}" >> $GITHUB_ENV + + - name: Install system dependencies + run: sudo apt update && sudo apt install poppler-utils + + - name: Checkout server + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + submodules: true + repository: nextcloud/server + ref: ${{ matrix.server-versions }} + + - name: Checkout app + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + submodules: true + path: apps/${{ env.APP_NAME }} + + - name: Get php version + id: php_versions + uses: icewind1991/nextcloud-version-matrix@8a7bac6300b2f0f3100088b297995a229558ddba # v1.3.2.3.1.3.2 + with: + filename: apps/${{ env.APP_NAME }}/appinfo/info.xml + + - name: Set up php ${{ steps.php_versions.outputs.php-min }} + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2 + with: + php-version: ${{ steps.php_versions.outputs.php-min }} + extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, sqlite, pdo_sqlite, xmlreader, xmlwriter, zip, zlib + coverage: none + ini-file: development + ini-values: disable_functions= + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check composer file existence + id: check_composer + uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3.0.0 + with: + files: apps/${{ env.APP_NAME }}/composer.json + + - name: Set up composer dependencies + if: steps.check_composer.outputs.files_exists == 'true' + working-directory: apps/${{ env.APP_NAME }} + run: | + composer remove nextcloud/ocp --dev --no-scripts + composer install --no-dev + + - name: Read package.json node and npm engines version + uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3 + id: versions + with: + path: apps/${{ env.APP_NAME }} + fallbackNode: '^24' + fallbackNpm: '^11' + + - name: Set up node ${{ steps.versions.outputs.nodeVersion }} + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: ${{ steps.versions.outputs.nodeVersion }} + + - name: Set up npm ${{ steps.versions.outputs.npmVersion }} + run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}' + + - name: Install node dependencies & build app + working-directory: apps/${{ env.APP_NAME }} + env: + CYPRESS_INSTALL_BINARY: 0 + run: | + npm ci + TESTING=true npm run build --if-present + + - name: Set up Nextcloud + run: | + sudo echo "127.0.0.1 mailpit" | sudo tee -a /etc/hosts + mkdir data + ./occ maintenance:install \ + --verbose \ + --database=sqlite \ + --admin-user admin \ + --admin-pass admin + ./occ --version + + - name: Install app dependencies + run: | + git clone --depth 1 -b ${{ matrix.server-versions }} https://github.com/nextcloud/notifications apps/notifications + composer --working-dir=apps/notifications install --no-dev + ./occ app:enable --force notifications + git clone --depth 1 -b ${{ matrix.server-versions }} https://github.com/nextcloud/activity apps/activity + composer --working-dir=apps/activity install --no-dev + ./occ app:enable --force activity + + - name: Set up LibreSign + run: | + ./occ app:enable --force ${{ env.APP_NAME }} + ./occ config:system:set allow_local_remote_servers --value true --type boolean + ./occ config:system:set auth.bruteforce.protection.enabled --value false --type boolean + ./occ config:system:set ratelimit.protection.enabled --value false --type boolean + ./occ config:system:set mail_smtphost --value mailpit + ./occ config:system:set mail_smtpport --value 1025 --type integer + ./occ config:system:set overwrite.cli.url --value 'http://localhost:8080' + ./occ config:system:set overwritehost --value 'localhost:8080' + ./occ config:system:set debug --value true --type boolean + ./occ libresign:install --use-local-cert --java + ./occ libresign:install --use-local-cert --jsignpdf + ./occ libresign:install --use-local-cert --pdftk + ./occ libresign:configure:openssl \ + --cn="Common Name" \ + --c=BR \ + --ou="Organization Unit" \ + --st="Rio de Janeiro" \ + --o=LibreSign \ + --l="Rio de Janeiro" + ./occ user:setting admin settings email admin@email.tld + + - name: Start PHP built-in server + run: | + # front_controller_active tells Nextcloud to generate clean URLs (without index.php prefix) + # This mirrors what Apache mod_rewrite does via .htaccess RewriteRule + front_controller_active=true php -S localhost:8080 -t . apps/${{ env.APP_NAME }}/playwright/router.php & + # Wait for server to become available + timeout 30 bash -c 'until curl -s http://localhost:8080/status.php > /dev/null; do sleep 1; done' + echo "Nextcloud is ready at http://localhost:8080" + + - name: Install Playwright browsers + working-directory: apps/${{ env.APP_NAME }} + run: npx playwright install chromium --with-deps + + - name: Run Playwright tests + working-directory: apps/${{ env.APP_NAME }} + env: + PLAYWRIGHT_BASE_URL: http://localhost:8080 + NEXTCLOUD_ADMIN_USER: admin + NEXTCLOUD_ADMIN_PASSWORD: admin + run: npx playwright test + + - name: Upload Playwright report + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + if: always() + with: + name: playwright-report + path: apps/${{ env.APP_NAME }}/playwright-report/ + retention-days: 30 + + - name: Upload test results + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + if: always() + with: + name: playwright-test-results + path: apps/${{ env.APP_NAME }}/test-results/ + retention-days: 30 + + - name: Print Nextcloud logs + if: always() + run: cat data/nextcloud.log 2>/dev/null || echo "No Nextcloud logs found" + + summary: + permissions: + contents: none + runs-on: ubuntu-latest + needs: [matrix, changes, playwright] + + if: always() + + name: playwright-summary + + steps: + - name: Summary status + run: if ${{ needs.changes.outputs.src != 'false' && needs.playwright.result != 'success' }}; then exit 1; fi diff --git a/.gitignore b/.gitignore index 24e5eff10f..4ee4c89951 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ node_modules/ /lib/Vendor/ /coverage /dist/ +/test-results/ diff --git a/package-lock.json b/package-lock.json index 2ca2ae294f..06d1f4529f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,6 +62,7 @@ "@nextcloud/stylelint-config": "^3.2.1", "@nextcloud/vite-config": "^2.5.2", "@pinia/testing": "^1.0.3", + "@playwright/test": "^1.58.1", "@testing-library/dom": "^10.4.1", "@testing-library/vue": "^8.1.0", "@vitejs/plugin-vue": "^6.0.3", @@ -3091,6 +3092,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@redocly/ajv": { "version": "8.17.4", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.17.4.tgz", @@ -12449,6 +12466,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", diff --git a/package.json b/package.json index 0c32cb2f30..7ab27ab2f0 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,10 @@ "stylelint:fix": "stylelint src/**/*.scss src/**/*.vue --fix", "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui --ui-host=0.0.0.0 --ui-port=9323", + "test:e2e:report": "playwright show-report" }, "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -75,6 +78,7 @@ "npm": "^11.3.0" }, "devDependencies": { + "@playwright/test": "^1.58.1", "@nextcloud/browserslist-config": "^3.1.2", "@nextcloud/eslint-config": "^8.4.2", "@nextcloud/stylelint-config": "^3.2.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000000..55e760738b --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,50 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { defineConfig, devices } from '@playwright/test' + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './playwright', + + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI ? 'github' : 'list', + /* Default timeout for each test (60 seconds) */ + timeout: 60000, + + /* Shared settings for all the projects below. */ + use: { + /* Base URL to use in actions like `await page.goto('./apps/libresign')`. */ + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'https://localhost', + + /* Ignore HTTPS errors on local self-signed certificates */ + ignoreHTTPSErrors: true, + + /* Collect trace when retrying the failed test. */ + trace: 'on-first-retry', + + /* Screenshot on failure */ + screenshot: 'only-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], +}) diff --git a/playwright/e2e/sign-herself-with-click-to-sign.spec.ts b/playwright/e2e/sign-herself-with-click-to-sign.spec.ts new file mode 100644 index 0000000000..58d1042c92 --- /dev/null +++ b/playwright/e2e/sign-herself-with-click-to-sign.spec.ts @@ -0,0 +1,55 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/test' +import { login } from '../support/nc-login' +import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' + +test('sign herself with click to sign', async ({ page }) => { + await login( + page.request, + process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + + await configureOpenSsl(page.request, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + + await setAppConfig( + page.request, + 'libresign', + 'identify_methods', + JSON.stringify([ + { name: 'account', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } } }, + { name: 'email', enabled: false, mandatory: false }, + ]), + ) + + await page.goto('./apps/libresign') + await page.getByRole('button', { name: 'Upload from URL' }).click(); + await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf'); + await page.getByRole('button', { name: 'Send' }).click(); + await page.getByRole('button', { name: 'Add signer' }).click(); + await page.getByPlaceholder('Account').fill('admin'); + await page.getByText('admin@email.tld').click(); + await page.getByRole('button', { name: 'Save' }).click(); + await page.getByRole('button', { name: 'Request signatures' }).click(); + await page.getByRole('button', { name: 'Send' }).click(); + await page.getByRole('button', { name: 'Sign document' }).click(); + await page.getByRole('button', { name: 'Sign the document.' }).click(); + await page.getByRole('button', { name: 'Confirm' }).click(); + await page.waitForURL('**/validation/**'); + await expect(page.getByText('This document is valid')).toBeVisible(); + await page.getByRole('button', { name: 'Expand details' }).click(); + await page.getByRole('button', { name: 'Expand validation status', exact: true }).click(); + await expect(page.getByRole('link', { name: 'Document integrity verified' })).toBeVisible(); + await page.getByRole('button', { name: 'Expand document certification', exact: true }).click(); + await expect(page.getByRole('link', { name: 'Document has not been' })).toBeVisible(); +}); diff --git a/playwright/router.php b/playwright/router.php new file mode 100644 index 0000000000..616eec675b --- /dev/null +++ b/playwright/router.php @@ -0,0 +1,42 @@ + { + const tokenResponse = await request.get('./csrftoken', { + failOnStatusCode: true, + }) + + const { token: requesttoken } = await tokenResponse.json() as { token: string } + + // Strip everything from "index.php" onward so we get the bare origin + const origin = tokenResponse.url().replace(/index\.php.*/, '') + + const loginResponse = await request.post('./login', { + form: { + user, + password, + requesttoken, + }, + headers: { + Origin: origin, + }, + maxRedirects: 0, + failOnStatusCode: false, + }) + + // The Nextcloud login sets x-user-id on success (even on the 303 response). + if (!loginResponse.headers()['x-user-id']) { + throw new Error(`Login failed for user "${user}": no x-user-id header in response (status ${loginResponse.status()})`) + } + + // Confirm the session is valid + await request.get('./apps/files', { + failOnStatusCode: true, + }) +} diff --git a/playwright/support/nc-provisioning.ts b/playwright/support/nc-provisioning.ts new file mode 100644 index 0000000000..794b2ceb6b --- /dev/null +++ b/playwright/support/nc-provisioning.ts @@ -0,0 +1,176 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Helpers for configuring the Nextcloud environment from Playwright tests, + * equivalent to Behat's OCC/OCS helpers. + * + * All operations go through the Nextcloud OCS Provisioning API and are + * performed as admin. No Docker or OCC CLI access is needed. + */ + +import type { APIRequestContext } from '@playwright/test' + +type OcsResponse = { + ocs: { + meta: { status: string; statuscode: number; message: string } + data: T + } +} + +async function ocsRequest( + request: APIRequestContext, + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, + adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + body?: Record, + jsonBody?: unknown, +): Promise { + const url = `./ocs/v2.php${path}` + const auth = 'Basic ' + Buffer.from(`${adminUser}:${adminPassword}`).toString('base64') + const headers: Record = { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + Authorization: auth, + } + if (jsonBody !== undefined) { + headers['Content-Type'] = 'application/json' + } + const response = await request[method.toLowerCase() as 'get' | 'post' | 'put' | 'delete'](url, { + headers, + ...(jsonBody !== undefined + ? { data: JSON.stringify(jsonBody) } + : body !== undefined ? { form: body } : {}), + failOnStatusCode: false, + }) + + if (!response.ok() && response.status() !== 404) { + throw new Error(`OCS request failed: ${method} ${path} → ${response.status()} ${await response.text()}`) + } + + const text = await response.text() + if (!text) { + return { ocs: { meta: { status: 'ok', statuscode: response.status(), message: '' }, data: {} } } as OcsResponse + } + return JSON.parse(text) as OcsResponse +} + +// --------------------------------------------------------------------------- +// Users +// --------------------------------------------------------------------------- + +/** + * Creates a user if it doesn't exist yet. + * Equivalent to Behat: `user :user exists` + */ +export async function ensureUserExists( + request: APIRequestContext, + userId: string, + password = '123456', +): Promise { + const check = await ocsRequest(request, 'GET', `/cloud/users/${userId}`) + if (check.ocs.meta.statuscode === 200) { + return + } + const create = await ocsRequest(request, 'POST', '/cloud/users', undefined, undefined, { + userid: userId, + password, + }) + if (create.ocs.meta.statuscode !== 200) { + throw new Error(`Failed to create user "${userId}": ${create.ocs.meta.message}`) + } +} + +/** + * Deletes a user. Silently succeeds if the user doesn't exist. + */ +export async function deleteUser( + request: APIRequestContext, + userId: string, +): Promise { + await ocsRequest(request, 'DELETE', `/cloud/users/${userId}`) +} + +// --------------------------------------------------------------------------- +// App config (equivalent to `occ config:app:set`) +// --------------------------------------------------------------------------- + +/** + * Sets an app config value. + * Equivalent to: `occ config:app:set --value=` + */ +export async function setAppConfig( + request: APIRequestContext, + appId: string, + key: string, + value: string, +): Promise { + const result = await ocsRequest( + request, + 'POST', + `/apps/provisioning_api/api/v1/config/apps/${appId}/${key}`, + undefined, + undefined, + { value }, + ) + if (result.ocs.meta.statuscode !== 200) { + throw new Error(`Failed to set app config ${appId}/${key}: ${result.ocs.meta.message}`) + } +} + +/** + * Deletes an app config value. + * Equivalent to: `occ config:app:delete ` + */ +export async function deleteAppConfig( + request: APIRequestContext, + appId: string, + key: string, +): Promise { + await ocsRequest(request, 'DELETE', `/apps/provisioning_api/api/v1/config/apps/${appId}/${key}`) +} + +// --------------------------------------------------------------------------- +// LibreSign-specific helpers +// --------------------------------------------------------------------------- + +type OpenSslCertNames = { + OU?: string | string[] + O?: string + C?: string + ST?: string + L?: string +} + +/** + * Configures the OpenSSL certificate engine. + * Equivalent to: `occ libresign:configure:openssl --cn=... --c=... ...` + */ +export async function configureOpenSsl( + request: APIRequestContext, + commonName: string, + names: OpenSslCertNames = {}, +): Promise { + const normalised: OpenSslCertNames = { ...names } + if (typeof normalised.OU === 'string') { + normalised.OU = [normalised.OU] + } + + const namesArray = (Object.entries(normalised) as [string, string | string[] | undefined][]) + .filter(([, val]) => val !== undefined) + .map(([id, value]) => ({ id, value })) + + const result = await ocsRequest( + request, + 'POST', + '/apps/libresign/api/v1/admin/certificate/openssl', + undefined, + undefined, + undefined, + { rootCert: { commonName, names: namesArray } }, + ) + if (result.ocs.meta.statuscode !== 200) { + throw new Error(`Failed to configure OpenSSL: ${result.ocs.meta.message}`) + } +}