From cfb242f0ee9b6faba18180ed121e955f5099bb20 Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 15 Apr 2025 13:48:43 +0200 Subject: [PATCH 01/71] first commit --- README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ece9ad --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# matrix-authentication-service-tchap From 1e92e0b47aa473523eb2b52ae7e167bb2bcf1977 Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 15 Apr 2025 15:00:51 +0200 Subject: [PATCH 02/71] add playwright tests --- .../playwright/.env.sample | 17 + .../playwright/.gitignore | 32 ++ .../playwright/README.md | 102 +++++ .../playwright/fixtures/auth-fixture.ts | 38 ++ .../playwright/init.sh | 23 + .../playwright/package-lock.json | 413 ++++++++++++++++++ .../playwright/package.json | 26 ++ .../playwright/playwright.config.ts | 55 +++ .../playwright/tests/oidc-auth.spec.ts | 72 +++ .../playwright/tests/utils/auth-helpers.ts | 136 ++++++ .../playwright/tests/utils/config.ts | 36 ++ .../playwright/tests/utils/keycloak-admin.ts | 205 +++++++++ .../playwright/tests/utils/mas-admin.ts | 227 ++++++++++ .../playwright/tsconfig.json | 17 + 14 files changed, 1399 insertions(+) create mode 100644 matrix-authentication-service-tchap/playwright/.env.sample create mode 100644 matrix-authentication-service-tchap/playwright/.gitignore create mode 100644 matrix-authentication-service-tchap/playwright/README.md create mode 100644 matrix-authentication-service-tchap/playwright/fixtures/auth-fixture.ts create mode 100755 matrix-authentication-service-tchap/playwright/init.sh create mode 100644 matrix-authentication-service-tchap/playwright/package-lock.json create mode 100644 matrix-authentication-service-tchap/playwright/package.json create mode 100644 matrix-authentication-service-tchap/playwright/playwright.config.ts create mode 100644 matrix-authentication-service-tchap/playwright/tests/oidc-auth.spec.ts create mode 100644 matrix-authentication-service-tchap/playwright/tests/utils/auth-helpers.ts create mode 100644 matrix-authentication-service-tchap/playwright/tests/utils/config.ts create mode 100644 matrix-authentication-service-tchap/playwright/tests/utils/keycloak-admin.ts create mode 100644 matrix-authentication-service-tchap/playwright/tests/utils/mas-admin.ts create mode 100644 matrix-authentication-service-tchap/playwright/tsconfig.json diff --git a/matrix-authentication-service-tchap/playwright/.env.sample b/matrix-authentication-service-tchap/playwright/.env.sample new file mode 100644 index 0000000..638cbed --- /dev/null +++ b/matrix-authentication-service-tchap/playwright/.env.sample @@ -0,0 +1,17 @@ +# URLs +MAS_URL=https://auth.tchapgouv.com +KEYCLOAK_URL=https://sso.tchapgouv.com + +# Keycloak Admin Credentials +KEYCLOAK_ADMIN_USERNAME=admin +KEYCLOAK_ADMIN_PASSWORD= +KEYCLOAK_REALM=proconnect-mock +KEYCLOAK_CLIENT_ID=mas + +# MAS Admin API Credentials +MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 +MAS_ADMIN_CLIENT_SECRET= + +# Test User Credentials +TEST_USER_PREFIX=playwright_test_user +TEST_USER_PASSWORD=Test@123456 diff --git a/matrix-authentication-service-tchap/playwright/.gitignore b/matrix-authentication-service-tchap/playwright/.gitignore new file mode 100644 index 0000000..ccdddcd --- /dev/null +++ b/matrix-authentication-service-tchap/playwright/.gitignore @@ -0,0 +1,32 @@ +# Dependencies +node_modules/ +.npm +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Playwright +playwright-report/ +playwright-results/ +test-results/ +playwright/.cache/ + +# Environment variables +.env + +# Build artifacts +dist/ +build/ + +# IDE +.idea/ +.vscode/* +!.vscode/extensions.json +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +*.code-workspace + +# OS +.DS_Store +Thumbs.db diff --git a/matrix-authentication-service-tchap/playwright/README.md b/matrix-authentication-service-tchap/playwright/README.md new file mode 100644 index 0000000..56f4231 --- /dev/null +++ b/matrix-authentication-service-tchap/playwright/README.md @@ -0,0 +1,102 @@ +# Playwright Tests for Matrix Authentication Service + +Ce projet contient des tests Playwright pour tester le scénario d'authentification OIDC avec Keycloak dans le service d'authentification Matrix (MAS). + +## Prérequis + +- Node.js 16 ou supérieur +- npm ou yarn +- Docker pour exécuter Keycloak et PostgreSQL +- Keycloak accessible sur `sso.tchapgouv.com` +- Matrix Authentication Service accessible sur `auth.tchapgouv.com` + +### Démarrage des services + +Avant d'exécuter les tests, vous devez démarrer les services Keycloak et Matrix Authentication Service : + +```bash +# Démarrer Keycloak et PostgreSQL +./tchap/start-local-stack.sh + +# Démarrer le service Matrix Authentication Service +./tchap/start-local-mas.sh +``` + +Ces scripts démarrent les services nécessaires pour les tests : +- PostgreSQL pour le stockage des données +- Keycloak avec le realm `proconnect-mock` préconfiguré +- Matrix Authentication Service configuré pour utiliser Keycloak comme fournisseur OIDC + +Les entrees DNS doivent etre présentes dans le fichier hosts + +## Installation + +Pour initialiser rapidement le projet, utilisez le script d'initialisation : + +```bash +# Rendre le script exécutable +chmod +x init.sh + +# Exécuter le script d'initialisation +./init.sh +``` + +Ce script va : +- Créer le répertoire pour les résultats des tests +- Installer les dépendances npm +- Installer les navigateurs Playwright + + +Vous pouvez également effectuer ces étapes manuellement : + +```bash +# Installer les dépendances +npm install + +# Installer les navigateurs Playwright +npx playwright install +``` + +## Configuration + +Les tests utilisent un fichier `.env` pour la configuration. Vous pouvez modifier ce fichier pour adapter les tests à votre environnement. + +``` +# URLs +MAS_URL=https://auth.tchapgouv.com +KEYCLOAK_URL=https://sso.tchapgouv.com + +# Keycloak Admin Credentials +KEYCLOAK_ADMIN_USERNAME=admin +KEYCLOAK_ADMIN_PASSWORD=admin +KEYCLOAK_REALM=proconnect-mock +KEYCLOAK_CLIENT_ID=mas + +# MAS Admin API Credentials +MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 +MAS_ADMIN_CLIENT_SECRET=phoo8ahneir3ohY2eigh4xuu6Oodaewi + +# Test User Credentials +TEST_USER_PREFIX=playwright_test_user +TEST_USER_PASSWORD=Test@123456 +``` + +## Exécution des tests + +```bash +# Exécuter tous les tests +npm test + +# Exécuter les tests avec l'interface utilisateur visible +npm run test:headed + +# Exécuter les tests en mode debug +npm run test:debug + +# Exécuter les tests avec l'interface utilisateur de Playwright +npm run test:ui +``` + +## Captures d'écran + +Les tests génèrent des captures d'écran à chaque étape importante du processus d'authentification. Ces captures sont enregistrées dans le répertoire `playwright-results/`. \ No newline at end of file diff --git a/matrix-authentication-service-tchap/playwright/fixtures/auth-fixture.ts b/matrix-authentication-service-tchap/playwright/fixtures/auth-fixture.ts new file mode 100644 index 0000000..1aa21a0 --- /dev/null +++ b/matrix-authentication-service-tchap/playwright/fixtures/auth-fixture.ts @@ -0,0 +1,38 @@ +import { test as base } from '@playwright/test'; +import { createKeycloakTestUser, cleanupKeycloakTestUser, TestUser } from '../tests/utils/auth-helpers'; +import { disposeApiContext as disposeKeycloakApiContext } from '../tests/utils/keycloak-admin'; +import { disposeApiContext as disposeMasApiContext } from '../tests/utils/mas-admin'; + +/** + * Extend the basic test fixtures with our authentication fixtures + */ +export const test = base.extend<{ + testUser: TestUser; +}>({ + /** + * Create a test user in Keycloak before the test and clean it up after + */ + testUser: async ({}, use) => { + try { + // Create a test user in Keycloak + const user = await createKeycloakTestUser(); + console.log(`Created test user: ${user.username} (${user.email})`); + + // Use the test user in the test + await use(user); + + // Clean up the test user after the test + await cleanupKeycloakTestUser(user); + console.log(`Cleaned up test user: ${user.username}`); + } finally { + // Dispose API contexts + await Promise.all([ + disposeKeycloakApiContext(), + disposeMasApiContext() + ]); + console.log('API contexts disposed'); + } + }, +}); + +export { expect } from '@playwright/test'; diff --git a/matrix-authentication-service-tchap/playwright/init.sh b/matrix-authentication-service-tchap/playwright/init.sh new file mode 100755 index 0000000..677efc1 --- /dev/null +++ b/matrix-authentication-service-tchap/playwright/init.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# This script initializes the Playwright test project + +# Create the results directory +mkdir -p playwright-results + +# Install dependencies +echo "Installing dependencies..." +rm -rf node_modules +npm install + +# Install Playwright browsers +echo "Installing Playwright browsers..." +npx playwright install + +echo "Initialization complete!" +echo "" +echo "Next steps:" +echo "1. Start the services with: ./tchap/start-local-stack.sh and ./tchap/start-local-mas.sh" +echo "2. Run the tests with: npm test" +echo "" +echo "For more information, see the README.md file." diff --git a/matrix-authentication-service-tchap/playwright/package-lock.json b/matrix-authentication-service-tchap/playwright/package-lock.json new file mode 100644 index 0000000..5347503 --- /dev/null +++ b/matrix-authentication-service-tchap/playwright/package-lock.json @@ -0,0 +1,413 @@ +{ + "name": "mas-playwright-tests", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mas-playwright-tests", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.40.0", + "@types/node": "^20.10.0", + "@types/node-fetch": "^2.6.9", + "dotenv": "^16.3.1", + "node-fetch": "^2.6.9", + "typescript": "^5.3.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.51.1.tgz", + "integrity": "sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==", + "dev": true, + "dependencies": { + "playwright": "1.51.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "20.17.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.30.tgz", + "integrity": "sha512-7zf4YyHA+jvBNfVrk2Gtvs6x7E8V+YDW05bNfG2XkWDJfYRXrTiP/DsB2zSYTaHX0bGIujTBQdMVAhb+j7mwpg==", + "dev": true, + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", + "dev": true, + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "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, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/playwright": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.1.tgz", + "integrity": "sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==", + "dev": true, + "dependencies": { + "playwright-core": "1.51.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.1.tgz", + "integrity": "sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } +} diff --git a/matrix-authentication-service-tchap/playwright/package.json b/matrix-authentication-service-tchap/playwright/package.json new file mode 100644 index 0000000..a18d145 --- /dev/null +++ b/matrix-authentication-service-tchap/playwright/package.json @@ -0,0 +1,26 @@ +{ + "name": "mas-playwright-tests", + "version": "1.0.0", + "description": "Playwright tests for Matrix Authentication Service OIDC authentication", + "main": "index.js", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "test:debug": "playwright test --debug", + "test:ui": "playwright test --ui" + }, + "keywords": [ + "playwright", + "testing", + "oidc", + "keycloak" + ], + "author": "", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.40.0", + "@types/node": "^20.10.0", + "dotenv": "^16.3.1", + "typescript": "^5.3.2" + } +} diff --git a/matrix-authentication-service-tchap/playwright/playwright.config.ts b/matrix-authentication-service-tchap/playwright/playwright.config.ts new file mode 100644 index 0000000..4fe7047 --- /dev/null +++ b/matrix-authentication-service-tchap/playwright/playwright.config.ts @@ -0,0 +1,55 @@ +import { defineConfig, devices } from '@playwright/test'; +import dotenv from 'dotenv'; + +// Load environment variables from .env file +dotenv.config(); + +/** + * See https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: './tests', + /* Maximum time one test can run for */ + timeout: 60 * 1000, + /* 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, + /* Reporter to use */ + reporter: 'html', + /* Shared settings for all the projects below */ + use: { + /* Base URL to use in actions like `await page.goto('/')` */ + baseURL: process.env.MAS_URL || 'https://auth.tchapgouv.com', + + /* Collect trace when retrying the failed test */ + trace: 'on-first-retry', + + /* Take screenshot on failure */ + screenshot: 'only-on-failure', + + /* Record video on failure */ + video: 'on-first-retry', + + /* Ignore HTTPS errors */ + ignoreHTTPSErrors: true, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + ], +}); diff --git a/matrix-authentication-service-tchap/playwright/tests/oidc-auth.spec.ts b/matrix-authentication-service-tchap/playwright/tests/oidc-auth.spec.ts new file mode 100644 index 0000000..aba8759 --- /dev/null +++ b/matrix-authentication-service-tchap/playwright/tests/oidc-auth.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from '../fixtures/auth-fixture'; +import { + performOidcLogin, + verifyUserInMas, + createMasTestUser, + cleanupMasTestUser, + performPasswordLogin, + TestUser +} from './utils/auth-helpers'; +import { checkMasUserExistsByEmail } from './utils/mas-admin'; + +test.describe('Authentication Flows', () => { + // Create a directory for screenshots + test.beforeAll(async ({ }, testInfo) => { + const { exec } = require('child_process'); + exec('mkdir -p playwright-results'); + }); + + test('should authenticate via Keycloak and create user in MAS', async ({ page, testUser }) => { + // Verify the test user doesn't exist in MAS yet + const existsBeforeLogin = await checkMasUserExistsByEmail(testUser.email); + expect(existsBeforeLogin).toBe(false); + + // Perform the OIDC login flow + await performOidcLogin(page, testUser); + + // Click the create account button + await page.locator('button[type="submit"]').click(); + + // Verify we're successfully logged in + // This could be checking for a specific element that's only visible when logged in + await expect(page.locator('text=Sign out')).toBeVisible(); + + // Take a screenshot of the authenticated state + await page.screenshot({ path: 'playwright-results/04-authenticated.png' }); + + // Verify the user was created in MAS + await verifyUserInMas(testUser); + + // Double-check with the API + const existsAfterLogin = await checkMasUserExistsByEmail(testUser.email); + expect(existsAfterLogin).toBe(true); + + console.log(`Successfully authenticated and verified user ${testUser.username} (${testUser.email})`); + console.log(`User ID in Keycloak: ${testUser.keycloakId}`); + console.log(`User ID in MAS: ${testUser.masId}`); + }); + + test('should authenticate with username and password', async ({ page }) => { + // Create a test user with a password in MAS + const user = await createMasTestUser(); + + try { + console.log(`Created test user in MAS: ${user.username} (${user.email})`); + + // Perform password login + await performPasswordLogin(page, user); + + // Verify we're successfully logged in + await expect(page.locator('text=Sign out')).toBeVisible(); + + // Take a screenshot of the authenticated state + await page.screenshot({ path: 'playwright-results/04-password-auth-success.png' }); + + console.log(`Successfully authenticated with password for user: ${user.username}`); + } finally { + // Clean up the test user + await cleanupMasTestUser(user); + console.log(`Cleaned up test user: ${user.username}`); + } + }); +}); diff --git a/matrix-authentication-service-tchap/playwright/tests/utils/auth-helpers.ts b/matrix-authentication-service-tchap/playwright/tests/utils/auth-helpers.ts new file mode 100644 index 0000000..94ce867 --- /dev/null +++ b/matrix-authentication-service-tchap/playwright/tests/utils/auth-helpers.ts @@ -0,0 +1,136 @@ +import { Page } from '@playwright/test'; +import { createKeycloakUser, deleteKeycloakUser } from './keycloak-admin'; +import { waitForMasUser, createMasUserWithPassword, deactivateMasUser } from './mas-admin'; +import { generateTestUser } from './config'; + +/** + * Test user type + */ +export interface TestUser { + username: string; + email: string; + password: string; + keycloakId?: string; + masId?: string; +} + +/** + * Create a test user in Keycloak + */ +export async function createKeycloakTestUser(): Promise { + const user = generateTestUser(); + const keycloakId = await createKeycloakUser(user.username, user.email, user.password); + return { ...user, keycloakId }; +} + +/** + * Clean up a test user + */ +export async function cleanupKeycloakTestUser(user: TestUser): Promise { + if (user.keycloakId) { + await deleteKeycloakUser(user.keycloakId); + } +} + +/** + * Perform OIDC login via Keycloak + * This function handles the entire authentication flow: + * 1. Navigate to MAS login page + * 2. Click on the OIDC provider button + * 3. Fill in credentials on the Keycloak login page + * 4. Wait for successful authentication and redirect back to MAS + */ +export async function performOidcLogin(page: Page, user: TestUser): Promise { + // Navigate to the login page + await page.goto('/login'); + + // Take a screenshot of the login page + await page.screenshot({ path: 'playwright-results/01-login-page.png' }); + + // Find and click the OIDC provider button (adjust the selector as needed) + // This is based on the login.html template which shows provider buttons + const oidcButton = page.locator('a.cpd-button[href*="/upstream/authorize/"]'); + await oidcButton.click(); + + // Wait for navigation to Keycloak + await page.waitForURL(url => url.toString().includes('sso.tchapgouv.com')); + + // Take a screenshot of the Keycloak login page + await page.screenshot({ path: 'playwright-results/02-keycloak-login.png' }); + + // Fill in the username and password + await page.locator('#username').fill(user.username); + await page.locator('#password').fill(user.password); + + // Click the login button + await page.locator('button[type="submit"]').click(); + + + // Wait for redirect back to MAS + await page.waitForURL(url => url.toString().includes('auth.tchapgouv.com')); + + // Take a screenshot after successful login + await page.screenshot({ path: 'playwright-results/03-after-login.png' }); +} + +/** + * Verify that a user was created in MAS after OIDC authentication + */ +export async function verifyUserInMas(user: TestUser): Promise { + const masUser = await waitForMasUser(user.email); + user.masId = masUser.id; +} + +/** + * Create a test user directly in MAS with password + */ +export async function createMasTestUser(): Promise { + const user = generateTestUser(); + const masId = await createMasUserWithPassword(user.username, user.email, user.password); + return { ...user, masId }; +} + +/** + * Clean up a MAS test user + */ +export async function cleanupMasTestUser(user: TestUser): Promise { + if (user.masId) { + await deactivateMasUser(user.masId); + } +} + +/** + * Perform password login to MAS + * This function handles the direct authentication flow: + * 1. Navigate to MAS login page + * 2. Fill in username and password + * 3. Submit the form + * 4. Wait for successful authentication + */ +export async function performPasswordLogin(page: Page, user: TestUser): Promise { + console.log(`[Auth] Performing password login for user: ${user.username}`); + + // Navigate to the login page + await page.goto('/login'); + + // Take a screenshot of the login page + await page.screenshot({ path: 'playwright-results/01-password-login-page.png' }); + + // Fill in the username and password + await page.locator('input[name="username"]').fill(user.username); + await page.locator('input[name="password"]').fill(user.password); + + // Take a screenshot before submitting + await page.screenshot({ path: 'playwright-results/02-password-login-filled.png' }); + + // Click the login button (submit the form) + await page.locator('button[type="submit"]').click(); + + // Wait for successful login (redirect to dashboard or home page) + await page.waitForURL(url => !url.toString().includes('/login')); + + // Take a screenshot after successful login + await page.screenshot({ path: 'playwright-results/03-password-login-success.png' }); + + console.log(`[Auth] Password login successful for user: ${user.username}`); +} diff --git a/matrix-authentication-service-tchap/playwright/tests/utils/config.ts b/matrix-authentication-service-tchap/playwright/tests/utils/config.ts new file mode 100644 index 0000000..0437b71 --- /dev/null +++ b/matrix-authentication-service-tchap/playwright/tests/utils/config.ts @@ -0,0 +1,36 @@ +import dotenv from 'dotenv'; + +// Load environment variables from .env file +dotenv.config(); + +// URLs +export const MAS_URL = process.env.MAS_URL || 'https://auth.tchapgouv.com'; +export const KEYCLOAK_URL = process.env.KEYCLOAK_URL || 'https://sso.tchapgouv.com'; + +// Keycloak Admin Credentials +export const KEYCLOAK_ADMIN_USERNAME = process.env.KEYCLOAK_ADMIN_USERNAME || 'admin'; +export const KEYCLOAK_ADMIN_PASSWORD = process.env.KEYCLOAK_ADMIN_PASSWORD || 'admin'; +export const KEYCLOAK_REALM = process.env.KEYCLOAK_REALM || 'proconnect-mock'; +export const KEYCLOAK_CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID || 'mas'; + +// MAS Admin API Credentials +export const MAS_ADMIN_CLIENT_ID = process.env.MAS_ADMIN_CLIENT_ID || '01J44RKQYM4G3TNVANTMTDYTX6'; +export const MAS_ADMIN_CLIENT_SECRET = process.env.MAS_ADMIN_CLIENT_SECRET || 'phoo8ahneir3ohY2eigh4xuu6Oodaewi'; + +// Test User Credentials +export const TEST_USER_PREFIX = process.env.TEST_USER_PREFIX || 'playwright_test_user'; +export const TEST_USER_PASSWORD = process.env.TEST_USER_PASSWORD || 'Test@123456'; + +// Generate a unique username and email for testing +export function generateTestUser() { + const timestamp = new Date().getTime(); + const randomSuffix = Math.floor(Math.random() * 10000); + const username = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}`; + const email = `${username}@example.com`; + + return { + username, + email, + password: TEST_USER_PASSWORD + }; +} diff --git a/matrix-authentication-service-tchap/playwright/tests/utils/keycloak-admin.ts b/matrix-authentication-service-tchap/playwright/tests/utils/keycloak-admin.ts new file mode 100644 index 0000000..456af29 --- /dev/null +++ b/matrix-authentication-service-tchap/playwright/tests/utils/keycloak-admin.ts @@ -0,0 +1,205 @@ +import { APIRequestContext, request } from '@playwright/test'; +import { + KEYCLOAK_URL, + KEYCLOAK_ADMIN_USERNAME, + KEYCLOAK_ADMIN_PASSWORD, + KEYCLOAK_REALM +} from './config'; + +// Create a reusable API request context +let apiContext: APIRequestContext | null = null; + +async function getApiContext(): Promise { + if (!apiContext) { + console.log(`[Keycloak API] Creating new API context with baseURL: ${KEYCLOAK_URL}`); + apiContext = await request.newContext({ + baseURL: KEYCLOAK_URL, + ignoreHTTPSErrors: true + }); + } + return apiContext; +} + +/** + * Get an admin access token for Keycloak + */ +export async function getKeycloakAdminToken(): Promise { + console.log(`[Keycloak API] Requesting admin token with username: ${KEYCLOAK_ADMIN_USERNAME}`); + const apiRequestContext = await getApiContext(); + + const response = await apiRequestContext.post('/realms/master/protocol/openid-connect/token', { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + form: { + grant_type: 'password', + client_id: 'admin-cli', + username: KEYCLOAK_ADMIN_USERNAME, + password: KEYCLOAK_ADMIN_PASSWORD + } + }); + + if (!response.ok()) { + const errorText = await response.text(); + console.error(`[Keycloak API] Failed to get admin token: ${response.status()} - ${errorText}`); + throw new Error(`Failed to get Keycloak admin token: ${response.status()} - ${errorText}`); + } + + const data = await response.json() as { access_token: string }; + console.log(`[Keycloak API] Successfully obtained admin token`); + return data.access_token; +} + +/** + * Create a user in Keycloak + */ +export async function createKeycloakUser(username: string, email: string, password: string): Promise { + console.log(`[Keycloak API] Creating user: ${username} with email: ${email}`); + const token = await getKeycloakAdminToken(); + const apiRequestContext = await getApiContext(); + + // First, create the user + console.log(`[Keycloak API] Creating user in realm: ${KEYCLOAK_REALM}`); + const createResponse = await apiRequestContext.post(`/admin/realms/${KEYCLOAK_REALM}/users`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + data: { + username, + email, + enabled: true, + emailVerified: true, + firstName: username, + lastName: username, + attributes: { + idp_id: username + } + } + }); + + if (!createResponse.ok()) { + const errorText = await createResponse.text(); + console.error(`[Keycloak API] Failed to create user: ${createResponse.status()} - ${errorText}`); + throw new Error(`Failed to create Keycloak user: ${createResponse.status()} - ${errorText}`); + } + console.log(`[Keycloak API] User created successfully`); + + // Get the user ID + console.log(`[Keycloak API] Getting user ID for username: ${username}`); + const usersResponse = await apiRequestContext.get( + `/admin/realms/${KEYCLOAK_REALM}/users?username=${encodeURIComponent(username)}`, + { + headers: { + 'Authorization': `Bearer ${token}` + } + } + ); + + if (!usersResponse.ok()) { + const errorText = await usersResponse.text(); + console.error(`[Keycloak API] Failed to get user ID: ${usersResponse.status()} - ${errorText}`); + throw new Error(`Failed to get Keycloak user: ${usersResponse.status()} - ${errorText}`); + } + + const users = await usersResponse.json() as Array<{ id: string }>; + if (users.length === 0) { + console.error(`[Keycloak API] User ${username} not found after creation`); + throw new Error(`User ${username} not found after creation`); + } + + const userId = users[0].id; + console.log(`[Keycloak API] Found user with ID: ${userId}`); + + // Set the password + console.log(`[Keycloak API] Setting password for user: ${username}`); + const passwordResponse = await apiRequestContext.put( + `/admin/realms/${KEYCLOAK_REALM}/users/${userId}/reset-password`, + { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + data: { + type: 'password', + value: password, + temporary: false + } + } + ); + + if (!passwordResponse.ok()) { + const errorText = await passwordResponse.text(); + console.error(`[Keycloak API] Failed to set password: ${passwordResponse.status()} - ${errorText}`); + throw new Error(`Failed to set Keycloak user password: ${passwordResponse.status()} - ${errorText}`); + } + console.log(`[Keycloak API] Password set successfully for user: ${username}`); + + return userId; +} + +/** + * Delete a user from Keycloak + */ +export async function deleteKeycloakUser(userId: string): Promise { + console.log(`[Keycloak API] Deleting user with ID: ${userId}`); + const token = await getKeycloakAdminToken(); + const apiRequestContext = await getApiContext(); + + const response = await apiRequestContext.delete( + `/admin/realms/${KEYCLOAK_REALM}/users/${userId}`, + { + headers: { + 'Authorization': `Bearer ${token}` + } + } + ); + + if (!response.ok()) { + const errorText = await response.text(); + console.error(`[Keycloak API] Failed to delete user: ${response.status()} - ${errorText}`); + throw new Error(`Failed to delete Keycloak user: ${response.status()} - ${errorText}`); + } + console.log(`[Keycloak API] User deleted successfully`); +} + +/** + * Check if a user exists in Keycloak + */ +export async function checkKeycloakUserExists(username: string): Promise { + console.log(`[Keycloak API] Checking if user exists: ${username}`); + const token = await getKeycloakAdminToken(); + const apiRequestContext = await getApiContext(); + + const response = await apiRequestContext.get( + `/admin/realms/${KEYCLOAK_REALM}/users?username=${encodeURIComponent(username)}`, + { + headers: { + 'Authorization': `Bearer ${token}` + } + } + ); + + if (!response.ok()) { + const errorText = await response.text(); + console.error(`[Keycloak API] Failed to check user: ${response.status()} - ${errorText}`); + throw new Error(`Failed to check Keycloak user: ${response.status()} - ${errorText}`); + } + + const users = await response.json() as Array<{ id: string }>; + const exists = users.length > 0; + console.log(`[Keycloak API] User ${username} exists: ${exists}`); + return exists; +} + +/** + * Dispose the API context when done + */ +export async function disposeApiContext(): Promise { + if (apiContext) { + console.log(`[Keycloak API] Disposing API context`); + await apiContext.dispose(); + apiContext = null; + console.log(`[Keycloak API] API context disposed`); + } else { + console.log(`[Keycloak API] No API context to dispose`); + } +} diff --git a/matrix-authentication-service-tchap/playwright/tests/utils/mas-admin.ts b/matrix-authentication-service-tchap/playwright/tests/utils/mas-admin.ts new file mode 100644 index 0000000..13cebb8 --- /dev/null +++ b/matrix-authentication-service-tchap/playwright/tests/utils/mas-admin.ts @@ -0,0 +1,227 @@ +import { APIRequestContext, request } from '@playwright/test'; +import { + MAS_URL, + MAS_ADMIN_CLIENT_ID, + MAS_ADMIN_CLIENT_SECRET +} from './config'; + +// Create a reusable API request context +let apiContext: APIRequestContext | null = null; + +async function getApiContext(): Promise { + if (!apiContext) { + console.log(`[MAS API] Creating new API context with baseURL: ${MAS_URL}`); + apiContext = await request.newContext({ + baseURL: MAS_URL, + ignoreHTTPSErrors: true + }); + } + return apiContext; +} + +/** + * Get an admin access token for MAS + */ +export async function getMasAdminToken(): Promise { + console.log(`[MAS API] Requesting admin token with client ID: ${MAS_ADMIN_CLIENT_ID}`); + const apiRequestContext = await getApiContext(); + const authHeader = Buffer.from(`${MAS_ADMIN_CLIENT_ID}:${MAS_ADMIN_CLIENT_SECRET}`).toString('base64'); + + const response = await apiRequestContext.post('/oauth2/token', { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': `Basic ${authHeader}` + }, + form: { + grant_type: 'client_credentials', + scope: 'urn:mas:admin' + } + }); + + if (!response.ok()) { + const errorText = await response.text(); + console.error(`[MAS API] Failed to get admin token: ${response.status()} - ${errorText}`); + throw new Error(`Failed to get MAS admin token: ${response.status()} - ${errorText}`); + } + + const data = await response.json() as { access_token: string }; + console.log(`[MAS API] Successfully obtained admin token`); + return data.access_token; +} + +/** + * Check if a user exists in MAS by email + */ +export async function checkMasUserExistsByEmail(email: string): Promise { + console.log(`[MAS API] Checking if user exists with email: ${email}`); + const token = await getMasAdminToken(); + const apiRequestContext = await getApiContext(); + + const response = await apiRequestContext.get( + `/api/admin/v1/user-emails?filter[email]=${encodeURIComponent(email)}`, + { + headers: { + 'Authorization': `Bearer ${token}` + } + } + ); + + if (!response.ok()) { + const errorText = await response.text(); + console.error(`[MAS API] Failed to check user: ${response.status()} - ${errorText}`); + throw new Error(`Failed to check MAS user: ${response.status()} - ${errorText}`); + } + + const result = await response.json() as { data: Array<{ id: string }> }; + const exists = result.data.length > 0; + console.log(`[MAS API] User with email ${email} exists: ${exists}`); + return exists; +} + +/** + * Get user details from MAS by email + */ +export async function getMasUserByEmail(email: string): Promise { + console.log(`[MAS API] Getting user details for email: ${email}`); + const token = await getMasAdminToken(); + const apiRequestContext = await getApiContext(); + + const response = await apiRequestContext.get( + `/api/admin/v1/user-emails?filter[email]=${encodeURIComponent(email)}`, + { + headers: { + 'Authorization': `Bearer ${token}` + } + } + ); + + if (!response.ok()) { + const errorText = await response.text(); + console.error(`[MAS API] Failed to get user details: ${response.status()} - ${errorText}`); + throw new Error(`Failed to get MAS user: ${response.status()} - ${errorText}`); + } + + const result = await response.json() as { data: Array }; + const user = result.data.length > 0 ? result.data[0] : null; + console.log(`[MAS API] User found: ${user !== null ? 'Yes' : 'No'}`); + if (user) { + console.log(`[MAS API] User ID: ${user.id}, Username: ${user.attributes?.username || 'N/A'}`); + } + return user; +} + +/** + * Wait for a user to be created in MAS + * This is useful after OIDC authentication, as there might be a slight delay + * before the user is fully created in MAS + */ +export async function waitForMasUser(email: string, maxAttempts = 10, delayMs = 1000): Promise { + console.log(`[MAS API] Waiting for user with email ${email} to be created (max ${maxAttempts} attempts)`); + for (let attempt = 0; attempt < maxAttempts; attempt++) { + console.log(`[MAS API] Attempt ${attempt + 1}/${maxAttempts} to find user`); + try { + const user = await getMasUserByEmail(email); + if (user) { + console.log(`[MAS API] User found on attempt ${attempt + 1}`); + return user; + } + console.log(`[MAS API] User not found on attempt ${attempt + 1}, waiting ${delayMs}ms before next attempt`); + } catch (error) { + console.warn(`[MAS API] Attempt ${attempt + 1}/${maxAttempts} failed: ${error}`); + } + + // Wait before the next attempt + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + + const errorMsg = `User with email ${email} not found in MAS after ${maxAttempts} attempts`; + console.error(`[MAS API] ${errorMsg}`); + throw new Error(errorMsg); +} + +/** + * Create a user in MAS with a password + */ +export async function createMasUserWithPassword(username: string, email: string, password: string): Promise { + console.log(`[MAS API] Creating user with password: ${username} (${email})`); + const token = await getMasAdminToken(); + const apiRequestContext = await getApiContext(); + + const response = await apiRequestContext.post('/api/admin/v1/users', { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + data: { + "username": username, + "skip_homeserver_check": false + } + }); + + if (!response.ok()) { + const errorText = await response.text(); + console.error(`[MAS API] Failed to create user: ${response.status()} - ${errorText}`); + throw new Error(`Failed to create MAS user: ${response.status()} - ${errorText}`); + } + + const data = await response.json(); + console.log(data.data) + const userId = data.data.id; + + const responsePwd = await apiRequestContext.post(`/api/admin/v1/users/${userId}/set-password`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + data: { + "password": password, + "skip_password_check": true + } + }); + + if (!responsePwd.ok()) { + const errorText = await responsePwd.text(); + console.error(`[MAS API] Failed to set password for user: ${responsePwd.status()} - ${errorText}`); + throw new Error(`Failed to set password for user: ${responsePwd.status()} - ${errorText}`); + } + + console.log(`[MAS API] User created successfully with ID: ${userId}`); + return userId; +} + +/** + * Delete a user from MAS + */ +export async function deactivateMasUser(userId: string): Promise { + console.log(`[MAS API] Deleting user with ID: ${userId}`); + const token = await getMasAdminToken(); + const apiRequestContext = await getApiContext(); + + const response = await apiRequestContext.post(`/api/admin/v1/users/${userId}/deactivate`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok()) { + const errorText = await response.text(); + console.error(`[MAS API] Failed to delete user: ${response.status()} - ${errorText}`); + throw new Error(`Failed to delete MAS user: ${response.status()} - ${errorText}`); + } + + console.log(`[MAS API] User deleted successfully`); +} + +/** + * Dispose the API context when done + */ +export async function disposeApiContext(): Promise { + if (apiContext) { + console.log(`[MAS API] Disposing API context`); + await apiContext.dispose(); + apiContext = null; + console.log(`[MAS API] API context disposed`); + } else { + console.log(`[MAS API] No API context to dispose`); + } +} diff --git a/matrix-authentication-service-tchap/playwright/tsconfig.json b/matrix-authentication-service-tchap/playwright/tsconfig.json new file mode 100644 index 0000000..852a783 --- /dev/null +++ b/matrix-authentication-service-tchap/playwright/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": ["node", "@playwright/test"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} From 74e58ee2b1f8e84e79fbe83e2e7ffdae1f5dc290 Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 15 Apr 2025 15:41:02 +0200 Subject: [PATCH 03/71] add repo folder structure --- .../.github/README.md | 1 + matrix-authentication-service-tchap/README.md | 12 ++ .../conf/config.local.dev.yaml | 200 ++++++++++++++++++ .../templates/README.md | 4 + .../tools/README_TCHAP.md | 38 ++++ .../tools/pre-commit-check.sh | 10 + .../tools/start-local-mas.sh | 5 + .../tools/start-local-stack.sh | 16 ++ .../tools/test_admin.sh | 20 ++ 9 files changed, 306 insertions(+) create mode 100644 matrix-authentication-service-tchap/.github/README.md create mode 100644 matrix-authentication-service-tchap/README.md create mode 100644 matrix-authentication-service-tchap/conf/config.local.dev.yaml create mode 100644 matrix-authentication-service-tchap/templates/README.md create mode 100644 matrix-authentication-service-tchap/tools/README_TCHAP.md create mode 100644 matrix-authentication-service-tchap/tools/pre-commit-check.sh create mode 100755 matrix-authentication-service-tchap/tools/start-local-mas.sh create mode 100755 matrix-authentication-service-tchap/tools/start-local-stack.sh create mode 100755 matrix-authentication-service-tchap/tools/test_admin.sh diff --git a/matrix-authentication-service-tchap/.github/README.md b/matrix-authentication-service-tchap/.github/README.md new file mode 100644 index 0000000..e7a373c --- /dev/null +++ b/matrix-authentication-service-tchap/.github/README.md @@ -0,0 +1 @@ +à reflechir \ No newline at end of file diff --git a/matrix-authentication-service-tchap/README.md b/matrix-authentication-service-tchap/README.md new file mode 100644 index 0000000..18375a4 --- /dev/null +++ b/matrix-authentication-service-tchap/README.md @@ -0,0 +1,12 @@ + +## folder structure + +- /.github custom CI (if needed) +- /conf : configuration du MAS special tchap +- /playwright : test E2E +- /templates : custom html templates, css and manifest.json +- /tools : for local dev +- /translations : tchap custom labels + + +export $MAS_HOME : path of /matrix-authentication-service : fork léger https://github.com/tchapgouv/matrix-authentication-service diff --git a/matrix-authentication-service-tchap/conf/config.local.dev.yaml b/matrix-authentication-service-tchap/conf/config.local.dev.yaml new file mode 100644 index 0000000..39fc4ba --- /dev/null +++ b/matrix-authentication-service-tchap/conf/config.local.dev.yaml @@ -0,0 +1,200 @@ +http: + listeners: + - name: web + resources: + - name: discovery + - name: human + - name: oauth + - name: compat + - name: graphql + - name: assets + - name: adminapi + binds: + - address: '[::]:8080' + proxy_protocol: false + - name: internal + resources: + - name: health + - name: adminapi + binds: + - host: localhost + port: 8081 + proxy_protocol: false + trusted_proxies: + - 192.168.0.0/16 + - 172.16.0.0/12 + - 10.0.0.0/10 + - 127.0.0.1/8 + - fd00::/8 + - ::1/128 + # public_base: http://[::]:8080/ + # issuer: http://[::]:8080/ + public_base: https://auth.tchapgouv.com/ + issuer: https://auth.tchapgouv.com/ +database: + uri: postgresql://postgres:postgres@localhost/postgres + max_connections: 10 + min_connections: 0 + connect_timeout: 30 + idle_timeout: 600 + max_lifetime: 1800 +email: + from: '"Authentication Service" ' + reply_to: '"Authentication Service" ' + transport: smtp + mode: plain + hostname: 127.0.0.1 + port: 1025 + +secrets: + encryption: eb38b8b9087842b3345269f3c6ca92b2a8d6aa63e3a773d23ed1c9cb45c5ef83 + keys: + - kid: dyrZtIyXSA + key: | + -----BEGIN RSA PRIVATE KEY----- + MIIEogIBAAKCAQEAukLyWSv9KOeBsmIG4ntL/BP+wj5L4GbOyAOEzRBBO0ZbBYVn + 1L/aYQ65cQpQKK0MzH5cn7TvIY0JBh/ZsSm3e7DdhGJzoqPrW6E0/6QqXEl4gN1q + DUCMj2CwAcT9OX1Wt6cNq70+gbqp6+yT+Nn++KPylHa/V9wRxkaV3fyM+XYVeddB + amDR+fBjHgVXQ3xk2ezUBS3AmyKBgETnHufKCkxJ5mXdU9HT0ewg8J2PrgiRBwDj + XP7C5Zif/+NfYPO2mM/b0Y91pl9aXuUHX+/zlqtpcwX/WprEsCaRUa9qWHuiftMf + uelsflCgZ0KumZjzr3wPDEfu8n7WbVEObNxF+QIDAQABAoIBAEl82mNGUMbPuEMq + G+9FmDAnr27x5zvtNA6EHORPUn1Rf94IyXOOEloS1iV8XS3/QLp57I9ycpq5K2NI + M7qLbAIYQP3XXipAJD7ttpxaKABrWGj3cr0xx4NWMXsxPnttMUaaWXF14/CJNjuI + BsW7NLbi8HWU+F9wy26AMOb5mqFdQfk15H3LaM3L3hMuV5DOcA467luwHmuGGTji + VjZg3yZZF2ROtTwwVSB7UCokmW3FZys/U4SyzXSrboUxF9T3PW3Hxm9e1JfFQuLh + rn85Q4IDpRtK2O5ECmKjY0cyvQOVItQytTeVXtSEzgMfK5VpnYMefdCFvhExcsuV + JHT1c/UCgYEAziAPCJYNTXsvo3yEW1iWnAjfi6R7g9aluNDjf4xmva5DTOdZSH1d + AJkzXZtPYXXlR42RT4FzE/ICdjo81aDMrMHwoIiH9p1n7IdDTt3GONYi3VPKGvZd + Ghgdq5jgdedDhF9MximZcWdZOZLCymKME61RuCg18p/MMIJ/FRNBE38CgYEA51R6 + CjNc93qO7hL7AGu+evs3/PVrHsg5iBf8GcGeyNNGkA5TJcYTO075VBDQuWsMZbvx + d0jBjROs8U2AJzgbh6+N/rZACflk8W4LyD3Pw+1coIcckH4hmgN37vkh63qiGX8K + YVe8CrbGliBB2OccXsdJVDe0f45kte0eJh6cAocCgYAH9d7+wuTCoEZHtxBZgsNW + RVV0zCZlAg4mZBLVIzP4kVlSCAE/tm+4DTKZo9zd87KmH8aD3oj2NTt5G2isC2i8 + J0VGvd8aXBveW57y1cfI/CQejhTZE7imwFWtAdtxUjweSZvqb0LYyVf9zDgvnryw + KdplFVB4DUnSece0pai2uwKBgDzV335dQZ6nsXz0quPScfZ/qJqyo+gledPLkvXn + EG35+f2ads1hSN95BmLQRUPt3gXHJlpbXONQAFQ5MHGf9MV7KpmIrlCxMJW5fgm8 + D66T9p8UyTNKqGWLcff7tqrpxkV0PnOZEg+zP4htlUOIi9J1EFjAiYxeEygw4pPd + yuNzAoGAI0lLE32iIm+j5byFCKRuS6cQmDBzUJxZiCuLsuZEINYdz2nxKZqd9VtA + rOXzo8vJuGLF1hjf1/C66F3EnPxcclPL10vLCE8RbfQYVwBxw7MG9BLJXIlMjb2V + 7CjVDE4Gaa96tChg1pepuJcOEuWez3o3Ard9oZ4Z9sm7VJzmuoY= + -----END RSA PRIVATE KEY----- + - kid: O1hkajPW2v + key: | + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEINsgMBFDIrIzqOIWuR94TCi5MTH1FS8wfgatu9BsO2jVoAoGCCqGSM49 + AwEHoUQDQgAEldEtvZaXtblpUdHpKKQiH7z9ADC55H0yrCYyQsLXbt14lI2NuseX + MWsvSLBzkbEetDxkmKh0bhOfrdwv9x5SwA== + -----END EC PRIVATE KEY----- + - kid: 2nhe3z2925 + key: | + -----BEGIN EC PRIVATE KEY----- + MIGkAgEBBDDTVngHypOwUnPOGXeskQJhdSLLPBCM+mkSvzr2SZ7Kjm3hftvs2s7J + gZBOZwXyoaKgBwYFK4EEACKhZANiAAQ3WGOQs3EqO2x4X7PBWs6Lw3qdmRLHqblc + Zplh3wYPDOoUMvD99Snxz43t5sK6kphLBL262/srx/UPT1McLUxBMlBvBUbBEKHX + a8icrL13yIwflquj0EHrE7czFJw1txs= + -----END EC PRIVATE KEY----- + - kid: 50aRR3QVqx + key: | + -----BEGIN EC PRIVATE KEY----- + MHQCAQEEIN8bErXY1sWEJ1y9KoYcpcUImIjpS/ay3pEugYPfr3Y/oAcGBSuBBAAK + oUQDQgAEMHcshHVFbMSEyyt3ptIdAhnrg+XlQskZ33hZvdtzm6I0wW8H8zslMp+I + t0KYCeIQ7HTPtgJAOsKxEPBfmVXZmA== + -----END EC PRIVATE KEY----- +passwords: + enabled: true + schemes: + - version: 1 + algorithm: bcrypt + secret: "secret01" + - version: 2 + algorithm: argon2id + minimum_complexity: 3 +matrix: + homeserver: tchapgouv.com + secret: '/DjWc4D3yyqgjYN8tum65g' + endpoint: https://matrix.tchapgouv.com/ + +clients: + - client_id: 0000000000000000000SYNAPSE + client_auth_method: client_secret_basic + client_secret: '/DjWc4D3yyqgjYN8tum65g' + + # for api admin calls + - client_id: 01J44RKQYM4G3TNVANTMTDYTX6 + client_auth_method: client_secret_basic + client_secret: phoo8ahneir3ohY2eigh4xuu6Oodaewi + + +policy: + data: + admin_clients: + # for api admin calls + - 01J44RKQYM4G3TNVANTMTDYTX6 + +account: + # Whether users are allowed to change their email addresses. + # + # Defaults to `true`. + email_change_allowed: false + + # Whether users are allowed to change their display names + # + # Defaults to `true`. + # This should be in sync with the policy in the homeserver configuration. + displayname_change_allowed: false + + # Whether to enable self-service password registration + # + # Defaults to `false`. + # This has no effect if password login is disabled. + password_registration_enabled: true + + # Whether users are allowed to change their passwords + # + # Defaults to `true`. + # This has no effect if password login is disabled. + password_change_allowed: true + + # Whether email-based password recovery is enabled + # + # Defaults to `false`. + # This has no effect if password login is disabled. + password_recovery_enabled: true + + allow_login_by_email: true + +#templates: + # From where to load the templates + # This is relative to the current working directory, *not* the config file +# path: /Users/mca/Documents/work/projets/betagouv/repo/matrix-authentication-service/templates2 + +# # Path to the frontend assets manifest file +# assets_manifest: /to/manifest.json +# +# # From where to load the translation files +# # Default in Docker distribution: `/usr/local/share/mas-cli/translations/` +# # Default in pre-built binaries: `./share/translations/` +# # Default in locally-built binaries: `./translations/` +# translations_path: /to/translations + + +# upstream_oauth2: +# providers: +# - id: "01JK5MR1SD21MAQY4PWMFG283W" +# issuer: "https://tchap-connect.tchapgouv.com/realms/tchap" +# token_endpoint_auth_method: client_secret_basic +# client_id: "matrix-authentication-service" +# client_secret: "HrJ1NZ0AbkHuWWjyRHh7X2lzn3S8eagt" +# scope: "openid profile email" +# claims_imports: +# localpart: +# action: force +# template: "{{ user.preferred_username }}" +# displayname: +# action: force +# template: "{{ user.name }}" +# email: +# action: force +# template: "{{ user.email }}" +# set_email_verification: always +# allow_existing_users: true diff --git a/matrix-authentication-service-tchap/templates/README.md b/matrix-authentication-service-tchap/templates/README.md new file mode 100644 index 0000000..d1055c8 --- /dev/null +++ b/matrix-authentication-service-tchap/templates/README.md @@ -0,0 +1,4 @@ +custom templates for tchap +#### + +manifest.json diff --git a/matrix-authentication-service-tchap/tools/README_TCHAP.md b/matrix-authentication-service-tchap/tools/README_TCHAP.md new file mode 100644 index 0000000..935683d --- /dev/null +++ b/matrix-authentication-service-tchap/tools/README_TCHAP.md @@ -0,0 +1,38 @@ +## Build + +https://element-hq.github.io/matrix-authentication-service/setup/installation.html#building-from-the-source + +frontend + +``` +cd frontend +npm ci +npm run build +cd .. +``` + + +policies +``` +cd policies +make DOCKER=1 +cd .. +``` + + +## To run + +``` +./start-local-stack.sh +./start-local-mas.sh +``` + + +## Command helper +``` +#createdb -U postgres postgres +# dropdb postgres +#createdb -U postgres -O postgres keycloak +#docker run -d -p 5432:5432 -e 'POSTGRES_USER=keycloak' -e 'POSTGRES_PASSWORD=keycloak' -e 'POSTGRES_DATABASE=keycloak' postgres-keycloak +# cargo test --package mas-handlers upstream_oauth2::link::tests +``` \ No newline at end of file diff --git a/matrix-authentication-service-tchap/tools/pre-commit-check.sh b/matrix-authentication-service-tchap/tools/pre-commit-check.sh new file mode 100644 index 0000000..9cf798d --- /dev/null +++ b/matrix-authentication-service-tchap/tools/pre-commit-check.sh @@ -0,0 +1,10 @@ +#!/bin/sh + +set -eu +# cd .. +# ./misc/update.sh +# cargo +nightly fmt +# export DATABASE_URL=postgresql://postgres:postgres@localhost/postgres +# cargo test --workspace +# unset DATABASE_URL +# cargo clippy --workspace --tests --bins --lib -- -D warnings \ No newline at end of file diff --git a/matrix-authentication-service-tchap/tools/start-local-mas.sh b/matrix-authentication-service-tchap/tools/start-local-mas.sh new file mode 100755 index 0000000..e75adf1 --- /dev/null +++ b/matrix-authentication-service-tchap/tools/start-local-mas.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -e +cd $MAS_HOME +cargo run -- server -c conf/config.local.dev.yaml \ No newline at end of file diff --git a/matrix-authentication-service-tchap/tools/start-local-stack.sh b/matrix-authentication-service-tchap/tools/start-local-stack.sh new file mode 100755 index 0000000..e85bd1d --- /dev/null +++ b/matrix-authentication-service-tchap/tools/start-local-stack.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +# Run mail service +docker rm -f mailcatcher +docker run -d --name mailcatcher -p 1080:1080 -p 1025:1025 sj26/mailcatcher +# Run postgres service +#createdb -U postgres postgres +# dropdb postgres +docker rm -f postgres +docker run -d --name postgres -p 5432:5432 -v ./tchap/postgres:/var/lib/postgresql/data:rw -e 'POSTGRES_USER=postgres' -e 'POSTGRES_PASSWORD=postgres' -e 'POSTGRES_DATABASE=postgres' -e 'PGDATA=/var/lib/postgresql/data' postgres +#createdb -U postgres -O postgres keycloak +#docker run -d -p 5432:5432 -e 'POSTGRES_USER=keycloak' -e 'POSTGRES_PASSWORD=keycloak' -e 'POSTGRES_DATABASE=keycloak' postgres-keycloak +#cargo run -- server -c config.local.dev.yaml +# cargo test --package mas-handlers upstream_oauth2::link::tests \ No newline at end of file diff --git a/matrix-authentication-service-tchap/tools/test_admin.sh b/matrix-authentication-service-tchap/tools/test_admin.sh new file mode 100755 index 0000000..f2ab367 --- /dev/null +++ b/matrix-authentication-service-tchap/tools/test_admin.sh @@ -0,0 +1,20 @@ + +CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 +CLIENT_SECRET=phoo8ahneir3ohY2eigh4xuu6Oodaewi +#MAS_HOST=localhost:8080 +MAS_HOST=auth.tchapgouv.com + +ACCESS_TOKEN=$(curl \ + -u "$CLIENT_ID:$CLIENT_SECRET" \ + -d "grant_type=client_credentials&scope=urn:mas:admin" \ + https://$MAS_HOST/oauth2/token \ + | jq -r '.access_token') + +echo $ACCESS_TOKEN + +# List users (The -g flag prevents curl from interpreting the brackets in the URL) +curl \ + -g \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + "https://$MAS_HOST/api/admin/v1/users?filter[can_request_admin]=true&filter[status]=active&page[first]=100" \ + | jq From a39ed7f22e81e643aaf0837af93357197a2068ce Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 15 Apr 2025 19:43:49 +0200 Subject: [PATCH 04/71] add docker compose --- .../.gitignore | 2 + matrix-authentication-service-tchap/README.md | 2 +- .../conf/config.local.dev.yaml | 73 ++++-- .../docker-compose.yml | 48 ++++ .../start-local-mas.sh | 6 + .../start-local-stack.sh | 27 +++ .../tools/keycloak/init-keycloak-db.sql | 1 + .../tools/keycloak/proconnect-mock-realm.json | 208 ++++++++++++++++++ .../tools/start-local-mas.sh | 5 - .../tools/start-local-stack.sh | 16 -- 10 files changed, 346 insertions(+), 42 deletions(-) create mode 100644 matrix-authentication-service-tchap/.gitignore create mode 100644 matrix-authentication-service-tchap/docker-compose.yml create mode 100755 matrix-authentication-service-tchap/start-local-mas.sh create mode 100755 matrix-authentication-service-tchap/start-local-stack.sh create mode 100644 matrix-authentication-service-tchap/tools/keycloak/init-keycloak-db.sql create mode 100644 matrix-authentication-service-tchap/tools/keycloak/proconnect-mock-realm.json delete mode 100755 matrix-authentication-service-tchap/tools/start-local-mas.sh delete mode 100755 matrix-authentication-service-tchap/tools/start-local-stack.sh diff --git a/matrix-authentication-service-tchap/.gitignore b/matrix-authentication-service-tchap/.gitignore new file mode 100644 index 0000000..c02e423 --- /dev/null +++ b/matrix-authentication-service-tchap/.gitignore @@ -0,0 +1,2 @@ +tchap/ +tmp/ \ No newline at end of file diff --git a/matrix-authentication-service-tchap/README.md b/matrix-authentication-service-tchap/README.md index 18375a4..159aa8e 100644 --- a/matrix-authentication-service-tchap/README.md +++ b/matrix-authentication-service-tchap/README.md @@ -9,4 +9,4 @@ - /translations : tchap custom labels -export $MAS_HOME : path of /matrix-authentication-service : fork léger https://github.com/tchapgouv/matrix-authentication-service +export $MAS_HOME : path of /matrix-authentication-service, fork léger https://github.com/tchapgouv/matrix-authentication-service diff --git a/matrix-authentication-service-tchap/conf/config.local.dev.yaml b/matrix-authentication-service-tchap/conf/config.local.dev.yaml index 39fc4ba..b96db85 100644 --- a/matrix-authentication-service-tchap/conf/config.local.dev.yaml +++ b/matrix-authentication-service-tchap/conf/config.local.dev.yaml @@ -177,24 +177,57 @@ account: # # Default in locally-built binaries: `./translations/` # translations_path: /to/translations +upstream_oauth2: + providers: + - id: "01JK5MR1SD21MAQY4PWMFG283W" + human_name: Proconnect (mock) + issuer: "https://sso.tchapgouv.com/realms/proconnect-mock" + token_endpoint_auth_method: client_secret_basic + client_id: "mas" + client_secret: "HrJ1NZ0AbkHuWWjyRHh7X2lzn3S8eagt" + scope: "openid profile email" + claims_imports: + localpart: + action: require + template: "{{ user.email | email_to_mxid_localpart }}" + displayname: + action: require + template: "{{ user.email | email_to_display_name }}" + email: + action: require + template: "{{ user.email }}" + set_email_verification: always + +telemetry: + tracing: + # # List of propagators to use for extracting and injecting trace contexts + # propagators: + # # Propagate according to the W3C Trace Context specification + # - tracecontext + # # Propagate according to the W3C Baggage specification + # - baggage + # # Propagate trace context with Jaeger compatible headers + # - jaeger + + # # The default: don't export traces + exporter: none + + # Export traces to an OTLP-compatible endpoint + #exporter: otlp + #endpoint: https://localhost:4318 + metrics: + # The default: don't export metrics + exporter: none + + # Export metrics to an OTLP-compatible endpoint + #exporter: otlp + #endpoint: https://localhost:4317 + + # Export metrics by exposing a Prometheus endpoint + # This requires mounting the `prometheus` resource to an HTTP listener + #exporter: prometheus + + # sentry: + # # DSN to use for sending errors and crashes to Sentry + # dsn: https://public@host:port/1 -# upstream_oauth2: -# providers: -# - id: "01JK5MR1SD21MAQY4PWMFG283W" -# issuer: "https://tchap-connect.tchapgouv.com/realms/tchap" -# token_endpoint_auth_method: client_secret_basic -# client_id: "matrix-authentication-service" -# client_secret: "HrJ1NZ0AbkHuWWjyRHh7X2lzn3S8eagt" -# scope: "openid profile email" -# claims_imports: -# localpart: -# action: force -# template: "{{ user.preferred_username }}" -# displayname: -# action: force -# template: "{{ user.name }}" -# email: -# action: force -# template: "{{ user.email }}" -# set_email_verification: always -# allow_existing_users: true diff --git a/matrix-authentication-service-tchap/docker-compose.yml b/matrix-authentication-service-tchap/docker-compose.yml new file mode 100644 index 0000000..3d2b683 --- /dev/null +++ b/matrix-authentication-service-tchap/docker-compose.yml @@ -0,0 +1,48 @@ +services: + mailcatcher: + image: sj26/mailcatcher + network_mode: host + + postgres: + image: postgres + ports: + - "5432:5432" + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=postgres + - PGDATA=/var/lib/postgresql/data + volumes: + - ./tmp/postgres:/var/lib/postgresql/data:rw + - ./tools/keycloak/init-keycloak-db.sql:/docker-entrypoint-initdb.d/init-keycloak-db.sql + networks: + - tchap-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 50 + + keycloak: + image: quay.io/keycloak/keycloak:latest + ports: + - "8082:8080" + environment: + - KEYCLOAK_ADMIN=admin + - KEYCLOAK_ADMIN_PASSWORD=admin + - KC_DB=postgres + - KC_DB_URL=jdbc:postgresql://postgres:5432/keycloak + - KC_DB_USERNAME=postgres + - KC_DB_PASSWORD=postgres + volumes: + - ./tools/keycloak/proconnect-mock-realm.json:/opt/keycloak/data/import/proconnect-mock-realm.json + command: start-dev --import-realm --hostname=https://sso.tchapgouv.com + networks: + - tchap-network + depends_on: + postgres: + condition: service_healthy + +networks: + tchap-network: + driver: bridge diff --git a/matrix-authentication-service-tchap/start-local-mas.sh b/matrix-authentication-service-tchap/start-local-mas.sh new file mode 100755 index 0000000..273acfb --- /dev/null +++ b/matrix-authentication-service-tchap/start-local-mas.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +set -e +MAS_CONF=$PWD/conf +cd $MAS_HOME +cargo run -- server -c $MAS_CONF/config.local.dev.yaml \ No newline at end of file diff --git a/matrix-authentication-service-tchap/start-local-stack.sh b/matrix-authentication-service-tchap/start-local-stack.sh new file mode 100755 index 0000000..56f3b88 --- /dev/null +++ b/matrix-authentication-service-tchap/start-local-stack.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +#set -e + +# Run mail service +#docker rm -f mailcatcher +#docker run -d --name mailcatcher -p 1080:1080 -p 1025:1025 sj26/mailcatcher +# Run postgres service +#createdb -U postgres postgres +# dropdb postgres +#docker rm -f postgres +#docker run -d --name postgres -p 5432:5432 -v ./tmp/postgres:/var/lib/postgresql/data:rw -e 'POSTGRES_USER=postgres' -e 'POSTGRES_PASSWORD=postgres' -e 'POSTGRES_DATABASE=postgres' -e 'PGDATA=/var/lib/postgresql/data' postgres +# Wait for postgres to be ready +#sleep 3 + +#docker exec -it postgres createdb -U postgres -O postgres keycloak + + +# Run Keycloak service with proconnect-mock realm import +# frontend url : https://sso.tchapgouv.com +#docker rm -f keycloak +#docker run -d --name keycloak -p 8082:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin -v $(pwd)/tchap/keycloak/proconnect-mock-realm.json:/opt/keycloak/data/import/proconnect-mock-realm.json quay.io/keycloak/keycloak:latest start-dev --import-realm --hostname=https://sso.tchapgouv.com + +docker compose stop + +docker compose start +docker compose logs -f \ No newline at end of file diff --git a/matrix-authentication-service-tchap/tools/keycloak/init-keycloak-db.sql b/matrix-authentication-service-tchap/tools/keycloak/init-keycloak-db.sql new file mode 100644 index 0000000..9816eb3 --- /dev/null +++ b/matrix-authentication-service-tchap/tools/keycloak/init-keycloak-db.sql @@ -0,0 +1 @@ +CREATE DATABASE keycloak; diff --git a/matrix-authentication-service-tchap/tools/keycloak/proconnect-mock-realm.json b/matrix-authentication-service-tchap/tools/keycloak/proconnect-mock-realm.json new file mode 100644 index 0000000..2933329 --- /dev/null +++ b/matrix-authentication-service-tchap/tools/keycloak/proconnect-mock-realm.json @@ -0,0 +1,208 @@ +{ + "realm": "proconnect-mock", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "enabled": true, + "sslRequired": "external", + "registrationAllowed": true, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "requiredCredentials": [ + "password" + ], + "smtpServer": { + "from": "keycloak@example.com", + "host": "maildev", + "port": "25", + "starttls": "false", + "auth": "false" + }, + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "internationalizationEnabled": false, + "supportedLocales": [], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "oauth2DevicePollingInterval": "5", + "clientOfflineSessionMaxLifespan": "0", + "clientSessionIdleTimeout": "0", + "parRequestUriLifespan": "60", + "clientSessionMaxLifespan": "0", + "clientOfflineSessionIdleTimeout": "0", + "cibaInterval": "5", + "realmReusableOtpCode": "false" + }, + "clients": [ + { + "clientId": "mas", + "name": "matrix authentication service", + "description": "matrix authentication service", + "rootUrl": "http://localhost:8080", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "HrJ1NZ0AbkHuWWjyRHh7X2lzn3S8eagt", + "redirectUris": [ + "http://localhost:8080/upstream/callback/01JK5MR1SD21MAQY4PWMFG283W", + "https://auth.tchapgouv.com/upstream/callback/01JK5MR1SD21MAQY4PWMFG283W"], + "webOrigins": ["+"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "frontchannel.logout.session.required": "true", + "display.on.consent.screen": "false", + "oauth2.device.authorization.grant.enabled": "false", + "use.jwks.url": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ], + "protocolMappers": [ + { + "name": "given_name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "aggregate.attrs": "false", + "introspection.token.claim": "true", + "multivalued": "false", + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "lightweight.claim": "false", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "name": "idp_id", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "aggregate.attrs": "false", + "introspection.token.claim": "true", + "multivalued": "false", + "userinfo.token.claim": "true", + "user.attribute": "idp_id", + "id.token.claim": "true", + "lightweight.claim": "false", + "access.token.claim": "true", + "claim.name": "idp_id", + "jsonType.label": "String" + } + }, + { + "name": "usual_name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "aggregate.attrs": "false", + "introspection.token.claim": "true", + "multivalued": "false", + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "lightweight.claim": "false", + "access.token.claim": "true", + "claim.name": "usual_name", + "jsonType.label": "String" + } + }, + { + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "aggregate.attrs": "false", + "introspection.token.claim": "true", + "multivalued": "false", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "lightweight.claim": "false", + "access.token.claim": "true", + "claim.name": "sub", + "jsonType.label": "String" + } + } + ] + } + ], + "components": { + "org.keycloak.userprofile.UserProfileProvider": [ + { + "providerId": "declarative-user-profile", + "subComponents": {}, + "config": { + "kc.user.profile.config": [ + "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"firstName\",\"displayName\":\"\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"lastName\",\"displayName\":\"\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"idp_id\",\"displayName\":\"idp_id\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}]}" + ] + } + } + ] + } +} diff --git a/matrix-authentication-service-tchap/tools/start-local-mas.sh b/matrix-authentication-service-tchap/tools/start-local-mas.sh deleted file mode 100755 index e75adf1..0000000 --- a/matrix-authentication-service-tchap/tools/start-local-mas.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -set -e -cd $MAS_HOME -cargo run -- server -c conf/config.local.dev.yaml \ No newline at end of file diff --git a/matrix-authentication-service-tchap/tools/start-local-stack.sh b/matrix-authentication-service-tchap/tools/start-local-stack.sh deleted file mode 100755 index e85bd1d..0000000 --- a/matrix-authentication-service-tchap/tools/start-local-stack.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - -set -e - -# Run mail service -docker rm -f mailcatcher -docker run -d --name mailcatcher -p 1080:1080 -p 1025:1025 sj26/mailcatcher -# Run postgres service -#createdb -U postgres postgres -# dropdb postgres -docker rm -f postgres -docker run -d --name postgres -p 5432:5432 -v ./tchap/postgres:/var/lib/postgresql/data:rw -e 'POSTGRES_USER=postgres' -e 'POSTGRES_PASSWORD=postgres' -e 'POSTGRES_DATABASE=postgres' -e 'PGDATA=/var/lib/postgresql/data' postgres -#createdb -U postgres -O postgres keycloak -#docker run -d -p 5432:5432 -e 'POSTGRES_USER=keycloak' -e 'POSTGRES_PASSWORD=keycloak' -e 'POSTGRES_DATABASE=keycloak' postgres-keycloak -#cargo run -- server -c config.local.dev.yaml -# cargo test --package mas-handlers upstream_oauth2::link::tests \ No newline at end of file From d25de1c615bbbcddefcf44760c25c1731b69b480 Mon Sep 17 00:00:00 2001 From: mcalinghee Date: Wed, 16 Apr 2025 15:37:38 +0200 Subject: [PATCH 05/71] correct config to allow email login --- .../conf/config.local.dev.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/matrix-authentication-service-tchap/conf/config.local.dev.yaml b/matrix-authentication-service-tchap/conf/config.local.dev.yaml index b96db85..dd1e506 100644 --- a/matrix-authentication-service-tchap/conf/config.local.dev.yaml +++ b/matrix-authentication-service-tchap/conf/config.local.dev.yaml @@ -161,7 +161,11 @@ account: # This has no effect if password login is disabled. password_recovery_enabled: true - allow_login_by_email: true + # Whether users can log in with their email address. + # + # Defaults to `false`. + # This has no effect if password login is disabled. + login_with_email_allowed: true #templates: # From where to load the templates From 4fedc027d7071556b03b3b9d09541a79b908ae89 Mon Sep 17 00:00:00 2001 From: mcalinghee Date: Wed, 16 Apr 2025 16:39:18 +0200 Subject: [PATCH 06/71] move folder one level up --- .../.github => .github}/README.md | 0 .../.gitignore => .gitignore | 0 README.md | 13 +- .../conf => conf}/config.local.dev.yaml | 10 +- .../docker-compose.yml => docker-compose.yml | 0 matrix-authentication-service-tchap/README.md | 12 - .../playwright => playwright}/.env.sample | 0 .../playwright => playwright}/.gitignore | 0 .../playwright => playwright}/README.md | 0 .../fixtures/auth-fixture.ts | 0 .../playwright => playwright}/init.sh | 0 .../package-lock.json | 0 .../playwright => playwright}/package.json | 0 .../playwright.config.ts | 0 .../tests/oidc-auth.spec.ts | 0 .../tests/utils/auth-helpers.ts | 0 .../tests/utils/config.ts | 0 .../tests/utils/keycloak-admin.ts | 0 .../tests/utils/mas-admin.ts | 0 .../playwright => playwright}/tsconfig.json | 0 .../start-local-mas.sh => start-local-mas.sh | 0 ...art-local-stack.sh => start-local-stack.sh | 0 .../templates => templates}/README.md | 0 templates/app.html | 31 + templates/base.html | 36 + templates/components/back_to_client.html | 37 + templates/components/button.html | 97 +++ templates/components/captcha.html | 41 + templates/components/errors.html | 23 + templates/components/field.html | 110 +++ templates/components/footer.html | 33 + templates/components/icon.html | 803 ++++++++++++++++++ templates/components/idp_brand.html | 53 ++ templates/components/logout.html | 21 + templates/components/scope.html | 31 + templates/emails/recovery.html | 52 ++ templates/emails/recovery.subject | 14 + templates/emails/recovery.txt | 16 + templates/emails/verification.html | 19 + templates/emails/verification.subject | 11 + templates/emails/verification.txt | 19 + templates/form_post.html | 32 + templates/pages/404.html | 27 + templates/pages/account/deactivated.html | 26 + templates/pages/account/locked.html | 26 + templates/pages/account/logged_out.html | 25 + templates/pages/consent.html | 76 ++ templates/pages/device_consent.html | 161 ++++ templates/pages/device_link.html | 42 + templates/pages/error.html | 42 + templates/pages/index.html | 37 + templates/pages/login.html | 104 +++ templates/pages/policy_violation.html | 52 ++ templates/pages/reauth.html | 54 ++ templates/pages/recovery/consumed.html | 24 + templates/pages/recovery/disabled.html | 24 + templates/pages/recovery/expired.html | 32 + templates/pages/recovery/finish.html | 47 + templates/pages/recovery/progress.html | 37 + templates/pages/recovery/start.html | 40 + templates/pages/register/index.html | 62 ++ templates/pages/register/password.html | 79 ++ .../pages/register/steps/display_name.html | 52 ++ .../pages/register/steps/email_in_use.html | 30 + .../pages/register/steps/verify_email.html | 53 ++ templates/pages/sso.html | 48 ++ .../pages/upstream_oauth2/do_register.html | 199 +++++ .../pages/upstream_oauth2/link_mismatch.html | 25 + .../pages/upstream_oauth2/suggest_link.html | 34 + templates/swagger/doc.html | 27 + templates/swagger/oauth2-redirect.html | 89 ++ .../tools => tools}/README_TCHAP.md | 0 .../keycloak/init-keycloak-db.sql | 0 .../keycloak/proconnect-mock-realm.json | 0 .../tools => tools}/pre-commit-check.sh | 0 .../tools => tools}/test_admin.sh | 0 76 files changed, 3070 insertions(+), 18 deletions(-) rename {matrix-authentication-service-tchap/.github => .github}/README.md (100%) rename matrix-authentication-service-tchap/.gitignore => .gitignore (100%) rename {matrix-authentication-service-tchap/conf => conf}/config.local.dev.yaml (97%) rename matrix-authentication-service-tchap/docker-compose.yml => docker-compose.yml (100%) delete mode 100644 matrix-authentication-service-tchap/README.md rename {matrix-authentication-service-tchap/playwright => playwright}/.env.sample (100%) rename {matrix-authentication-service-tchap/playwright => playwright}/.gitignore (100%) rename {matrix-authentication-service-tchap/playwright => playwright}/README.md (100%) rename {matrix-authentication-service-tchap/playwright => playwright}/fixtures/auth-fixture.ts (100%) rename {matrix-authentication-service-tchap/playwright => playwright}/init.sh (100%) rename {matrix-authentication-service-tchap/playwright => playwright}/package-lock.json (100%) rename {matrix-authentication-service-tchap/playwright => playwright}/package.json (100%) rename {matrix-authentication-service-tchap/playwright => playwright}/playwright.config.ts (100%) rename {matrix-authentication-service-tchap/playwright => playwright}/tests/oidc-auth.spec.ts (100%) rename {matrix-authentication-service-tchap/playwright => playwright}/tests/utils/auth-helpers.ts (100%) rename {matrix-authentication-service-tchap/playwright => playwright}/tests/utils/config.ts (100%) rename {matrix-authentication-service-tchap/playwright => playwright}/tests/utils/keycloak-admin.ts (100%) rename {matrix-authentication-service-tchap/playwright => playwright}/tests/utils/mas-admin.ts (100%) rename {matrix-authentication-service-tchap/playwright => playwright}/tsconfig.json (100%) rename matrix-authentication-service-tchap/start-local-mas.sh => start-local-mas.sh (100%) rename matrix-authentication-service-tchap/start-local-stack.sh => start-local-stack.sh (100%) rename {matrix-authentication-service-tchap/templates => templates}/README.md (100%) create mode 100644 templates/app.html create mode 100644 templates/base.html create mode 100644 templates/components/back_to_client.html create mode 100644 templates/components/button.html create mode 100644 templates/components/captcha.html create mode 100644 templates/components/errors.html create mode 100644 templates/components/field.html create mode 100644 templates/components/footer.html create mode 100644 templates/components/icon.html create mode 100644 templates/components/idp_brand.html create mode 100644 templates/components/logout.html create mode 100644 templates/components/scope.html create mode 100644 templates/emails/recovery.html create mode 100644 templates/emails/recovery.subject create mode 100644 templates/emails/recovery.txt create mode 100644 templates/emails/verification.html create mode 100644 templates/emails/verification.subject create mode 100644 templates/emails/verification.txt create mode 100644 templates/form_post.html create mode 100644 templates/pages/404.html create mode 100644 templates/pages/account/deactivated.html create mode 100644 templates/pages/account/locked.html create mode 100644 templates/pages/account/logged_out.html create mode 100644 templates/pages/consent.html create mode 100644 templates/pages/device_consent.html create mode 100644 templates/pages/device_link.html create mode 100644 templates/pages/error.html create mode 100644 templates/pages/index.html create mode 100644 templates/pages/login.html create mode 100644 templates/pages/policy_violation.html create mode 100644 templates/pages/reauth.html create mode 100644 templates/pages/recovery/consumed.html create mode 100644 templates/pages/recovery/disabled.html create mode 100644 templates/pages/recovery/expired.html create mode 100644 templates/pages/recovery/finish.html create mode 100644 templates/pages/recovery/progress.html create mode 100644 templates/pages/recovery/start.html create mode 100644 templates/pages/register/index.html create mode 100644 templates/pages/register/password.html create mode 100644 templates/pages/register/steps/display_name.html create mode 100644 templates/pages/register/steps/email_in_use.html create mode 100644 templates/pages/register/steps/verify_email.html create mode 100644 templates/pages/sso.html create mode 100644 templates/pages/upstream_oauth2/do_register.html create mode 100644 templates/pages/upstream_oauth2/link_mismatch.html create mode 100644 templates/pages/upstream_oauth2/suggest_link.html create mode 100644 templates/swagger/doc.html create mode 100644 templates/swagger/oauth2-redirect.html rename {matrix-authentication-service-tchap/tools => tools}/README_TCHAP.md (100%) rename {matrix-authentication-service-tchap/tools => tools}/keycloak/init-keycloak-db.sql (100%) rename {matrix-authentication-service-tchap/tools => tools}/keycloak/proconnect-mock-realm.json (100%) rename {matrix-authentication-service-tchap/tools => tools}/pre-commit-check.sh (100%) rename {matrix-authentication-service-tchap/tools => tools}/test_admin.sh (100%) diff --git a/matrix-authentication-service-tchap/.github/README.md b/.github/README.md similarity index 100% rename from matrix-authentication-service-tchap/.github/README.md rename to .github/README.md diff --git a/matrix-authentication-service-tchap/.gitignore b/.gitignore similarity index 100% rename from matrix-authentication-service-tchap/.gitignore rename to .gitignore diff --git a/README.md b/README.md index 3ece9ad..159aa8e 100644 --- a/README.md +++ b/README.md @@ -1 +1,12 @@ -# matrix-authentication-service-tchap + +## folder structure + +- /.github custom CI (if needed) +- /conf : configuration du MAS special tchap +- /playwright : test E2E +- /templates : custom html templates, css and manifest.json +- /tools : for local dev +- /translations : tchap custom labels + + +export $MAS_HOME : path of /matrix-authentication-service, fork léger https://github.com/tchapgouv/matrix-authentication-service diff --git a/matrix-authentication-service-tchap/conf/config.local.dev.yaml b/conf/config.local.dev.yaml similarity index 97% rename from matrix-authentication-service-tchap/conf/config.local.dev.yaml rename to conf/config.local.dev.yaml index dd1e506..753d869 100644 --- a/matrix-authentication-service-tchap/conf/config.local.dev.yaml +++ b/conf/config.local.dev.yaml @@ -167,11 +167,11 @@ account: # This has no effect if password login is disabled. login_with_email_allowed: true -#templates: - # From where to load the templates - # This is relative to the current working directory, *not* the config file -# path: /Users/mca/Documents/work/projets/betagouv/repo/matrix-authentication-service/templates2 - +# templates: +# # From where to load the templates +# # This is relative to the current working directory, *not* the config file +# path: /Users/mca/Documents/work/projets/betagouv/repo/matrix-authentication-service-tchap/templates +# # # Path to the frontend assets manifest file # assets_manifest: /to/manifest.json # diff --git a/matrix-authentication-service-tchap/docker-compose.yml b/docker-compose.yml similarity index 100% rename from matrix-authentication-service-tchap/docker-compose.yml rename to docker-compose.yml diff --git a/matrix-authentication-service-tchap/README.md b/matrix-authentication-service-tchap/README.md deleted file mode 100644 index 159aa8e..0000000 --- a/matrix-authentication-service-tchap/README.md +++ /dev/null @@ -1,12 +0,0 @@ - -## folder structure - -- /.github custom CI (if needed) -- /conf : configuration du MAS special tchap -- /playwright : test E2E -- /templates : custom html templates, css and manifest.json -- /tools : for local dev -- /translations : tchap custom labels - - -export $MAS_HOME : path of /matrix-authentication-service, fork léger https://github.com/tchapgouv/matrix-authentication-service diff --git a/matrix-authentication-service-tchap/playwright/.env.sample b/playwright/.env.sample similarity index 100% rename from matrix-authentication-service-tchap/playwright/.env.sample rename to playwright/.env.sample diff --git a/matrix-authentication-service-tchap/playwright/.gitignore b/playwright/.gitignore similarity index 100% rename from matrix-authentication-service-tchap/playwright/.gitignore rename to playwright/.gitignore diff --git a/matrix-authentication-service-tchap/playwright/README.md b/playwright/README.md similarity index 100% rename from matrix-authentication-service-tchap/playwright/README.md rename to playwright/README.md diff --git a/matrix-authentication-service-tchap/playwright/fixtures/auth-fixture.ts b/playwright/fixtures/auth-fixture.ts similarity index 100% rename from matrix-authentication-service-tchap/playwright/fixtures/auth-fixture.ts rename to playwright/fixtures/auth-fixture.ts diff --git a/matrix-authentication-service-tchap/playwright/init.sh b/playwright/init.sh similarity index 100% rename from matrix-authentication-service-tchap/playwright/init.sh rename to playwright/init.sh diff --git a/matrix-authentication-service-tchap/playwright/package-lock.json b/playwright/package-lock.json similarity index 100% rename from matrix-authentication-service-tchap/playwright/package-lock.json rename to playwright/package-lock.json diff --git a/matrix-authentication-service-tchap/playwright/package.json b/playwright/package.json similarity index 100% rename from matrix-authentication-service-tchap/playwright/package.json rename to playwright/package.json diff --git a/matrix-authentication-service-tchap/playwright/playwright.config.ts b/playwright/playwright.config.ts similarity index 100% rename from matrix-authentication-service-tchap/playwright/playwright.config.ts rename to playwright/playwright.config.ts diff --git a/matrix-authentication-service-tchap/playwright/tests/oidc-auth.spec.ts b/playwright/tests/oidc-auth.spec.ts similarity index 100% rename from matrix-authentication-service-tchap/playwright/tests/oidc-auth.spec.ts rename to playwright/tests/oidc-auth.spec.ts diff --git a/matrix-authentication-service-tchap/playwright/tests/utils/auth-helpers.ts b/playwright/tests/utils/auth-helpers.ts similarity index 100% rename from matrix-authentication-service-tchap/playwright/tests/utils/auth-helpers.ts rename to playwright/tests/utils/auth-helpers.ts diff --git a/matrix-authentication-service-tchap/playwright/tests/utils/config.ts b/playwright/tests/utils/config.ts similarity index 100% rename from matrix-authentication-service-tchap/playwright/tests/utils/config.ts rename to playwright/tests/utils/config.ts diff --git a/matrix-authentication-service-tchap/playwright/tests/utils/keycloak-admin.ts b/playwright/tests/utils/keycloak-admin.ts similarity index 100% rename from matrix-authentication-service-tchap/playwright/tests/utils/keycloak-admin.ts rename to playwright/tests/utils/keycloak-admin.ts diff --git a/matrix-authentication-service-tchap/playwright/tests/utils/mas-admin.ts b/playwright/tests/utils/mas-admin.ts similarity index 100% rename from matrix-authentication-service-tchap/playwright/tests/utils/mas-admin.ts rename to playwright/tests/utils/mas-admin.ts diff --git a/matrix-authentication-service-tchap/playwright/tsconfig.json b/playwright/tsconfig.json similarity index 100% rename from matrix-authentication-service-tchap/playwright/tsconfig.json rename to playwright/tsconfig.json diff --git a/matrix-authentication-service-tchap/start-local-mas.sh b/start-local-mas.sh similarity index 100% rename from matrix-authentication-service-tchap/start-local-mas.sh rename to start-local-mas.sh diff --git a/matrix-authentication-service-tchap/start-local-stack.sh b/start-local-stack.sh similarity index 100% rename from matrix-authentication-service-tchap/start-local-stack.sh rename to start-local-stack.sh diff --git a/matrix-authentication-service-tchap/templates/README.md b/templates/README.md similarity index 100% rename from matrix-authentication-service-tchap/templates/README.md rename to templates/README.md diff --git a/templates/app.html b/templates/app.html new file mode 100644 index 0000000..52a6243 --- /dev/null +++ b/templates/app.html @@ -0,0 +1,31 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2023, 2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{# Must be kept in sync with frontend/index.html #} +{% set _ = translator(lang) %} + + + + + + + {{ _("app.name") }} + {% set config = { + 'graphqlEndpoint': app_config.graphqlEndpoint, + 'root': app_config.root, + } -%} + + {{ include_asset('src/main.tsx') | indent(4) | safe }} + + + +
+ + diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..51cb70d --- /dev/null +++ b/templates/base.html @@ -0,0 +1,36 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2021-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% set _ = translator(lang) %} + +{% import "components/button.html" as button %} +{% import "components/field.html" as field %} +{% import "components/back_to_client.html" as back_to_client %} +{% import "components/logout.html" as logout %} +{% import "components/errors.html" as errors %} +{% import "components/icon.html" as icon %} +{% import "components/scope.html" as scope %} +{% import "components/captcha.html" as captcha %} + + + + + + {% block title %}{{ _("app.name") }}{% endblock title %} + + {{ include_asset('src/shared.css') | indent(4) | safe }} + {{ include_asset('src/templates.css') | indent(4) | safe }} + {{ captcha.head() }} + + + + + diff --git a/templates/components/back_to_client.html b/templates/components/back_to_client.html new file mode 100644 index 0000000..d767dd5 --- /dev/null +++ b/templates/components/back_to_client.html @@ -0,0 +1,37 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2021-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% macro link(text, uri, mode, params, type="button", kind="primary", destructive=False) %} + {% if type == "button" %} + {% if destructive %} + {% set class = "cpd-button destructive" %} + {% else %} + {% set class = "cpd-button" %} + {% endif %} + {% elif type == "link" %} + {% set class = "cpd-link" %} + {% if destructive %} + {% set kind = "critical" %} + {% endif %} + {% else %} + {{ throw(message="Invalid type") }} + {% endif %} + + {% if mode == "form_post" %} +
+ {% for key, value in params|items %} + + {% endfor %} + +
+ {% elif mode == "fragment" or mode == "query" %} + {{ text }} + {% else %} + {{ throw(message="Invalid mode") }} + {% endif %} +{% endmacro %} diff --git a/templates/components/button.html b/templates/components/button.html new file mode 100644 index 0000000..3b037f2 --- /dev/null +++ b/templates/components/button.html @@ -0,0 +1,97 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2021-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% macro link(text, href="#", class="", size="lg") %} + {{ text }} +{% endmacro %} + +{% macro link_text(text, href="#", class="") %} + {{ text }} +{% endmacro %} + +{% macro link_outline(text, href="#", class="", size="lg") %} + {{ text }} +{% endmacro %} + +{% macro link_tertiary(text, href="#", class="", size="lg") %} + {{ text }} +{% endmacro %} + +{% macro button( + text, + name="", + type="submit", + class="", + value="", + disabled=False, + kind="primary", + size="lg", + autocomplete=False, + autocorrect=False, + autocapitalize=False) %} + +{% endmacro %} + +{% macro button_text( + text, + name="", + type="submit", + class="", + value="", + disabled=False, + autocomplete=False, + autocorrect=False, + autocapitalize=False) %} + +{% endmacro %} + +{% macro button_outline( + text, + name="", + type="submit", + class="", + value="", + disabled=False, + size="lg", + autocomplete=False, + autocorrect=False, + autocapitalize=False) %} + +{% endmacro %} diff --git a/templates/components/captcha.html b/templates/components/captcha.html new file mode 100644 index 0000000..09bb919 --- /dev/null +++ b/templates/components/captcha.html @@ -0,0 +1,41 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% macro form(class="") -%} + {%- if captcha|default(False) -%} + + + {%- if captcha.service == "recaptcha_v2" -%} +
+ {%- elif captcha.service == "cloudflare_turnstile" -%} +
+ {%- elif captcha.service == "hcaptcha" -%} +
+ {%- else -%} + {{ throw(message="Invalid captcha service setup") }} + {%- endif %} + {%- endif -%} +{% endmacro %} + +{% macro head() -%} + {%- if captcha|default(False) -%} + {%- if captcha.service == "recaptcha_v2" -%} + + {%- elif captcha.service == "cloudflare_turnstile" -%} + + {%- elif captcha.service == "hcaptcha" -%} + + {%- else -%} + {{ throw(message="Invalid captcha service setup") }} + {%- endif %} + {%- endif -%} +{%- endmacro %} diff --git a/templates/components/errors.html b/templates/components/errors.html new file mode 100644 index 0000000..e622fdc --- /dev/null +++ b/templates/components/errors.html @@ -0,0 +1,23 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2022-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% macro form_error_message(error) -%} + {% if error.kind == "invalid_credentials" %} + {{ _("mas.errors.invalid_credentials") }} + {% elif error.kind == "password_mismatch" %} + {{ _("mas.errors.password_mismatch") }} + {% elif error.kind == "rate_limit_exceeded" %} + {{ _("mas.errors.rate_limit_exceeded") }} + {% elif error.kind == "policy" %} + {{ _("mas.errors.denied_policy", policy=error.message) }} + {% elif error.kind == "captcha" %} + {{ _("mas.errors.captcha") }} + {% else %} + {{ error.kind }} + {% endif %} +{%- endmacro %} diff --git a/templates/components/field.html b/templates/components/field.html new file mode 100644 index 0000000..4552068 --- /dev/null +++ b/templates/components/field.html @@ -0,0 +1,110 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2021-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% set cnt = counter() %} + +{% macro new_id() -%} + form-{{- cnt.next() -}} +{%- endmacro %} + +{% macro attributes(field, default_value=None) -%} + {%- set value = field.value | default(default_value) -%} + name="{{ field.name }}" id="{{ field.id }}" + {%- if field.errors is not empty %} data-invalid{% endif %} + {%- if value %} value="{{ value }}" {% endif %} +{%- endmacro %} + +{% macro field(label, name, form_state=false, class="", inline=false) %} + {% set field_id = new_id() %} + {% if not form_state %} + {% set form_state = {"fields": {}} %} + {% endif %} + + {% set state = form_state.fields[name] | default({"errors": [], "value": ""}) %} + {% set field = { + "id": new_id(), + "name": name, + "errors": state.errors, + "value": state.value, + } %} + +
+ {% if not inline %} + + + {{ caller(field) }} + {% else %} +
+ {{ caller(field) }} +
+ +
+ + {% endif %} + + + {% if field.errors is not empty %} + {% for error in field.errors %} + {% if error.kind != "unspecified" %} +
+ {% if error.kind == "required" %} + {{ _("mas.errors.field_required") }} + {% elif error.kind == "exists" and field.name == "username" %} + {{ _("mas.errors.username_taken") }} + {% elif error.kind == "policy" %} + {% if error.code == "username-too-short" %} + {{ _("mas.errors.username_too_short") }} + {% elif error.code == "username-too-long" %} + {{ _("mas.errors.username_too_long") }} + {% elif error.code == "username-invalid-chars" %} + {{ _("mas.errors.username_invalid_chars") }} + {% elif error.code == "username-all-numeric" %} + {{ _("mas.errors.username_all_numeric") }} + {% elif error.code == "username-banned" %} + {{ _("mas.errors.username_banned") }} + {% elif error.code == "username-not-allowed" %} + {{ _("mas.errors.username_not_allowed") }} + {% elif error.code == "email-domain-not-allowed" %} + {{ _("mas.errors.email_domain_not_allowed") }} + {% elif error.code == "email-domain-banned" %} + {{ _("mas.errors.email_domain_banned") }} + {% elif error.code == "email-not-allowed" %} + {{ _("mas.errors.email_not_allowed") }} + {% elif error.code == "email-banned" %} + {{ _("mas.errors.email_banned") }} + {% else %} + {{ _("mas.errors.denied_policy", policy=error.message) }} + {% endif %} + {% elif error.kind == "password_mismatch" %} + {{ _("mas.errors.password_mismatch") }} + {% else %} + {{ error.kind }} + {% endif %} +
+ {% endif %} + {% endfor %} + {% endif %} + + {% if inline %} +
+ {% endif %} +
+{% endmacro %} + + +{% macro separator() %} +
+
+

{{ _("mas.or_separator") }}

+
+
+{% endmacro %} diff --git a/templates/components/footer.html b/templates/components/footer.html new file mode 100644 index 0000000..02c0d31 --- /dev/null +++ b/templates/components/footer.html @@ -0,0 +1,33 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2023, 2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + + diff --git a/templates/components/icon.html b/templates/components/icon.html new file mode 100644 index 0000000..7781b2f --- /dev/null +++ b/templates/components/icon.html @@ -0,0 +1,803 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2023, 2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{# Regenerate with the following shell script: + +for i in frontend/node_modules/@vector-im/compound-design-tokens/icons/*.svg; do + NAME=$(basename "$i" | sed 's/\.svg//' | tr '-' '_') + CONTENT=$(cat "$i") + cat < +{% endmacro %} + +{% macro arrow_down() %} + +{% endmacro %} + +{% macro arrow_left() %} + +{% endmacro %} + +{% macro arrow_right() %} + +{% endmacro %} + +{% macro arrow_up_right() %} + +{% endmacro %} + +{% macro arrow_up() %} + +{% endmacro %} + +{% macro ask_to_join_solid() %} + +{% endmacro %} + +{% macro ask_to_join() %} + +{% endmacro %} + +{% macro attachment() %} + +{% endmacro %} + +{% macro audio() %} + +{% endmacro %} + +{% macro block() %} + +{% endmacro %} + +{% macro bold() %} + +{% endmacro %} + +{% macro calendar() %} + +{% endmacro %} + +{% macro chart() %} + +{% endmacro %} + +{% macro chat_new() %} + +{% endmacro %} + +{% macro chat_problem() %} + +{% endmacro %} + +{% macro chat_solid() %} + +{% endmacro %} + +{% macro chat() %} + +{% endmacro %} + +{% macro check_circle_solid() %} + +{% endmacro %} + +{% macro check_circle() %} + +{% endmacro %} + +{% macro check() %} + +{% endmacro %} + +{% macro chevron_down() %} + +{% endmacro %} + +{% macro chevron_left() %} + +{% endmacro %} + +{% macro chevron_right() %} + +{% endmacro %} + +{% macro chevron_up_down() %} + +{% endmacro %} + +{% macro chevron_up() %} + +{% endmacro %} + +{% macro circle() %} + +{% endmacro %} + +{% macro close() %} + +{% endmacro %} + +{% macro cloud_solid() %} + +{% endmacro %} + +{% macro cloud() %} + +{% endmacro %} + +{% macro code() %} + +{% endmacro %} + +{% macro collapse() %} + +{% endmacro %} + +{% macro company() %} + +{% endmacro %} + +{% macro compose() %} + +{% endmacro %} + +{% macro computer() %} + +{% endmacro %} + +{% macro copy() %} + +{% endmacro %} + +{% macro dark_mode() %} + +{% endmacro %} + +{% macro delete() %} + +{% endmacro %} + +{% macro devices() %} + +{% endmacro %} + +{% macro dial_pad() %} + +{% endmacro %} + +{% macro document() %} + +{% endmacro %} + +{% macro download_ios() %} + +{% endmacro %} + +{% macro download() %} + +{% endmacro %} + +{% macro drag_grid() %} + +{% endmacro %} + +{% macro drag_list() %} + +{% endmacro %} + +{% macro edit_solid() %} + +{% endmacro %} + +{% macro edit() %} + +{% endmacro %} + +{% macro email_solid() %} + +{% endmacro %} + +{% macro email() %} + +{% endmacro %} + +{% macro end_call() %} + +{% endmacro %} + +{% macro error_solid() %} + +{% endmacro %} + +{% macro error() %} + +{% endmacro %} + +{% macro expand() %} + +{% endmacro %} + +{% macro explore() %} + +{% endmacro %} + +{% macro export_archive() %} + +{% endmacro %} + +{% macro extensions_solid() %} + +{% endmacro %} + +{% macro extensions() %} + +{% endmacro %} + +{% macro favourite_solid() %} + +{% endmacro %} + +{% macro favourite() %} + +{% endmacro %} + +{% macro file_error() %} + +{% endmacro %} + +{% macro files() %} + +{% endmacro %} + +{% macro filter() %} + +{% endmacro %} + +{% macro forward() %} + +{% endmacro %} + +{% macro grid() %} + +{% endmacro %} + +{% macro group() %} + +{% endmacro %} + +{% macro headphones_off_solid() %} + +{% endmacro %} + +{% macro headphones_solid() %} + +{% endmacro %} + +{% macro help_solid() %} + +{% endmacro %} + +{% macro help() %} + +{% endmacro %} + +{% macro history() %} + +{% endmacro %} + +{% macro home_solid() %} + +{% endmacro %} + +{% macro home() %} + +{% endmacro %} + +{% macro host() %} + +{% endmacro %} + +{% macro image_error() %} + +{% endmacro %} + +{% macro image() %} + +{% endmacro %} + +{% macro indent_decrease() %} + +{% endmacro %} + +{% macro indent_increase() %} + +{% endmacro %} + +{% macro info_solid() %} + +{% endmacro %} + +{% macro info() %} + +{% endmacro %} + +{% macro inline_code() %} + +{% endmacro %} + +{% macro italic() %} + +{% endmacro %} + +{% macro key_off_solid() %} + +{% endmacro %} + +{% macro key_off() %} + +{% endmacro %} + +{% macro key_solid() %} + +{% endmacro %} + +{% macro key() %} + +{% endmacro %} + +{% macro keyboard() %} + +{% endmacro %} + +{% macro labs() %} + +{% endmacro %} + +{% macro leave() %} + +{% endmacro %} + +{% macro link() %} + +{% endmacro %} + +{% macro linux() %} + +{% endmacro %} + +{% macro list_bulleted() %} + +{% endmacro %} + +{% macro list_numbered() %} + +{% endmacro %} + +{% macro list_view() %} + +{% endmacro %} + +{% macro location_navigator_centred() %} + +{% endmacro %} + +{% macro location_navigator() %} + +{% endmacro %} + +{% macro location_pin_solid() %} + +{% endmacro %} + +{% macro location_pin() %} + +{% endmacro %} + +{% macro lock_off() %} + +{% endmacro %} + +{% macro lock_solid() %} + +{% endmacro %} + +{% macro lock() %} + +{% endmacro %} + +{% macro mac() %} + +{% endmacro %} + +{% macro mark_as_read() %} + +{% endmacro %} + +{% macro mark_as_unread() %} + +{% endmacro %} + +{% macro mark_threads_as_read() %} + +{% endmacro %} + +{% macro marker_read_receipts() %} + +{% endmacro %} + +{% macro mention() %} + +{% endmacro %} + +{% macro menu() %} + +{% endmacro %} + +{% macro mic_off_solid() %} + +{% endmacro %} + +{% macro mic_off() %} + +{% endmacro %} + +{% macro mic_on_solid() %} + +{% endmacro %} + +{% macro mic_on() %} + +{% endmacro %} + +{% macro minus() %} + +{% endmacro %} + +{% macro mobile() %} + +{% endmacro %} + +{% macro notifications_off_solid() %} + +{% endmacro %} + +{% macro notifications_off() %} + +{% endmacro %} + +{% macro notifications_solid() %} + +{% endmacro %} + +{% macro notifications() %} + +{% endmacro %} + +{% macro offline() %} + +{% endmacro %} + +{% macro overflow_horizontal() %} + +{% endmacro %} + +{% macro overflow_vertical() %} + +{% endmacro %} + +{% macro pause_solid() %} + +{% endmacro %} + +{% macro pause() %} + +{% endmacro %} + +{% macro pin_solid() %} + +{% endmacro %} + +{% macro pin() %} + +{% endmacro %} + +{% macro play_solid() %} + +{% endmacro %} + +{% macro play() %} + +{% endmacro %} + +{% macro plus() %} + +{% endmacro %} + +{% macro polls_end() %} + +{% endmacro %} + +{% macro polls() %} + +{% endmacro %} + +{% macro pop_out() %} + +{% endmacro %} + +{% macro preferences() %} + +{% endmacro %} + +{% macro presence_outline_8x8() %} + +{% endmacro %} + +{% macro presence_solid_8x8() %} + +{% endmacro %} + +{% macro presence_strikethrough_8x8() %} + +{% endmacro %} + +{% macro public() %} + +{% endmacro %} + +{% macro qr_code() %} + +{% endmacro %} + +{% macro quote() %} + +{% endmacro %} + +{% macro raised_hand_solid() %} + +{% endmacro %} + +{% macro reaction_add() %} + +{% endmacro %} + +{% macro reaction_solid() %} + +{% endmacro %} + +{% macro reaction() %} + +{% endmacro %} + +{% macro reply() %} + +{% endmacro %} + +{% macro restart() %} + +{% endmacro %} + +{% macro room() %} + +{% endmacro %} + +{% macro search() %} + +{% endmacro %} + +{% macro send_solid() %} + +{% endmacro %} + +{% macro send() %} + +{% endmacro %} + +{% macro settings_solid() %} + +{% endmacro %} + +{% macro settings() %} + +{% endmacro %} + +{% macro share_android() %} + +{% endmacro %} + +{% macro share_ios() %} + +{% endmacro %} + +{% macro share_screen_solid() %} + +{% endmacro %} + +{% macro share_screen() %} + +{% endmacro %} + +{% macro share() %} + +{% endmacro %} + +{% macro sidebar() %} + +{% endmacro %} + +{% macro sign_out() %} + +{% endmacro %} + +{% macro spinner() %} + +{% endmacro %} + +{% macro spotlight_view() %} + +{% endmacro %} + +{% macro spotlight() %} + +{% endmacro %} + +{% macro strikethrough() %} + +{% endmacro %} + +{% macro switch_camera_solid() %} + +{% endmacro %} + +{% macro take_photo_solid() %} + +{% endmacro %} + +{% macro take_photo() %} + +{% endmacro %} + +{% macro text_formatting() %} + +{% endmacro %} + +{% macro threads_solid() %} + +{% endmacro %} + +{% macro threads() %} + +{% endmacro %} + +{% macro time() %} + +{% endmacro %} + +{% macro underline() %} + +{% endmacro %} + +{% macro unknown_solid() %} + +{% endmacro %} + +{% macro unknown() %} + +{% endmacro %} + +{% macro unpin() %} + +{% endmacro %} + +{% macro user_add_solid() %} + +{% endmacro %} + +{% macro user_add() %} + +{% endmacro %} + +{% macro user_profile_solid() %} + +{% endmacro %} + +{% macro user_profile() %} + +{% endmacro %} + +{% macro user_solid() %} + +{% endmacro %} + +{% macro user() %} + +{% endmacro %} + +{% macro verified() %} + +{% endmacro %} + +{% macro video_call_declined_solid() %} + +{% endmacro %} + +{% macro video_call_missed_solid() %} + +{% endmacro %} + +{% macro video_call_off_solid() %} + +{% endmacro %} + +{% macro video_call_off() %} + +{% endmacro %} + +{% macro video_call_solid() %} + +{% endmacro %} + +{% macro video_call() %} + +{% endmacro %} + +{% macro visibility_off() %} + +{% endmacro %} + +{% macro visibility_on() %} + +{% endmacro %} + +{% macro voice_call_solid() %} + +{% endmacro %} + +{% macro voice_call() %} + +{% endmacro %} + +{% macro volume_off_solid() %} + +{% endmacro %} + +{% macro volume_off() %} + +{% endmacro %} + +{% macro volume_on_solid() %} + +{% endmacro %} + +{% macro volume_on() %} + +{% endmacro %} + +{% macro warning() %} + +{% endmacro %} + +{% macro web_browser() %} + +{% endmacro %} + +{% macro windows() %} + +{% endmacro %} diff --git a/templates/components/idp_brand.html b/templates/components/idp_brand.html new file mode 100644 index 0000000..e0226c0 --- /dev/null +++ b/templates/components/idp_brand.html @@ -0,0 +1,53 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2023, 2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% macro logo(brand, class="") -%} + {% if brand == "google" -%} + + + + + + + {% elif brand == "gitlab" %} + + + + + + + + + + {% elif brand == "twitter" %} + + + + {% elif brand == "github" %} + + + + {% elif brand == "facebook" %} + + + + + + + + + + + {% elif brand == "apple" %} + + + + {% elif brand == "discord" %} + + {% endif %} +{% endmacro %} diff --git a/templates/components/logout.html b/templates/components/logout.html new file mode 100644 index 0000000..ef4daa4 --- /dev/null +++ b/templates/components/logout.html @@ -0,0 +1,21 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2022-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% macro button(text, csrf_token, as_link=false, post_logout_action={}) %} +
+ + {% for key, value in post_logout_action|items %} + + {% endfor %} + {% if as_link %} + + {% else %} + + {% endif %} +
+{% endmacro %} diff --git a/templates/components/scope.html b/templates/components/scope.html new file mode 100644 index 0000000..2f0ae10 --- /dev/null +++ b/templates/components/scope.html @@ -0,0 +1,31 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2023, 2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% macro list(scopes) %} +
    + {% for scope in (scopes | split(" ")) %} + {% if scope == "openid" %} +
  • {{ icon.user_profile() }}

    {{ _("mas.scope.view_profile") }}

  • + {% elif scope == "urn:mas:graphql:*" %} +
  • {{ icon.info() }}

    {{ _("mas.scope.edit_profile") }}

  • +
  • {{ icon.computer() }}

    {{ _("mas.scope.manage_sessions") }}

  • + {% elif scope == "urn:matrix:org.matrix.msc2967.client:api:*" %} +
  • {{ icon.chat() }}

    {{ _("mas.scope.view_messages") }}

  • +
  • {{ icon.send() }}

    {{ _("mas.scope.send_messages") }}

  • + {% elif scope == "urn:synapse:admin:*" %} +
  • {{ icon.error_solid() }}

    {{ _("mas.scope.synapse_admin") }}

  • + {% elif scope == "urn:mas:admin" %} +
  • {{ icon.error_solid() }}

    {{ _("mas.scope.mas_admin") }}

  • + {% elif scope is startingwith("urn:matrix:org.matrix.msc2967.client:device:") %} + {# We hide this scope #} + {% else %} +
  • {{ icon.info() }}

    {{ scope }}

  • + {% endif %} + {% endfor %} +
+{% endmacro %} diff --git a/templates/emails/recovery.html b/templates/emails/recovery.html new file mode 100644 index 0000000..0567ba7 --- /dev/null +++ b/templates/emails/recovery.html @@ -0,0 +1,52 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{%- set _ = translator(lang) -%} + + + + + + + + + + {{ _("mas.emails.recovery.headline", server_name=branding.server_name) }}
+
+ {{ _("mas.emails.recovery.click_button") }}
+
+ {{ _("mas.emails.recovery.create_new_password") }}
+

+ {{ _("mas.emails.recovery.fallback") }} {{ _("mas.emails.recovery.copy_link") }} +

+

+ {{ recovery_link }} +

+ {{ _("mas.emails.recovery.you_can_ignore") }} + + diff --git a/templates/emails/recovery.subject b/templates/emails/recovery.subject new file mode 100644 index 0000000..a4c158d --- /dev/null +++ b/templates/emails/recovery.subject @@ -0,0 +1,14 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{%- set _ = translator(lang) -%} +{%- set mxid -%} + @{{ user.username }}:{{ branding.server_name }} +{%- endset -%} + +{{ _("mas.emails.recovery.subject", mxid=mxid) }} diff --git a/templates/emails/recovery.txt b/templates/emails/recovery.txt new file mode 100644 index 0000000..51d8f9d --- /dev/null +++ b/templates/emails/recovery.txt @@ -0,0 +1,16 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{%- set _ = translator(lang) -%} +{{ _("mas.emails.recovery.headline", server_name=branding.server_name) }} + +{{ _("mas.emails.recovery.copy_link") }} + + {{ recovery_link }} + +{{ _("mas.emails.recovery.you_can_ignore") }} diff --git a/templates/emails/verification.html b/templates/emails/verification.html new file mode 100644 index 0000000..58378dc --- /dev/null +++ b/templates/emails/verification.html @@ -0,0 +1,19 @@ +{# +Copyright 2024, 2025 New Vector Ltd. +Copyright 2021-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{%- set _ = translator(lang) -%} + +{%- if browser_session is defined -%} + {%- set username = browser_session.user.username -%} +{%- elif user_registration is defined -%} + {%- set username = user_registration.username -%} +{%- endif -%} + +{{ _("mas.emails.greeting", username=(username|default("user"))) }}
+
+{{ _("mas.emails.verify.body_html", code=authentication_code.code) }}
diff --git a/templates/emails/verification.subject b/templates/emails/verification.subject new file mode 100644 index 0000000..d4ed1b3 --- /dev/null +++ b/templates/emails/verification.subject @@ -0,0 +1,11 @@ +{# +Copyright 2024, 2025 New Vector Ltd. +Copyright 2021-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{%- set _ = translator(lang) -%} + +{{ _("mas.emails.verify.subject", code=authentication_code.code) }} diff --git a/templates/emails/verification.txt b/templates/emails/verification.txt new file mode 100644 index 0000000..7afb408 --- /dev/null +++ b/templates/emails/verification.txt @@ -0,0 +1,19 @@ +{# +Copyright 2024, 2025 New Vector Ltd. +Copyright 2021-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{%- set _ = translator(lang) -%} + +{%- if browser_session is defined -%} + {%- set username = browser_session.user.username -%} +{%- elif user_registration is defined -%} + {%- set username = user_registration.username -%} +{%- endif -%} + +{{ _("mas.emails.greeting", username=(username|default("user"))) }} + +{{ _("mas.emails.verify.body_text", code=authentication_code.code) }} diff --git a/templates/form_post.html b/templates/form_post.html new file mode 100644 index 0000000..2fb62ea --- /dev/null +++ b/templates/form_post.html @@ -0,0 +1,32 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2021-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+

{{ _("common.loading") }}

+
+
+ +
+ {% for key, value in params|items %} + + {% endfor %} + + +
+ + {# Submit the form in JavaScript on the next tick, so that if the browser + wants to display the placeholder instead of a blank page, it can #} + +{% endblock %} diff --git a/templates/pages/404.html b/templates/pages/404.html new file mode 100644 index 0000000..fbd707a --- /dev/null +++ b/templates/pages/404.html @@ -0,0 +1,27 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2023, 2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+

{{ _("mas.not_found.heading") }}

+

{{ _("mas.not_found.description") }}

+
+ {{ button.link_text(text=_("mas.back_to_homepage"), href="/") }} +
+ +
+ + +
{{ method }} {{ uri }} {{ version }}
+
+{{ version }} 404 Not Found
+
+
+{% endblock %} diff --git a/templates/pages/account/deactivated.html b/templates/pages/account/deactivated.html new file mode 100644 index 0000000..cc3f520 --- /dev/null +++ b/templates/pages/account/deactivated.html @@ -0,0 +1,26 @@ +{# +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+
+ {{ icon.delete() }} +
+ +
+

{{ _("mas.account.deactivated.heading") }}

+ {% set mxid = "@" + user.username + ":" + branding.server_name %} +

{{ _("mas.account.deactivated.description", mxid=mxid) }}

+
+ + {{ logout.button(text=_("action.sign_in"), csrf_token=csrf_token) }} +
+
+{% endblock %} diff --git a/templates/pages/account/locked.html b/templates/pages/account/locked.html new file mode 100644 index 0000000..24b7a8c --- /dev/null +++ b/templates/pages/account/locked.html @@ -0,0 +1,26 @@ +{# +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+
+ {{ icon.block() }} +
+ +
+

{{ _("mas.account.locked.heading") }}

+ {% set mxid = "@" + user.username + ":" + branding.server_name %} +

{{ _("mas.account.locked.description", mxid=mxid) }}

+
+ + {{ logout.button(text=_("action.sign_in"), csrf_token=csrf_token) }} +
+
+{% endblock %} diff --git a/templates/pages/account/logged_out.html b/templates/pages/account/logged_out.html new file mode 100644 index 0000000..e625a4c --- /dev/null +++ b/templates/pages/account/logged_out.html @@ -0,0 +1,25 @@ +{# +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+
+ {{ icon.leave() }} +
+ +
+

{{ _("mas.account.logged_out.heading") }}

+

{{ _("mas.account.logged_out.description") }}

+
+ + {{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token) }} +
+
+{% endblock %} diff --git a/templates/pages/consent.html b/templates/pages/consent.html new file mode 100644 index 0000000..7b02f76 --- /dev/null +++ b/templates/pages/consent.html @@ -0,0 +1,76 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2022-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% set consent_page = true %} + +{% extends "base.html" %} + +{% block content %} + {% set client_name = client.client_name or client.client_id %} +
+ {% if client.logo_uri %} + + {% else %} + + {% endif %} + +
+

{{ _("mas.consent.heading") }}

+

+ {{ _("mas.consent.client_wants_access", client_name=client_name, redirect_uri=(grant.redirect_uri | simplify_url)) }} + {{ _("mas.consent.this_will_allow", client_name=client_name) }} +

+
+
+ + + +
+ {{ _("mas.consent.make_sure_you_trust", client_name=client_name) }} + {{ _("mas.consent.you_may_be_sharing") }} + {% if client.policy_uri or client.tos_uri %} + Find out how {{ client_name }} will handle your data by reviewing its + {% if client.policy_uri %} + privacy policy{% if not client.tos_uri %}.{% endif %} + {% endif %} + {% if client.policy_uri and client.tos_uri%} + and + {% endif %} + {% if client.tos_uri %} + terms of service. + {% endif %} + {% endif %} +
+ +
+
+ + {{ button.button(text=_("action.continue")) }} +
+ +
+

+ {{ _("mas.not_you", username=current_session.user.username) }} +

+ + {{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token, post_logout_action=action, as_link=true) }} +
+ + {{ back_to_client.link( + text=_("action.cancel"), + kind="tertiary", + uri=grant.redirect_uri, + mode=grant.response_mode, + params=dict(error="access_denied", state=grant.state) + ) }} +
+{% endblock content %} diff --git a/templates/pages/device_consent.html b/templates/pages/device_consent.html new file mode 100644 index 0000000..e8abbdb --- /dev/null +++ b/templates/pages/device_consent.html @@ -0,0 +1,161 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2023, 2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% set consent_page = true %} + +{% extends "base.html" %} + +{% block content %} + {% set client_name = client.client_name or client.client_id %} + + {% if grant.state == "pending" %} +
+ {% if client.logo_uri %} + + {% else %} + + {% endif %} + +
+

{{ _("mas.consent.heading") }}

+ +
+
+
+ {% if grant.user_agent.device_type == "mobile" %} + {{ icon.mobile() }} + {% elif grant.user_agent.device_type == "tablet" %} + {{ icon.web_browser() }} + {% elif grant.user_agent.device_type == "pc" %} + {{ icon.computer() }} + {% else %} + {{ icon.unknown_solid() }} + {% endif %} +
+ +
+ {% if grant.user_agent.model %} +
{{ grant.user_agent.model }}
+ {% endif %} + + {% if grant.user_agent.os %} +
+ {{ grant.user_agent.os }} + {% if grant.user_agent.os_version %} + {{ grant.user_agent.os_version }} + {% endif %} +
+ {% endif %} + + {# If we haven't detected a model, it's probably a browser, so show the name #} + {% if not grant.user_agent.model and grant.user_agent.name %} +
+ {{ grant.user_agent.name }} + {% if grant.user_agent.version %} + {{ grant.user_agent.version }} + {% endif %} +
+ {% endif %} + + {# If we couldn't detect anything, show a generic "Device" #} + {% if not grant.user_agent.model and not grant.user_agent.name and not grant.user_agent.os %} +
{{ _("mas.device_card.generic_device") }}
+ {% endif %} +
+
+ +
+ +

+ {{ _("mas.device_consent.another_device_access") }} + {{ _("mas.consent.this_will_allow", client_name=client_name) }} +

+
+
+ + + +
+ {{ _("mas.consent.make_sure_you_trust", client_name=client_name) }} + {{ _("mas.consent.you_may_be_sharing") }} + {% if client.policy_uri or client.tos_uri %} + Find out how {{ client_name }} will handle your data by reviewing its + {% if client.policy_uri %} + privacy policy{% if not client.tos_uri %}.{% endif %} + {% endif %} + {% if client.policy_uri and client.tos_uri%} + and + {% endif %} + {% if client.tos_uri %} + terms of service. + {% endif %} + {% endif %} +
+ +
+
+ + + +
+ +
+

+ {{ _("mas.not_you", username=current_session.user.username) }} +

+ + {{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token, post_logout_action=action, as_link=true) }} +
+
+ {% elif grant.state == "rejected" %} +
+
+ {{ icon.block() }} +
+ +
+

{{ _("mas.device_consent.denied.heading") }}

+

{{ _("mas.device_consent.denied.description", client_name=client_name) }}

+
+
+ {% else %} +
+
+ {{ icon.check() }} +
+ +
+

{{ _("mas.device_consent.granted.heading") }}

+

{{ _("mas.device_consent.granted.description", client_name=client_name) }}

+
+
+ {% endif %} +{% endblock content %} diff --git a/templates/pages/device_link.html b/templates/pages/device_link.html new file mode 100644 index 0000000..c77219f --- /dev/null +++ b/templates/pages/device_link.html @@ -0,0 +1,42 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2023, 2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+ {{ icon.link() }} +
+ +
+

{{ _("mas.device_code_link.headline") }}

+

{{ _("mas.device_code_link.description") }}

+
+
+ +
+ {% call(f) field.field(label="Device code", name="code", class="mb-4 self-center", form_state=form_state) %} +
+ + + {% for _ in range(6) %} + + {% endfor %} +
+ {% endcall %} + + {{ button.button(text=_("action.continue")) }} +
+{% endblock content %} diff --git a/templates/pages/error.html b/templates/pages/error.html new file mode 100644 index 0000000..d078563 --- /dev/null +++ b/templates/pages/error.html @@ -0,0 +1,42 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2021-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{# Sometimes we don't have the language set, so we default to english #} +{% set lang = lang or "en" %} + +{% extends "base.html" %} + +{% block content %} +
+
+
+ {{ icon.error_solid() }} +
+ +
+

{{ _("error.unexpected") }}

+ {% if code %} +

+ {{ code }} +

+ {% endif %} + {% if description %} +

+ {{ description }} +

+ {% endif %} +
+
+ + {% if details %} +
+ {# caution: do not introduce whitespace between
 and  #}
+      
{{ details }}
+ {% endif %} +
+{% endblock %} diff --git a/templates/pages/index.html b/templates/pages/index.html new file mode 100644 index 0000000..03fb476 --- /dev/null +++ b/templates/pages/index.html @@ -0,0 +1,37 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2021-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+
+

{{ _("app.human_name") }}

+

+ {{ _("app.technical_description", discovery_url=discovery_url) }} +

+
+
+ + {% if current_session %} +

+ {{ _("mas.navbar.signed_in_as", username=current_session.user.username) }} +

+ + {{ button.link(text=_("mas.navbar.my_account"), href="/account/") }} + {{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token) }} + {% else %} + {{ button.link(text=_("action.sign_in"), href="/login") }} + + {% if features.password_registration %} + {{ button.link_outline(text=_("mas.navbar.register"), href="/register") }} + {% endif %} + {% endif %} +
+{% endblock content %} diff --git a/templates/pages/login.html b/templates/pages/login.html new file mode 100644 index 0000000..8ed2300 --- /dev/null +++ b/templates/pages/login.html @@ -0,0 +1,104 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2021-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% from "components/idp_brand.html" import logo %} + +{% block content %} +
+
+
+ {{ icon.user_profile_solid() }} +
+ + {% if next and next.kind == "link_upstream" %} +
+

{{ _("mas.login.link.headline") }}

+ {% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %} +

{{ _("mas.login.link.description", provider=name) }}

+
+ {% else %} +
+

{{ _("mas.login.headline") }}

+

{{ _("mas.login.description") }}

+
+ {% endif %} +
+ +
+ {% if form.errors is not empty %} + {% for error in form.errors %} +
+ {{ errors.form_error_message(error=error) }} +
+ {% endfor %} + {% endif %} + + + + {% if features.login_with_email_allowed %} + {% call(f) field.field(label="TCHAP", name="username", form_state=form) %} + + {% endcall %} + {% else %} + {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %} + + {% endcall %} + {% endif %} + + {% if features.password_login %} + {% call(f) field.field(label=_("common.password"), name="password", form_state=form) %} + + {% endcall %} + + {% if features.account_recovery %} + {{ button.link_text(text=_("mas.login.forgot_password"), href="/recover", class="self-center") }} + {% endif %} + {% endif %} +
+ +
+ {% if features.password_login %} + {{ button.button(text=_("action.continue")) }} + {% endif %} + + {% if features.password_login and providers %} + {{ field.separator() }} + {% endif %} + + {% if providers %} + {% set params = next["params"] | default({}) | to_params(prefix="?") %} + {% for provider in providers %} + {% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %} + + {{ logo(provider.brand_name) }} + {{ _("mas.login.continue_with_provider", provider=name) }} + + {% endfor %} + {% endif %} +
+ + {% if (not next or next.kind != "link_upstream") and features.password_registration %} +
+

+ {{ _("mas.login.call_to_register") }} +

+ + {% set params = next["params"] | default({}) | to_params(prefix="?") %} + {{ button.link_text(text=_("action.create_account"), href="/register" ~ params) }} +
+ {% endif %} + + {% if not providers and not features.password_login %} +
+ {{ _("mas.login.no_login_methods") }} +
+ {% endif %} +
+{% endblock content %} diff --git a/templates/pages/policy_violation.html b/templates/pages/policy_violation.html new file mode 100644 index 0000000..cca0365 --- /dev/null +++ b/templates/pages/policy_violation.html @@ -0,0 +1,52 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2022-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+ {{ icon.error_solid() }} +
+ +
+

{{ _("mas.policy_violation.heading") }}

+

{{ _("mas.policy_violation.description") }}

+
+
+ +
+
+
+ {% if client.logo_uri %} + + {% endif %} +
+ {{ client.client_name or client.client_id }} +
+ +
+

+ {{ _("mas.policy_violation.logged_as", username=current_session.user.username) }} +

+ + {{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token, post_logout_action=action, as_link=True) }} +
+ + {# We only show the cancel button if we're in an authorization code flow, not in the device code flow. #} + {% if grant.grant_type == "authorization_code" %} + {{ back_to_client.link( + text=_("action.cancel"), + destructive=True, + uri=grant.redirect_uri, + mode=grant.response_mode, + params=dict(error="access_denied", state=grant.state) + ) }} + {% endif %} +
+{% endblock content %} diff --git a/templates/pages/reauth.html b/templates/pages/reauth.html new file mode 100644 index 0000000..3a71a10 --- /dev/null +++ b/templates/pages/reauth.html @@ -0,0 +1,54 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2021-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+ {{ icon.lock() }} +
+ +
+

Hi {{ current_session.user.username }}

+

To continue, please verify it's you:

+
+
+ +
+
+ + {# TODO: errors #} + + {% call(f) field.field(label=_("common.password"), name="password", form_state=form) %} + + {% endcall %} + + {{ button.button(text=_("action.continue")) }} +
+ + {% if next and next.kind == "continue_authorization_grant" %} + {{ back_to_client.link( + text="Cancel", + destructive=True, + uri=next.grant.redirect_uri, + mode=next.grant.response_mode, + params=dict(error="access_denied", state=next.grant.state) + ) }} + {% endif %} + +
+

+ Not {{ current_session.user.username }}? +

+ + {% set post_logout_action = next["params"] | default({}) %} + {{ logout.button(text="Sign out", csrf_token=csrf_token, post_logout_action=post_logout_action, as_link=true) }} +
+
+{% endblock content %} diff --git a/templates/pages/recovery/consumed.html b/templates/pages/recovery/consumed.html new file mode 100644 index 0000000..e289f3f --- /dev/null +++ b/templates/pages/recovery/consumed.html @@ -0,0 +1,24 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+ {{ icon.error_solid() }} +
+ +
+

{{ _("mas.recovery.consumed.heading") }}

+

{{ _("mas.recovery.consumed.description") }}

+
+ + {{ button.link_outline(text=_("action.start_over"), href="/login") }} +
+{% endblock content %} diff --git a/templates/pages/recovery/disabled.html b/templates/pages/recovery/disabled.html new file mode 100644 index 0000000..a4a942c --- /dev/null +++ b/templates/pages/recovery/disabled.html @@ -0,0 +1,24 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+ {{ icon.lock_solid() }} +
+ +
+

{{ _("mas.recovery.disabled.heading") }}

+

{{ _("mas.recovery.disabled.description") }}

+
+ + {{ button.link_outline(text=_("action.back"), href="/login") }} +
+{% endblock content %} diff --git a/templates/pages/recovery/expired.html b/templates/pages/recovery/expired.html new file mode 100644 index 0000000..2900444 --- /dev/null +++ b/templates/pages/recovery/expired.html @@ -0,0 +1,32 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+ {{ icon.error_solid() }} +
+ +
+

{{ _("mas.recovery.expired.heading") }}

+

{{ _("mas.recovery.expired.description", email=session.email) }}

+
+
+ +
+
+ + + {{ button.button(text=_("mas.recovery.expired.resend_email"), type="submit") }} +
+ + {{ button.link_outline(text=_("action.start_over"), href="/login") }} +
+{% endblock content %} diff --git a/templates/pages/recovery/finish.html b/templates/pages/recovery/finish.html new file mode 100644 index 0000000..923aee9 --- /dev/null +++ b/templates/pages/recovery/finish.html @@ -0,0 +1,47 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+ {{ icon.lock_solid() }} +
+ +
+

{{ _("mas.recovery.finish.heading") }}

+

{{ _("mas.recovery.finish.description") }}

+
+
+ +
+ {# Hidden username field so that password manager can save the username #} + + + {% if form.errors is not empty %} + {% for error in form.errors %} +
+ {{ errors.form_error_message(error=error) }} +
+ {% endfor %} + {% endif %} + + + + {% call(f) field.field(label=_("mas.recovery.finish.new"), name="new_password", form_state=form) %} + + {% endcall %} + + {% call(f) field.field(label=_("mas.recovery.finish.confirm"), name="new_password_confirm", form_state=form) %} + + {% endcall %} + + {{ button.button(text=_("mas.recovery.finish.save_and_continue"), type="submit") }} +
+{% endblock content %} diff --git a/templates/pages/recovery/progress.html b/templates/pages/recovery/progress.html new file mode 100644 index 0000000..e929e63 --- /dev/null +++ b/templates/pages/recovery/progress.html @@ -0,0 +1,37 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+ {{ icon.send_solid() }} +
+ +
+

{{ _("mas.recovery.progress.heading") }}

+

{{ _("mas.recovery.progress.description", email=session.email) }}

+
+
+ +
+ {% if resend_failed_due_to_rate_limit | default(false) %} +
+ {{ _("mas.errors.rate_limit_exceeded") }} +
+ {% endif %} +
+ + + {{ button.button_outline(text=_("mas.recovery.progress.resend_email"), type="submit") }} +
+ + {{ button.link_tertiary(text=_("mas.recovery.progress.change_email"), href="/recover") }} +
+{% endblock content %} diff --git a/templates/pages/recovery/start.html b/templates/pages/recovery/start.html new file mode 100644 index 0000000..96e9ea3 --- /dev/null +++ b/templates/pages/recovery/start.html @@ -0,0 +1,40 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+ {{ icon.email_solid() }} +
+ +
+

{{ _("mas.recovery.start.heading") }}

+

{{ _("mas.recovery.start.description") }}

+
+
+ +
+ {% if form.errors is not empty %} + {% for error in form.errors %} +
+ {{ errors.form_error_message(error=error) }} +
+ {% endfor %} + {% endif %} + + + + {% call(f) field.field(label=_("common.email_address"), name="email", form_state=form) %} + + {% endcall %} + + {{ button.button(text=_("action.continue"), type="submit") }} +
+{% endblock content %} diff --git a/templates/pages/register/index.html b/templates/pages/register/index.html new file mode 100644 index 0000000..1f1d16e --- /dev/null +++ b/templates/pages/register/index.html @@ -0,0 +1,62 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2021-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% from "components/idp_brand.html" import logo %} + +{% block content %} +
+
+
+ {{ icon.user_profile_solid() }} +
+ +
+

{{ _("mas.register.create_account.heading") }}

+ + {% if features.password_registration %} +

{{ _("mas.register.create_account.description") }}

+ {% endif %} +
+
+ + {% if features.password_registration %} + {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %} + +
+ @username:{{ branding.server_name }} +
+ {% endcall %} + {% endif %} + +
+ {% for key, value in next["params"] | default({}) | items %} + + {% endfor %} + + {% if features.password_registration %} + {{ button.button(text=_("mas.register.continue_with_email")) }} + {% endif %} + + {% if providers %} + {% set params = next["params"] | default({}) | to_params(prefix="?") %} + {% for provider in providers %} + {% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %} + + {{ logo(provider.brand_name) }} + {{ _("mas.login.continue_with_provider", provider=name) }} + + {% endfor %} + {% endif %} + + {% set params = next["params"] | default({}) | to_params(prefix="?") %} + {{ button.link_tertiary(text=_("mas.register.call_to_login"), href="/login" ~ params) }} +
+
+{% endblock content %} diff --git a/templates/pages/register/password.html b/templates/pages/register/password.html new file mode 100644 index 0000000..8769c36 --- /dev/null +++ b/templates/pages/register/password.html @@ -0,0 +1,79 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2021-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+ {{ icon.user_profile_solid() }} +
+ +
+

{{ _("mas.register.create_account.heading") }}

+
+
+ +
+ {% for error in form.errors %} + {# Special case for the captcha error, as we want to put it at the bottom #} + {% if error.kind != "captcha" %} +
+ {{ errors.form_error_message(error=error) }} +
+ {% endif %} + {% endfor %} + + + + {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %} + + {% endcall %} + + {% call(f) field.field(label=_("common.email_address"), name="email", form_state=form) %} + + {% endcall %} + + {% call(f) field.field(label=_("common.password"), name="password", form_state=form) %} + + {% endcall %} + + {% call(f) field.field(label=_("common.password_confirm"), name="password_confirm", form_state=form) %} + + {% endcall %} + + {% if branding.tos_uri %} + {% call(f) field.field(label=_("mas.register.terms_of_service", tos_uri=branding.tos_uri), name="accept_terms", form_state=form, inline=true, class="my-4") %} +
+
+ +
+ {{ icon.check() }} +
+
+
+ {% endcall %} + {% endif %} + + {{ captcha.form(class="mb-4 self-center") }} + + {% for error in form.errors %} + {# Special case for the captcha error #} + {% if error.kind == "captcha" %} +
+ {{ errors.form_error_message(error=error) }} +
+ {% endif %} + {% endfor %} + + {{ button.button(text=_("action.continue")) }} + + {% set params = next["params"] | default({}) | to_params(prefix="?") %} + {{ button.link_tertiary(text=_("mas.register.call_to_login"), href="/login" ~ params) }} +
+{% endblock content %} diff --git a/templates/pages/register/steps/display_name.html b/templates/pages/register/steps/display_name.html new file mode 100644 index 0000000..b4e774c --- /dev/null +++ b/templates/pages/register/steps/display_name.html @@ -0,0 +1,52 @@ +{# +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+ {{ icon.visibility_on() }} +
+
+

{{ _("mas.choose_display_name.headline") }}

+

{{ _("mas.choose_display_name.description") }}

+
+
+ +
+
+ {% if form.errors is not empty %} + {% for error in form.errors %} +
+ {{ errors.form_error_message(error=error) }} +
+ {% endfor %} + {% endif %} + + + + + {% call(f) field.field(label=_("common.display_name"), name="display_name", form_state=form, class="mb-4") %} + + {% endcall %} + + {{ button.button(text=_("action.continue")) }} +
+ +
+ + + {{ button.button(text=_("action.skip"), kind="tertiary") }} +
+
+{% endblock content %} diff --git a/templates/pages/register/steps/email_in_use.html b/templates/pages/register/steps/email_in_use.html new file mode 100644 index 0000000..ce9a0bd --- /dev/null +++ b/templates/pages/register/steps/email_in_use.html @@ -0,0 +1,30 @@ +{# +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+
+ {{ icon.error_solid() }} +
+ +
+

+ {{ _("mas.email_in_use.title", email=email) }} +

+

+ {{ _("mas.email_in_use.description") }} +

+
+
+ + {% set params = action | default({}) | to_params(prefix="?") %} + {{ button.link_outline(text=_("action.start_over"), href="/register" ~ params) }} +
+{% endblock %} diff --git a/templates/pages/register/steps/verify_email.html b/templates/pages/register/steps/verify_email.html new file mode 100644 index 0000000..39b1c9a --- /dev/null +++ b/templates/pages/register/steps/verify_email.html @@ -0,0 +1,53 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2022-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+ {{ icon.send_solid() }} +
+
+

{{ _("mas.verify_email.headline") }}

+

{{ _("mas.verify_email.description", email=authentication.email) }}

+
+
+ +
+ {% if form.errors is not empty %} + {% for error in form.errors %} +
+ {{ errors.form_error_message(error=error) }} +
+ {% endfor %} + {% endif %} + + + + {% call(f) field.field(label=_("mas.verify_email.6_digit_code"), name="code", form_state=form, class="mb-4 self-center") %} +
+ + + {% for _ in range(6) %} + + {% endfor %} +
+ {% endcall %} + + {{ button.button(text=_("action.continue")) }} +
+{% endblock content %} diff --git a/templates/pages/sso.html b/templates/pages/sso.html new file mode 100644 index 0000000..5104dc5 --- /dev/null +++ b/templates/pages/sso.html @@ -0,0 +1,48 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2022-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} + {% set client_name = login.redirect_uri | simplify_url %} + +
+ + +
+

Allow access to your account?

+

{{ client_name }} wants to access your account. This will allow {{ client_name }} to:

+
+
+ + + +
+ Make sure that you trust {{ client_name }}. + You may be sharing sensitive information with this site or app. +
+ +
+
+ + {{ button.button(text=_("action.continue")) }} +
+ +
+

+ {{ _("mas.not_you", username=current_session.user.username) }} +

+ + {{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token, post_logout_action=action, as_link=true) }} +
+
+{% endblock content %} diff --git a/templates/pages/upstream_oauth2/do_register.html b/templates/pages/upstream_oauth2/do_register.html new file mode 100644 index 0000000..c9b8935 --- /dev/null +++ b/templates/pages/upstream_oauth2/do_register.html @@ -0,0 +1,199 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2022-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% from "components/idp_brand.html" import logo %} + +{% block content %} + {% if force_localpart %} +
+
+ {{ icon.download() }} +
+ +
+

+ {{ _("mas.upstream_oauth2.register.import_data.heading") }} +

+

+ {{ _("mas.upstream_oauth2.register.import_data.description", server_name=branding.server_name) }} +

+
+
+ {% elif upstream_oauth_provider.human_name %} +
+
+ {{ icon.user_profile_solid() }} +
+ +
+

+ {{ _("mas.upstream_oauth2.register.signup_with_upstream.heading", human_name=upstream_oauth_provider.human_name) }} +

+
+
+ {% else %} +
+
+ {{ icon.mention() }} +
+ +
+

+ {{ _("mas.upstream_oauth2.register.choose_username.heading") }} +

+

+ {{ _("mas.upstream_oauth2.register.choose_username.description") }} +

+
+
+ {% endif %} + + {% if upstream_oauth_provider.human_name %} + + {% endif %} + +
+ + + + {% if form_state.errors is not empty %} + {% for error in form_state.errors %} +
+ {{- errors.form_error_message(error=error) -}} +
+ {% endfor %} + {% endif %} + + + {% if force_localpart %} + {% call(f) field.field(label=_("common.mxid"), name="mxid") %} + + +
+ {{- _("mas.upstream_oauth2.register.enforced_by_policy") -}} +
+ {% endcall %} + {% else %} + {% call(f) field.field(label=_("common.username"), name="username", form_state=form_state) %} + + + {% if f.errors is empty %} +
+ @{{ imported_localpart or (_("common.username") | lower) }}:{{ branding.server_name }} +
+ {% endif %} + {% endcall %} + {% endif %} + + {% if imported_email %} +
+ {% call(f) field.field(label=_("common.email_address"), name="email", class="flex-1") %} + + +
+ {% if upstream_oauth_provider.human_name %} + {{- _("mas.upstream_oauth2.register.imported_from_upstream_with_name", human_name=upstream_oauth_provider.human_name) -}} + {% else %} + {{- _("mas.upstream_oauth2.register.imported_from_upstream") -}} + {% endif %} +
+ {% endcall %} + + {% if not force_email %} +
+
+
+ +
+ {{ icon.check() }} +
+
+
+ +
+ {% endif %} +
+ {% endif %} + + {% if imported_display_name %} +
+ {% call(f) field.field(label=_("common.display_name"), name="display_name", class="flex-1") %} + + +
+ {% if upstream_oauth_provider.human_name %} + {{- _("mas.upstream_oauth2.register.imported_from_upstream_with_name", human_name=upstream_oauth_provider.human_name) -}} + {% else %} + {{- _("mas.upstream_oauth2.register.imported_from_upstream") -}} + {% endif %} +
+ {% endcall %} + + {% if not force_display_name %} +
+
+
+ +
+ {{ icon.check() }} +
+
+
+
+ +
+
+ {% endif %} +
+ {% endif %} + + {% if branding.tos_uri %} + {% call(f) field.field(label=_("mas.register.terms_of_service", tos_uri=branding.tos_uri), name="accept_terms", form_state=form_state, inline=true, class="my-4") %} +
+
+ +
+ {{ icon.check() }} +
+
+
+ {% endcall %} + {% endif %} + + + {{ button.button(text=_("action.create_account")) }} +
+ + {# Leave this for now as we don't have that fully designed yet + {{ field.separator() }} + {{ button.link_outline(text=_("mas.upstream_oauth2.register.link_existing"), href=login_link) }} + #} +{% endblock content %} diff --git a/templates/pages/upstream_oauth2/link_mismatch.html b/templates/pages/upstream_oauth2/link_mismatch.html new file mode 100644 index 0000000..4ab1dc8 --- /dev/null +++ b/templates/pages/upstream_oauth2/link_mismatch.html @@ -0,0 +1,25 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2022-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+ {{ icon.warning() }} +
+ +
+

+ {{ _("mas.upstream_oauth2.link_mismatch.heading") }} +

+
+
+ + {{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token) }} +{% endblock content %} diff --git a/templates/pages/upstream_oauth2/suggest_link.html b/templates/pages/upstream_oauth2/suggest_link.html new file mode 100644 index 0000000..a226092 --- /dev/null +++ b/templates/pages/upstream_oauth2/suggest_link.html @@ -0,0 +1,34 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2022-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+ {{ icon.link() }} +
+ +
+

{{ _("mas.upstream_oauth2.suggest_link.heading") }}

+
+
+ +
+
+ + + + {{ button.button(text=_("mas.upstream_oauth2.suggest_link.action")) }} +
+ + {{ field.separator() }} + + {{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token, post_logout_action=post_logout_action) }} +
+{% endblock content %} diff --git a/templates/swagger/doc.html b/templates/swagger/doc.html new file mode 100644 index 0000000..59a7763 --- /dev/null +++ b/templates/swagger/doc.html @@ -0,0 +1,27 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + + + + + + + API documentation + + {{ include_asset('src/swagger.ts') | indent(4) | safe }} + + + +
+ + diff --git a/templates/swagger/oauth2-redirect.html b/templates/swagger/oauth2-redirect.html new file mode 100644 index 0000000..e00644e --- /dev/null +++ b/templates/swagger/oauth2-redirect.html @@ -0,0 +1,89 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{# This is taken from the swagger-ui/dist/oauth2-redirect.html file #} + + + + + API documentation: OAuth2 Redirect + + + + + diff --git a/matrix-authentication-service-tchap/tools/README_TCHAP.md b/tools/README_TCHAP.md similarity index 100% rename from matrix-authentication-service-tchap/tools/README_TCHAP.md rename to tools/README_TCHAP.md diff --git a/matrix-authentication-service-tchap/tools/keycloak/init-keycloak-db.sql b/tools/keycloak/init-keycloak-db.sql similarity index 100% rename from matrix-authentication-service-tchap/tools/keycloak/init-keycloak-db.sql rename to tools/keycloak/init-keycloak-db.sql diff --git a/matrix-authentication-service-tchap/tools/keycloak/proconnect-mock-realm.json b/tools/keycloak/proconnect-mock-realm.json similarity index 100% rename from matrix-authentication-service-tchap/tools/keycloak/proconnect-mock-realm.json rename to tools/keycloak/proconnect-mock-realm.json diff --git a/matrix-authentication-service-tchap/tools/pre-commit-check.sh b/tools/pre-commit-check.sh similarity index 100% rename from matrix-authentication-service-tchap/tools/pre-commit-check.sh rename to tools/pre-commit-check.sh diff --git a/matrix-authentication-service-tchap/tools/test_admin.sh b/tools/test_admin.sh similarity index 100% rename from matrix-authentication-service-tchap/tools/test_admin.sh rename to tools/test_admin.sh From 5f76231f68f40047f51e76ea35f8b21c442f0fab Mon Sep 17 00:00:00 2001 From: Olivier D Date: Tue, 22 Apr 2025 10:11:36 +0200 Subject: [PATCH 07/71] Update start-local-mas.sh --- start-local-mas.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/start-local-mas.sh b/start-local-mas.sh index 273acfb..c839438 100755 --- a/start-local-mas.sh +++ b/start-local-mas.sh @@ -1,6 +1,14 @@ #!/bin/bash set -e + +# Check if MAS_HOME is defined +if [ -z "$MAS_HOME" ]; then + echo "Error: MAS_HOME environment variable is not defined" + echo "Please set MAS_HOME to the path of your matrix-authentication-service directory" + exit 1 +fi + MAS_CONF=$PWD/conf cd $MAS_HOME -cargo run -- server -c $MAS_CONF/config.local.dev.yaml \ No newline at end of file +cargo run -- server -c $MAS_CONF/config.local.dev.yaml From e8a053ec5f839927a70813bdda01a031103d70bb Mon Sep 17 00:00:00 2001 From: Olivier D Date: Tue, 22 Apr 2025 10:11:56 +0200 Subject: [PATCH 08/71] Update start-local-stack.sh --- start-local-stack.sh | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/start-local-stack.sh b/start-local-stack.sh index 56f3b88..e4f5d74 100755 --- a/start-local-stack.sh +++ b/start-local-stack.sh @@ -21,7 +21,6 @@ #docker rm -f keycloak #docker run -d --name keycloak -p 8082:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin -v $(pwd)/tchap/keycloak/proconnect-mock-realm.json:/opt/keycloak/data/import/proconnect-mock-realm.json quay.io/keycloak/keycloak:latest start-dev --import-realm --hostname=https://sso.tchapgouv.com -docker compose stop - -docker compose start -docker compose logs -f \ No newline at end of file +mkdir tmp +docker compose up -d +docker compose logs -f From b0216c0ab7aa6375e1bfc66a77abbf7085d754ef Mon Sep 17 00:00:00 2001 From: mcalinghee Date: Tue, 22 Apr 2025 12:28:56 +0200 Subject: [PATCH 09/71] support html template (#2) * support html template --- README.md | 1 + ...ig.local.dev.yaml => config.template.yaml} | 26 +- {templates => resources}/README.md | 0 resources/css/tchap.css | 27 + .../templates}/pages/login.html | 11 +- start-local-mas.sh | 7 +- templates/app.html | 31 - templates/base.html | 36 - templates/components/back_to_client.html | 37 - templates/components/button.html | 97 --- templates/components/captcha.html | 41 - templates/components/errors.html | 23 - templates/components/field.html | 110 --- templates/components/footer.html | 33 - templates/components/icon.html | 803 ------------------ templates/components/idp_brand.html | 53 -- templates/components/logout.html | 21 - templates/components/scope.html | 31 - templates/emails/recovery.html | 52 -- templates/emails/recovery.subject | 14 - templates/emails/recovery.txt | 16 - templates/emails/verification.html | 19 - templates/emails/verification.subject | 11 - templates/emails/verification.txt | 19 - templates/form_post.html | 32 - templates/pages/404.html | 27 - templates/pages/account/deactivated.html | 26 - templates/pages/account/locked.html | 26 - templates/pages/account/logged_out.html | 25 - templates/pages/consent.html | 76 -- templates/pages/device_consent.html | 161 ---- templates/pages/device_link.html | 42 - templates/pages/error.html | 42 - templates/pages/index.html | 37 - templates/pages/policy_violation.html | 52 -- templates/pages/reauth.html | 54 -- templates/pages/recovery/consumed.html | 24 - templates/pages/recovery/disabled.html | 24 - templates/pages/recovery/expired.html | 32 - templates/pages/recovery/finish.html | 47 - templates/pages/recovery/progress.html | 37 - templates/pages/recovery/start.html | 40 - templates/pages/register/index.html | 62 -- templates/pages/register/password.html | 79 -- .../pages/register/steps/display_name.html | 52 -- .../pages/register/steps/email_in_use.html | 30 - .../pages/register/steps/verify_email.html | 53 -- templates/pages/sso.html | 48 -- .../pages/upstream_oauth2/do_register.html | 199 ----- .../pages/upstream_oauth2/link_mismatch.html | 25 - .../pages/upstream_oauth2/suggest_link.html | 34 - templates/swagger/doc.html | 27 - templates/swagger/oauth2-redirect.html | 89 -- tools/build_conf.sh | 23 + 54 files changed, 77 insertions(+), 2967 deletions(-) rename conf/{config.local.dev.yaml => config.template.yaml} (92%) rename {templates => resources}/README.md (100%) create mode 100644 resources/css/tchap.css rename {templates => resources/templates}/pages/login.html (85%) delete mode 100644 templates/app.html delete mode 100644 templates/base.html delete mode 100644 templates/components/back_to_client.html delete mode 100644 templates/components/button.html delete mode 100644 templates/components/captcha.html delete mode 100644 templates/components/errors.html delete mode 100644 templates/components/field.html delete mode 100644 templates/components/footer.html delete mode 100644 templates/components/icon.html delete mode 100644 templates/components/idp_brand.html delete mode 100644 templates/components/logout.html delete mode 100644 templates/components/scope.html delete mode 100644 templates/emails/recovery.html delete mode 100644 templates/emails/recovery.subject delete mode 100644 templates/emails/recovery.txt delete mode 100644 templates/emails/verification.html delete mode 100644 templates/emails/verification.subject delete mode 100644 templates/emails/verification.txt delete mode 100644 templates/form_post.html delete mode 100644 templates/pages/404.html delete mode 100644 templates/pages/account/deactivated.html delete mode 100644 templates/pages/account/locked.html delete mode 100644 templates/pages/account/logged_out.html delete mode 100644 templates/pages/consent.html delete mode 100644 templates/pages/device_consent.html delete mode 100644 templates/pages/device_link.html delete mode 100644 templates/pages/error.html delete mode 100644 templates/pages/index.html delete mode 100644 templates/pages/policy_violation.html delete mode 100644 templates/pages/reauth.html delete mode 100644 templates/pages/recovery/consumed.html delete mode 100644 templates/pages/recovery/disabled.html delete mode 100644 templates/pages/recovery/expired.html delete mode 100644 templates/pages/recovery/finish.html delete mode 100644 templates/pages/recovery/progress.html delete mode 100644 templates/pages/recovery/start.html delete mode 100644 templates/pages/register/index.html delete mode 100644 templates/pages/register/password.html delete mode 100644 templates/pages/register/steps/display_name.html delete mode 100644 templates/pages/register/steps/email_in_use.html delete mode 100644 templates/pages/register/steps/verify_email.html delete mode 100644 templates/pages/sso.html delete mode 100644 templates/pages/upstream_oauth2/do_register.html delete mode 100644 templates/pages/upstream_oauth2/link_mismatch.html delete mode 100644 templates/pages/upstream_oauth2/suggest_link.html delete mode 100644 templates/swagger/doc.html delete mode 100644 templates/swagger/oauth2-redirect.html create mode 100755 tools/build_conf.sh diff --git a/README.md b/README.md index 159aa8e..c43ec56 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,4 @@ export $MAS_HOME : path of /matrix-authentication-service, fork léger https://github.com/tchapgouv/matrix-authentication-service +export $MAS_TCHAP_HOME : path of /matrix-authentication-service-tchap \ No newline at end of file diff --git a/conf/config.local.dev.yaml b/conf/config.template.yaml similarity index 92% rename from conf/config.local.dev.yaml rename to conf/config.template.yaml index 753d869..bbcec99 100644 --- a/conf/config.local.dev.yaml +++ b/conf/config.template.yaml @@ -167,19 +167,19 @@ account: # This has no effect if password login is disabled. login_with_email_allowed: true -# templates: -# # From where to load the templates -# # This is relative to the current working directory, *not* the config file -# path: /Users/mca/Documents/work/projets/betagouv/repo/matrix-authentication-service-tchap/templates -# -# # Path to the frontend assets manifest file -# assets_manifest: /to/manifest.json -# -# # From where to load the translation files -# # Default in Docker distribution: `/usr/local/share/mas-cli/translations/` -# # Default in pre-built binaries: `./share/translations/` -# # Default in locally-built binaries: `./translations/` -# translations_path: /to/translations +templates: + # From where to load the templates + # This is relative to the current working directory, *not* the config file + path: "WILL BE REPLACED BY build_conf.sh" + + # Path to the frontend assets manifest file + # assets_manifest: "/to/manifest.json" + + # # From where to load the translation files + # # Default in Docker distribution: `/usr/local/share/mas-cli/translations/` + # # Default in pre-built binaries: `./share/translations/` + # # Default in locally-built binaries: `./translations/` + # translations_path: /to/translations upstream_oauth2: providers: diff --git a/templates/README.md b/resources/README.md similarity index 100% rename from templates/README.md rename to resources/README.md diff --git a/resources/css/tchap.css b/resources/css/tchap.css new file mode 100644 index 0000000..7103409 --- /dev/null +++ b/resources/css/tchap.css @@ -0,0 +1,27 @@ +/* NOT USED AT THE MOMENT */ + +.proconnect-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +.proconnect-button { + background-color: transparent !important; + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScyMTEnIGhlaWdodD0nNTgnIGZpbGw9J25vbmUnPjxwYXRoIGZpbGw9JyMwMDAwOTEnIGQ9J00wIDBoMjExdjU4SDB6Jy8+PHBhdGggZmlsbD0nI2ZmZicgZD0nbTY5Ljk4NiAyNi4zNjggMS4xNTYtMS4wNzFjLjgzMyAxLjA1NCAxLjgxOSAxLjU5OCAyLjk0MSAxLjU5OCAxLjI5MiAwIDIuMDQtLjgxNiAyLjA0LTEuOTA0IDAtMi41NS01LjYyNy0yLjI0NC01LjYyNy02LjAzNSAwLTEuNzM0IDEuNDI4LTMuMTk2IDMuNDUxLTMuMTk2IDEuNjgzIDAgMi45MDcuNzY1IDMuNzkxIDEuOTM4bC0xLjE5IDEuMDM3Yy0uNjk3LTEuMDAzLTEuNTQ3LTEuNTQ3LTIuNTg0LTEuNTQ3LTEuMTA1IDAtMS44MzYuNzQ4LTEuODM2IDEuNzM0IDAgMi41NjcgNS42MjcgMi4yNDQgNS42MjcgNi4wNTIgMCAyLjAyMy0xLjU4MSAzLjM0OS0zLjY1NSAzLjM0OS0xLjc2OCAwLTMuMDc3LS42NjMtNC4xMTQtMS45NTVabTEwLjgxNy01LjcxMkg3OS40NmwxLjQ0NS00LjU1NmgxLjY0OWwtMS43NTEgNC41NTZabTQuODE4LTMuNDUxYy0uNTYgMC0xLjAyLS40NTktMS4wMi0xLjAyYTEuMDIgMS4wMiAwIDAgMSAxLjAyLTEuMDAzYy41NjEgMCAxLjAwMy40NTkgMS4wMDMgMS4wMDMgMCAuNTYxLS40NDIgMS4wMi0xLjAwMyAxLjAyWk04NC44OTEgMjh2LTguNTY4aDEuNDQ0VjI4SDg0Ljg5Wm0zLjc2Ny00LjI4NGMwLTIuNDk5IDEuNzE3LTQuNjI0IDQuNDAzLTQuNjI0IDEuMjQxIDAgMi4yNjEuNDU5IDMuMDQzIDEuMjkyVjE1LjI1aDEuNDQ1VjI4aC0xLjQ0NXYtLjk1MmMtLjc4Mi44MzMtMS44MDIgMS4yOTItMy4wNDMgMS4yOTItMi42ODYgMC00LjQwMy0yLjEyNS00LjQwMy00LjYyNFptMS41MyAwYzAgMS44MTkgMS4yMjQgMy4yNjQgMy4wNDMgMy4yNjQgMS4xOSAwIDIuMjEtLjU3OCAyLjg3My0xLjU5OFYyMi4wNWMtLjY4LTEuMDM3LTEuNy0xLjU5OC0yLjg3My0xLjU5OC0xLjgxOSAwLTMuMDQzIDEuNDQ1LTMuMDQzIDMuMjY0Wm0xOC4wMjMgMi44NzNjLS43OTkgMS4wNzEtMi4wNzQgMS43NTEtMy42NzIgMS43NTEtMi44OSAwLTQuNjc1LTIuMTI1LTQuNjc1LTQuNjI0IDAtMi42MDEgMS42NjYtNC42MjQgNC4zMTgtNC42MjQgMi4zMjkgMCAzLjg0MiAxLjU4MSAzLjg0MiAzLjcyMyAwIC4zNC0uMDUxLjY4LS4xMDIuOTE4aC02LjU2MnYuMDM0YzAgMS44ODcgMS4yOTIgMy4yNjQgMy4yMTMgMy4yNjQgMS4wODggMCAyLjAwNi0uNTEgMi41NjctMS4yNzVsMS4wNzEuODMzWm0tNC4wMTItNi4yNTZjLTEuMzk0IDAtMi4zOC43ODItMi43MiAyLjI2MWg1LjA4M2MtLjA1MS0xLjI0MS0uOTUyLTIuMjYxLTIuMzYzLTIuMjYxWk0xMTAuNDczIDI4di04LjU2OGgxLjQ0NXYuOTY5Yy42OTctLjc2NSAxLjU4MS0xLjMwOSAyLjg1Ni0xLjMwOSAxLjkyMSAwIDMuMzQ5IDEuMjkyIDMuMzQ5IDMuNzIzVjI4aC0xLjQ2MnYtNS4xMzRjMC0xLjUzLS44NS0yLjQxNC0yLjE3Ni0yLjQxNC0xLjI0MSAwLTIuMDIzLjcxNC0yLjU2NyAxLjYxNVYyOGgtMS40NDVabTExLjA1Mi0yLjg3M3YtNC4zNjloLTEuNjE1di0xLjMyNmgxLjYxNVYxNy4yOWgxLjQ2MnYyLjE0MmgyLjk3NXYxLjMyNmgtMi45NzV2NC4zNjljMCAxLjM0My42OCAxLjcxNyAxLjcxNyAxLjcxNy41NjEgMCAuOTUyLS4wNjggMS4yNzUtLjIwNHYxLjI5MmMtLjQwOC4xNy0uODY3LjIzOC0xLjQ3OS4yMzgtMS45MDQgMC0yLjk3NS0uOTUyLTIuOTc1LTMuMDQzWm03LjM3Ny03LjkyMmMtLjU2MSAwLTEuMDItLjQ1OS0xLjAyLTEuMDJhMS4wMiAxLjAyIDAgMCAxIDEuMDItMS4wMDNjLjU2MSAwIDEuMDAzLjQ1OSAxLjAwMyAxLjAwMyAwIC41NjEtLjQ0MiAxLjAyLTEuMDAzIDEuMDJaTTEyOC4xNzEgMjh2LTguNTY4aDEuNDQ1VjI4aC0xLjQ0NVptMy4zNzctOC41NjhoMS42MTV2LTEuMDU0YzAtMS44MzYgMS4yMDctMy4xMjggMy4wNDMtMy4xMjguOTUyIDAgMS43LjM0IDIuMjEuODMzbC0uOTAxIDEuMDU0YTEuNjMzIDEuNjMzIDAgMCAwLTEuMjkyLS41NzhjLS45MzUgMC0xLjU5OC42OC0xLjU5OCAxLjc4NXYxLjA4OGgyLjk3NXYxLjMyNmgtMi45NzVWMjhoLTEuNDYydi03LjI0MmgtMS42MTV2LTEuMzI2Wm04LjU0My0yLjIyN2MtLjU2MSAwLTEuMDItLjQ1OS0xLjAyLTEuMDJhMS4wMiAxLjAyIDAgMCAxIDEuMDItMS4wMDNjLjU2MSAwIDEuMDAzLjQ1OSAxLjAwMyAxLjAwMyAwIC41NjEtLjQ0MiAxLjAyLTEuMDAzIDEuMDJaTTEzOS4zNiAyOHYtOC41NjhoMS40NDVWMjhoLTEuNDQ1Wm0xMi4xMTUtMS40MTFjLS43OTkgMS4wNzEtMi4wNzQgMS43NTEtMy42NzIgMS43NTEtMi44OSAwLTQuNjc1LTIuMTI1LTQuNjc1LTQuNjI0IDAtMi42MDEgMS42NjYtNC42MjQgNC4zMTgtNC42MjQgMi4zMjkgMCAzLjg0MiAxLjU4MSAzLjg0MiAzLjcyMyAwIC4zNC0uMDUxLjY4LS4xMDIuOTE4aC02LjU2MnYuMDM0YzAgMS44ODcgMS4yOTIgMy4yNjQgMy4yMTMgMy4yNjQgMS4wODggMCAyLjAwNi0uNTEgMi41NjctMS4yNzVsMS4wNzEuODMzWm0tNC4wMTItNi4yNTZjLTEuMzk0IDAtMi4zOC43ODItMi43MiAyLjI2MWg1LjA4M2MtLjA1MS0xLjI0MS0uOTUyLTIuMjYxLTIuMzYzLTIuMjYxWk0xNTMuNzM3IDI4di04LjU2OGgxLjQ0NXYxLjA3MWMuNjI5LS43NDggMS40MTEtMS4yNDEgMi40OTktMS4yNDEuMjcyIDAgLjUyNy4wMzQuNzMxLjEwMnYxLjQ5NmEzLjEwNSAzLjEwNSAwIDAgMC0uODUtLjExOWMtMS4xMjIgMC0xLjg1My41NzgtMi4zOCAxLjQ0NVYyOGgtMS40NDVabTEzLjY4NS4zNGMtMS42ODMgMC0yLjgyMi0uOTUyLTIuODIyLTIuNDQ4IDAtMS4zMjYuOTg2LTIuMjc4IDIuODIyLTIuNTY3bDIuODczLS40NzZ2LS41OTVjMC0xLjE5LS44NS0xLjg3LTIuMDU3LTEuODctMS4wMDMgMC0xLjgzNi40NDItMi4zMjkgMS4xOWwtMS4wODgtLjgzM2MuNzQ4LTEuMDIgMS45NTUtMS42NDkgMy40NTEtMS42NDkgMi4xNzYgMCAzLjQ2OCAxLjI3NSAzLjQ2OCAzLjE2MlYyOGgtMS40NDV2LTEuMDg4Yy0uNjQ2LjkwMS0xLjcxNyAxLjQyOC0yLjg3MyAxLjQyOFptLTEuMzc3LTIuNDk5YzAgLjczMS42MjkgMS4yOTIgMS42MTUgMS4yOTIgMS4xMzkgMCAyLjA0LS41OTUgMi42MzUtMS41ODFWMjMuOTJsLTIuNTMzLjQ0MmMtMS4xOS4xODctMS43MTcuNzMxLTEuNzE3IDEuNDc5Wm03LjI1Mi02LjQwOWgxLjU2NGwyLjczNyA3LjA1NSAyLjczNy03LjA1NWgxLjU2NEwxNzguNTUgMjhoLTEuOTA0bC0zLjM0OS04LjU2OFptMTcuODU2IDcuMTU3Yy0uNzk5IDEuMDcxLTIuMDc0IDEuNzUxLTMuNjcyIDEuNzUxLTIuODkgMC00LjY3NS0yLjEyNS00LjY3NS00LjYyNCAwLTIuNjAxIDEuNjY2LTQuNjI0IDQuMzE4LTQuNjI0IDIuMzI5IDAgMy44NDIgMS41ODEgMy44NDIgMy43MjMgMCAuMzQtLjA1MS42OC0uMTAyLjkxOGgtNi41NjJ2LjAzNGMwIDEuODg3IDEuMjkyIDMuMjY0IDMuMjEzIDMuMjY0IDEuMDg4IDAgMi4wMDYtLjUxIDIuNTY3LTEuMjc1bDEuMDcxLjgzM1ptLTQuMDEyLTYuMjU2Yy0xLjM5NCAwLTIuMzguNzgyLTIuNzIgMi4yNjFoNS4wODNjLS4wNTEtMS4yNDEtLjk1Mi0yLjI2MS0yLjM2My0yLjI2MVptMTAuMTg1IDYuNjQ3YzEuMDU0IDAgMS45MDQtLjUxIDIuNDMxLTEuMjc1bDEuMTU2Ljg4NGMtLjc5OSAxLjA3MS0yLjA0IDEuNzUxLTMuNjA0IDEuNzUxLTIuODM5IDAtNC42NTgtMi4xMjUtNC42NTgtNC42MjQgMC0yLjQ5OSAxLjgxOS00LjYyNCA0LjY1OC00LjYyNCAxLjU0NyAwIDIuODA1LjY5NyAzLjYwNCAxLjc1MWwtMS4xNTYuODg0YTIuOTI1IDIuOTI1IDAgMCAwLTIuNDQ4LTEuMjc1Yy0xLjgzNiAwLTMuMTQ1IDEuNDQ1LTMuMTQ1IDMuMjY0IDAgMS44MzYgMS4zMDkgMy4yNjQgMy4xNjIgMy4yNjRaTTcwLjg1NCA0NVYzMi40aDQuMTU4YzIuNzcyIDAgNC40NjQgMS40MjIgNC40NjQgMy43NjIgMCAyLjMyMi0xLjY5MiAzLjc0NC00LjQ2NCAzLjc0NEg3My40MVY0NWgtMi41NTZabTQuMjY2LTEwLjQyMmgtMS43MXYzLjE1aDEuNzFjMS4wOCAwIDEuNzI4LS41NzYgMS43MjgtMS42MDIgMC0uOTU0LS42NDgtMS41NDgtMS43MjgtMS41NDhaTTgxLjI0OSA0NXYtOS4wNzJoMi4yODZ2LjljLjU5NC0uNjEyIDEuMzY4LTEuMDggMi4zOTQtMS4wOC4zMDYgMCAuNTc2LjA1NC43OTIuMTI2djIuMzk0YTMuOTM4IDMuOTM4IDAgMCAwLTEuMDA4LS4xMjZjLTEuMTE2IDAtMS44MzYuNjEyLTIuMTc4IDEuMTdWNDVoLTIuMjg2Wm0xMS4zODYtOS40MzJjMi45NTIgMCA0Ljk2OCAyLjE3OCA0Ljk2OCA0Ljg5NnMtMi4wMTYgNC44OTYtNC45NjggNC44OTYtNC45NjgtMi4xNzgtNC45NjgtNC44OTYgMi4wMTYtNC44OTYgNC45NjgtNC44OTZabS4wMzYgNy42MzJjMS40NTggMCAyLjU1Ni0xLjE3IDIuNTU2LTIuNzM2IDAtMS41ODQtMS4wOTgtMi43MzYtMi41NTYtMi43MzYtMS41MTIgMC0yLjYyOCAxLjE1Mi0yLjYyOCAyLjczNiAwIDEuNTg0IDEuMTE2IDIuNzM2IDIuNjI4IDIuNzM2Wm0xMy4xNzItLjIzNGMxLjQ0IDAgMi41NzQtLjcwMiAzLjI5NC0xLjcyOGwyLjAxNiAxLjU0OGMtMS4xNTIgMS41NjYtMy4wMjQgMi41NzQtNS4zMSAyLjU3NC0zLjk3OCAwLTYuNjk2LTMuMDYtNi42OTYtNi42NnMyLjcxOC02LjY2IDYuNjk2LTYuNjZjMi4yODYgMCA0LjE1OCAxLjAyNiA1LjMxIDIuNTU2bC0yLjAxNiAxLjU2NmMtLjcyLTEuMDI2LTEuODU0LTEuNzI4LTMuMjk0LTEuNzI4LTIuMzc2IDAtNC4wNjggMS44NTQtNC4wNjggNC4yNjZzMS42OTIgNC4yNjYgNC4wNjggNC4yNjZabTExLjM2Ni03LjM5OGMyLjk1MiAwIDQuOTY4IDIuMTc4IDQuOTY4IDQuODk2cy0yLjAxNiA0Ljg5Ni00Ljk2OCA0Ljg5Ni00Ljk2OC0yLjE3OC00Ljk2OC00Ljg5NiAyLjAxNi00Ljg5NiA0Ljk2OC00Ljg5NlptLjAzNiA3LjYzMmMxLjQ1OCAwIDIuNTU2LTEuMTcgMi41NTYtMi43MzYgMC0xLjU4NC0xLjA5OC0yLjczNi0yLjU1Ni0yLjczNi0xLjUxMiAwLTIuNjI4IDEuMTUyLTIuNjI4IDIuNzM2IDAgMS41ODQgMS4xMTYgMi43MzYgMi42MjggMi43MzZabTcuMDE4IDEuOHYtOS4wNzJoMi4yODZ2LjcyYy42My0uNjEyIDEuNDc2LTEuMDggMi42ODItMS4wOCAxLjk2MiAwIDMuNTI4IDEuMzUgMy41MjggNC4wMzJWNDVoLTIuMzIydi01LjMxYzAtMS4yMDYtLjY2Ni0xLjk2Mi0xLjc4Mi0xLjk2Mi0xLjE1MiAwLTEuNzY0Ljc3NC0yLjEwNiAxLjM1VjQ1aC0yLjI4NlptMTEuMDkxIDB2LTkuMDcyaDIuMjg2di43MmMuNjMtLjYxMiAxLjQ3Ni0xLjA4IDIuNjgyLTEuMDggMS45NjIgMCAzLjUyOCAxLjM1IDMuNTI4IDQuMDMyVjQ1aC0yLjMyMnYtNS4zMWMwLTEuMjA2LS42NjYtMS45NjItMS43ODItMS45NjItMS4xNTIgMC0xLjc2NC43NzQtMi4xMDYgMS4zNVY0NWgtMi4yODZabTE5LjQ0NC0xLjQ3NmMtLjg0NiAxLjEzNC0yLjI1IDEuODM2LTMuOTYgMS44MzYtMy4yMjIgMC01LjA0LTIuMjUtNS4wNC00Ljg5NiAwLTIuNjgyIDEuNjkyLTQuODk2IDQuNjYyLTQuODk2IDIuNTIgMCA0LjE3NiAxLjY5MiA0LjE3NiA0LjA2OCAwIC41MDQtLjA3Mi45OS0uMTQ0IDEuMjk2aC02LjM1NGMuMTQ0IDEuNDk0IDEuMTg4IDIuMzc2IDIuNzM2IDIuMzc2Ljk5IDAgMS44LS40MzIgMi4yODYtMS4wOGwxLjYzOCAxLjI5NlptLTQuMzM4LTYuMDQ4Yy0xLjExNiAwLTEuODcyLjU0LTIuMTc4IDEuNzI4aDQuMDg2Yy0uMDM2LS45LS43MDItMS43MjgtMS45MDgtMS43MjhabTEwLjY5NiA1LjcyNGMuODgyIDAgMS41ODQtLjQzMiAyLjAxNi0xLjA2MmwxLjgxOCAxLjM4NmMtLjg0NiAxLjExNi0yLjE3OCAxLjgzNi0zLjgzNCAxLjgzNi0zLjEzMiAwLTUuMDA0LTIuMjUtNS4wMDQtNC44OTZzMS44NzItNC44OTYgNS4wMDQtNC44OTZjMS42NTYgMCAyLjk4OC43MiAzLjgzNCAxLjgzNmwtMS44MTggMS4zODZjLS40MzItLjYzLTEuMTE2LTEuMDYyLTIuMDUyLTEuMDYyLTEuNDk0IDAtMi41OTIgMS4xNTItMi41OTIgMi43MzYgMCAxLjYwMiAxLjA5OCAyLjczNiAyLjYyOCAyLjczNlptNi4yMDQtMS41MTJ2LTMuNjcyaC0xLjY5MnYtMi4wODhoMS42OTJWMzMuNjZoMi4zMDR2Mi4yNjhoMi43NzJ2Mi4wODhoLTIuNzcydjMuNjcyYzAgMS4wMDguNTQgMS40MDQgMS40NCAxLjQwNC42MyAwIDEuMDQ0LS4wNzIgMS4zNS0uMTk4djEuOTk4Yy0uNDUuMTk4LS45OS4yODgtMS43NDYuMjg4LTIuMjY4IDAtMy4zNDgtMS4yNzgtMy4zNDgtMy40OTJaJy8+PHBhdGggZmlsbD0nIzAwMDA5MScgZD0nTTQ2Ljk5MiAxOS4wOTggMzEuOTk4IDEwLjQybC0xNC45OTQgOC43NmEuNjA2LjYwNiAwIDAgMC0uMzA2LjUyNXYxNi45NDhhLjY2Ni42NjYgMCAwIDAgLjMwNi41MjRsMTQuOTkyIDguNiAxNC45OTQtOC43MDZhLjY2Ni42NjYgMCAwIDAgLjMwNi0uNTI0VjE5LjYyNmEuNjA0LjYwNCAwIDAgMC0uMzA0LS41MjhaJy8+PHBhdGggZmlsbD0nI0ZDQzYzQScgZD0nbTI2LjY0MSAxOS41OTgtNS4wMjkgOC42MjgtNC41NTctOS4xNzUgNS4zOS0zLjExMyA0LjQ4OSAzLjE2LS4yOTMuNVptMjAuNjU2IDE2Ljk4VjE5LjYyYS42LjYgMCAwIDAtLjMwNi0uNTIzTDMxLjk5OCAxMC40MicvPjxwYXRoIGZpbGw9JyMwMDYzQ0InIGQ9J00xNi43IDM2LjU3OCAzMiAxMC40MnYzNS4zNjJsLTE0Ljk5Ni04LjYwNWEuNjY1LjY2NSAwIDAgMS0uMzA2LS41MjRWMTkuNzA2bC4wMDIgMTYuODcyWm0yNC42NjktMjAuNzM1IDUuNDU4IDMuMTU1LTQuNDg5IDkuMTUtNS4zODctOS4yMzYgNC40MTgtMy4wN1onLz48cGF0aCBmaWxsPScjZmZmJyBkPSdtNTEuNjA2IDE2LjMwMy0xOS4xOS0xMS4wMmEuOTMzLjkzMyAwIDAgMC0uODMyIDBsLTE5LjE5IDExLjAyYS44ODcuODg3IDAgMCAwLS4zOTQuNjk1djIyYS44ODUuODg1IDAgMCAwIC4zOTQuN2wxOS4xODkgMTEuMDJhLjkzMi45MzIgMCAwIDAgLjgzMiAwbDE5LjE5MS0xMS4wMmEuODg2Ljg4NiAwIDAgMCAuMzk0LS43di0yMmEuODg3Ljg4NyAwIDAgMC0uMzk0LS42OTVaTTIyLjc4OSAzNC4wNTloLjA3OWMtLjA0MiAwLS4wNzkuMDA3LS4wNzkuMDUgMCAuMS4xNTEgMCAuMi4xYS45MTIuOTEyIDAgMCAwLS42MjkuMjc2YzAgLjA1LjEuMDUuMTUxLjA1LS4wNzUuMS0uMjI2LjA1LS4yNzcuMTUyYS4xNzYuMTc2IDAgMCAwIC4xLjA1Yy0uMDUgMC0uMSAwLS4xLjA1di4xNTJjLS4xMjYgMC0uMTc2LjEtLjI3Ny4xNS4yLjE1Mi4zMjcgMCAuNTI4IDAtLjUyOC4yLS45NTYuNDc5LTEuNDg0LjYzLS4xIDAgMCAuMTUtLjEuMTUuMTUxLjEuMjI3LS4wNS4zNzctLjA1LS42NTQuMzc4LTEuMzMzLjctMi4wMzcgMS4xMzNhLjM1MS4zNTEgMCAwIDAtLjEuMmgtLjJjLS4xLjA1LS4wNS4xNzYtLjE1MS4yNzcuMjI2LjE1LjUtLjIuNjU0IDAgLjA1IDAtLjEuMDUtLjIuMDUtLjA1IDAtLjA1LjEtLjEuMWgtLjE1NGMtLjEuMDc1LS4yLjEyNi0uMi4yNzZhLjIyLjIyIDAgMCAwLS4yMjYuMSA5LjAzMSA5LjAzMSAwIDAgMCAzLjE0NC0uNTc4IDcuNjgzIDcuNjgzIDAgMCAwIDIuMDg4LTEuNTYuMTc2LjE3NiAwIDAgMSAuMDUuMWMtLjE0Ny40MzctLjQzLjgxNi0uODA2IDEuMDgtLjI3Ny4xNTItLjQ3OC4zNzgtLjcuNDc5YTQuMDU3IDQuMDU3IDAgMCAwLS40MjguMjc2Yy0uNjMyLjE5Ny0xLjI4MS4zMzUtMS45MzkuNDEybC0uMzA1LjA0NGMtLjIyNS4wMzMtLjQ0OS4wNjktLjY3MS4xMDhsLTEuOTkzLTEuMTM4YS42NDcuNjQ3IDAgMCAxLS4yODgtLjQxMS41Ny41NyAwIDAgMCAuMDk0LS4wNjMuMjY2LjI2NiAwIDAgMC0uMTEzLS4wNzF2LS42NWExMi43ODIgMTIuNzgyIDAgMCAwIDMuMDM4LS45NDIgOC43NDYgOC43NDYgMCAwIDAtMy4wMzctMS4zNDN2LTEuNTE1YTExLjY3IDExLjY3IDAgMCAxIDEuNjM5LjM5MiA2LjQyIDYuNDIgMCAwIDEgMS4xODIuNTc4Yy4xNDcuMTQuMzA3LjI2Ny40NzguMzc3YS45MS45MSAwIDAgMCAuOC4wNWguMzNhMy45NjEgMy45NjEgMCAwIDAgMS45MzctLjkwNWMwIC4wNS4wNS4wNS4xLjA1YTMuNjI5IDMuNjI5IDAgMCAxLS40MjggMS4xMzJjLjAwMy4wNS0uMDQ4LjE1Mi4wNTMuMjAyWm0yLjgxNyAzLjU3Yy4yNTEtLjEuNC0uMjc2LjYyOS0uMzc2LS4wNS4wNS0uMDUuMTUtLjEuMmEzLjY5OSAzLjY5OSAwIDAgMC0uNTI4LjQgMTUuOTY1IDE1Ljk2NSAwIDAgMC0xLjU4NSAxLjYxYy0uMjUyLjMtLjUyOC41NzgtLjguODU1LS4wOTYuMDktLjIuMTcyLS4zMS4yNDVsLTIuNTI3LTEuNDVjLjM2LjAzLjcyMS4wMTMgMS4wNzYtLjA1My4yOTQtLjA4My41OC0uMTkyLjg1NS0uMzI3di4xYy43LS4yNzcgMS4yMzItLjkwNiAxLjkzNy0xLjEzMi4wMjUgMCAuMTI2LjEuMjI2LjA1YTEuODgzIDEuODgzIDAgMCAxIDEuNTA5LS43YzAgLjA1IDAgLjEuMDUuMWguMDI1Yy0uMTUxLjEyNi0uMzI3LjI1LS41LjM3Ny0uMDU3LjA1Mi0uMDA3LjEwMi4wNDMuMTAyWm0tOC45MDgtNi4xNjN2LS4xODZhNS44MTcgNS44MTcgMCAwIDEgMS41ODgtLjE4OCAxLjUyIDEuNTIgMCAwIDEgLjQ3OCAwIDUuODYgNS44NiAwIDAgMC0yLjA2Ni4zNzRabTMwLjYgNS4wODhhLjY2NS42NjUgMCAwIDEtLjMwNi41MjRsLTEwLjA3OSA1Ljg1YTMyLjI5NiAzMi4yOTYgMCAwIDEtMy40MDgtMS4xODQgMi44MjYgMi44MjYgMCAwIDEtLjA1LTIuMjQ1Yy4wOC0uMzA4LjE5OC0uNjA1LjM1Mi0uODgzLjAyNS0uMDI1LjA1LS4wNS4wNS0uMDc2YS4wMjUuMDI1IDAgMCAwIC4wMjUtLjAyNSA0LjMyIDQuMzIgMCAwIDEgLjM3Ny0uNTU1bC4wMTUtLjAxNS4wMi0uMDIxLjAxNS0uMDE1YzAtLjAyNS4wMjUtLjA1LjA1LS4wNzYuMDI1LS4wNTEuMDc1LS4wNzYuMS0uMTI2LjE3Ni0uMTg2LjM3LS4zNTQuNTc5LS41LjIxMy0uMDc3LjQzMS0uMTM2LjY1NC0uMTc3LjgxMS4wNiAxLjYxNy4xNyAyLjQxNS4zMjhhLjc1Mi43NTIgMCAwIDEgLjI3Ny4xYy4zMDEuMDU5LjYxMi4wNDEuOTA1LS4wNWExLjEzNyAxLjEzNyAwIDAgMCAuODU1LS43MDYgMS4yMTIgMS4yMTIgMCAwIDAgLjA1LTEuMDZjLS4xNzgtLjI3NS0uMDEzLS40MzYuMTgxLS41OWwuMDY4LS4wNTRjLjA4Ni0uMDYxLjE2NC0uMTM0LjIzMS0uMjE2LjEyNi0uMjUyLS4xLS40LS4xNTEtLjYzLS4wNS0uMS0uMjI2LS4wNS0uMzI3LS4yLjM1Mi0uMTUxLjg1NS0uNDMuNjI5LS44NTctLjE1MS0uMjI3LS4zNzctLjYzLS4xLS44NTcuMzUyLS4yLjg1NS0uMTUxIDEuMDA2LS40OGExLjEzNyAxLjEzNyAwIDAgMC0uMjkyLTEuMDg0bC0uMDc1LS4xMDhhNC43NTQgNC43NTQgMCAwIDEtLjIxMS0uMzIgNi45MDUgNi45MDUgMCAwIDAtLjUyOC0uNzU3IDQuMjk3IDQuMjk3IDAgMCAxLS41MjgtMS4wMWMtLjE1MS0uMzc3LjA1LS43MDUuMDUtMS4wODNhNi4zNDcgNi4zNDcgMCAwIDAtLjMyNy0yLjE0NGMtLjEyNi0uMzUzLS4xNzYtLjczMS0uMzI3LTEuMDZhMS4xMiAxLjEyIDAgMCAwLS4yMjYtLjU4LjM3NC4zNzQgMCAwIDEgMC0uMzI3Yy4yMDUtLjE0NS4zOTktLjMwNS41NzktLjQ4YS41NjcuNTY3IDAgMCAwLS4yLS43MDVjLS4zMjctLjE1MS0uMy4zMjgtLjUyOC40MjloLS4xNTFjLS4wNS0uMTI2LjA1LS4xNzcuMTUxLS4yNzcgMC0uMDUgMC0uMTUxLS4wNS0uMTUxLS4yIDAtLjM3Ny0uMDUxLS40MjgtLjE1MWEzLjk1NyAzLjk1NyAwIDAgMC0xLjg2MS0xLjI4NmMuMTg4LjA1OC4zODIuMDkxLjU3OS4xLjMzOC4wNzEuNjkuMDM2IDEuMDA2LS4xLjIyNy0uMDc2LjI3Ny0uNDguMzc3LS43MDZhLjguOCAwIDAgMC0uMTUxLS42MzEgMi4xOSAyLjE5IDAgMCAwLS45MDYtLjc1NiA5LjEzIDkuMTMgMCAwIDEtLjY3OS0uMzUzLjk1Ni45NTYgMCAwIDAtLjI1MS0uMTI2Yy0yLjk2NS0xLjQ4NS05LjA2OS0uMi05LjUzNCAwaC0uMDA5YTguMjU0IDguMjU0IDAgMCAwLTEuMjQ5LjQ3NSAzLjkyMiAzLjkyMiAwIDAgMC0yLjM2NSAyLjQ2NSAzLjgzIDMuODMgMCAwIDAtMS4zMzMgMS41MDljLS40MjguOC0xLjA1NiAxLjUwOS0uOTU2IDIuNDE0LjEuNzguMjc3IDEuNDg0LjQyOCAyLjI4OS4wNDMuMjcyLjExLjU0LjIuOC4xLjI3NiAwIC42MjkuMTUxLjg1NS4wNzUuMTUuMDI1LjMyNy4yMjcuNDI4di4yYy4wNS4wNS4wNS4xLjE1MS4xdi4yYy40MzUuNDIzLjgwNy45MDYgMS4xMDcgMS40MzQuMS4yNzYtLjQ3OC4xNS0uNy4wNWE1Ljk3NyA1Ljk3NyAwIDAgMS0xLjEzMi0uOTU2LjE3Ni4xNzYgMCAwIDAtLjA1MS4xYy4yLjM1Mi45MDYuNzguNTI4IDEuMDA2LS4yLjEtLjQyOC0uMTUxLS42MjkuMDUtLjA1LjA3NiAwIC4xNzcgMCAuMjc3LS4yNzctLjItLjU3OC0uMS0uODU1LS4yLS4yLS4wNS0uMjUyLS40MjctLjQ3OC0uNDI3YTE1LjE5MSAxNS4xOTEgMCAwIDAtMS44MTEtLjMyNyAxNS4xNDQgMTUuMTQ0IDAgMCAwLTEuNzM5LS4xNlYxOS43MDdhLjYwNi42MDYgMCAwIDEgLjMwNi0uNTI0bDE0Ljk4Ny04Ljc2MSAxNC45OTQgOC42NzdhLjYwNS42MDUgMCAwIDEgLjMwNi41MjR2MTYuOTMyWm0tNy45NTQtOC4yNjFhLjMyNS4zMjUgMCAwIDEtLjI4Mi4xNDkgMi44NCAyLjg0IDAgMCAwLS4yODIuMjczYy4xIDAgMCAuMTQ5LjEuMTQ5LS4yMDUuMjIzLjA3Ny42OTQtLjIwNS43OTMtLjM3LjA5OS0uNzU4LjA5OS0xLjEyNyAwYS43MjcuNzI3IDAgMCAxIC4xNjctLjAxNmguMDg1YS4zODIuMzgyIDAgMCAwIC4zMzctLjEzMnYtLjJjMC0uMDUtLjA1MS0uMDUtLjEtLjA1YS4xNi4xNiAwIDAgMS0uMS4wNS4yMjMuMjIzIDAgMCAwLS4xNTQtLjIuODA2LjgwNiAwIDAgMS0uNzE4LS4yNzMuNjcuNjcgMCAwIDEgLjQzNi0uMDVjLjEyOCAwIC4wNzctLjIyMy4yMzEtLjMyMmguMTU0Yy4zMDctLjM3Mi44NzEtLjQ3MS45NzQtLjg0MyAwLS4xLS4yODItLjEtLjQ4Ny0uMTVhMi4yNiAyLjI2IDAgMCAwLS44Mi4wNWMtLjM2LjA1LS43MTIuMTQyLTEuMDUxLjI3NC4yOC0uMjA2LjU5Mi0uMzY1LjkyMy0uNDcxLjIzMi0uMDkuNDczLS4xNTcuNzE4LS4ybC4xMzItLjAyNi4xMzMtLjAyN2EuOTcuOTcgMCAwIDEgLjU1NiAwYy4yMzEuMS42MTUuMS42NjYuMjQ4LjEuMjczLS4xNTQuNTQ1LS40MzUuNzQ0LS4wNTcuMDguMTQ5LjEzNS4xNDkuMjNaJy8+PHJlY3Qgd2lkdGg9JzI5LjU2JyBoZWlnaHQ9JzEzLjMwMicgeD0nMzcnIHk9JzUnIGZpbGw9JyNGQ0M2M0EnIHJ4PScyJy8+PHBhdGggZmlsbD0nIzE2MTYxNicgZD0nTTM5LjU2MiAxNi4xNjhWNy4zMTZoMi45MjFjLjk3IDAgMS43MzIuMjM2IDIuMjg5LjcwOC41NjUuNDcyLjg0NyAxLjExNy44NDcgMS45MzUgMCAuODEtLjI4MiAxLjQ1LS44NDcgMS45MjItLjU1Ny40NzItMS4zMi43MDgtMi4yODkuNzA4aC0xLjEyNXYzLjU3OWgtMS43OTZabTIuOTk3LTcuMzIyaC0xLjIwMXYyLjIxM2gxLjJjLjM4IDAgLjY3NS0uMDk3Ljg4Ni0uMjkuMjItLjE5NS4zMjktLjQ3My4zMjktLjgzNiAwLS4zMzctLjExLS42MDItLjMyOS0uNzk2LS4yMS0uMTk0LS41MDYtLjI5MS0uODg1LS4yOTFaTTQ3LjIzIDE2LjE2OFY3LjMxNmgyLjcwN2MuOTcgMCAxLjczNi4yMzYgMi4zMDEuNzA4LjU2NS40NzIuODQ3IDEuMTE3Ljg0NyAxLjkzNSAwIC41My0uMTI2Ljk5NS0uMzc5IDEuMzktLjI0NC4zODktLjU5LjY4OC0xLjAzNy44OTlsMi43ODIgMy45MmgtMi4xNWwtMi4zNTItMy41NzloLS45MjN2My41NzloLTEuNzk1Wm0yLjgwOC03LjMyMmgtMS4wMTJ2Mi4yMTNoMS4wMTJjLjM4IDAgLjY3NC0uMDk3Ljg4NS0uMjkuMjEtLjE5NS4zMTYtLjQ3My4zMTYtLjgzNiAwLS4zMzctLjEwNS0uNjAyLS4zMTYtLjc5Ni0uMjEtLjE5NC0uNTA2LS4yOTEtLjg4NS0uMjkxWk01OS41NDkgNy4wNjNjLjY5IDAgMS4zMjMuMTI2IDEuODk2LjM4LjU4Mi4yNTIgMS4wOC41OSAxLjQ5MiAxLjAxMS40MTQuNDIxLjczNC45MTkuOTYyIDEuNDkyLjIyNy41NjUuMzQxIDEuMTY0LjM0MSAxLjc5NiAwIC42MzItLjExNCAxLjIzNS0uMzQxIDEuODA4YTQuNDg1IDQuNDg1IDAgMCAxLS45NjIgMS40OGMtLjQxMy40MjEtLjkxLjc1OC0xLjQ5MiAxLjAxMWE0LjY0OCA0LjY0OCAwIDAgMS0xLjg5Ni4zOCA0LjczOCA0LjczOCAwIDAgMS0zLjQwMi0xLjM5MSA0LjQ4NCA0LjQ4NCAwIDAgMS0uOTYxLTEuNDggNC44NTUgNC44NTUgMCAwIDEtLjM0Mi0xLjgwOGMwLS42MzMuMTE0LTEuMjMxLjM0Mi0xLjc5Ni4yMjctLjU3My41NDgtMS4wNy45NjEtMS40OTIuNDEzLS40MjIuOTEtLjc1OSAxLjQ5Mi0xLjAxMmE0LjczNyA0LjczNyAwIDAgMSAxLjkxLS4zNzlabTAgNy42NzZhMi44IDIuOCAwIDAgMCAxLjEzOC0uMjI4Yy4zNTQtLjE2LjY1My0uMzcuODk4LS42MzIuMjUyLS4yNy40NS0uNTg2LjU5NC0uOTQ5YTMuMjcgMy4yNyAwIDAgMCAuMjE1LTEuMTg4IDMuMTcgMy4xNyAwIDAgMC0uMjE1LTEuMTc2IDIuNzkxIDIuNzkxIDAgMCAwLS41OTUtLjk0OSAyLjU0OCAyLjU0OCAwIDAgMC0uODk3LS42MzIgMi42NzMgMi42NzMgMCAwIDAtMS4xMzgtLjI0Yy0uNDEzIDAtLjc5Ny4wOC0xLjE1MS4yNGEyLjY3OCAyLjY3OCAwIDAgMC0uOTEuNjMyIDIuODk5IDIuODk5IDAgMCAwLS41ODIuOTQ5IDMuMTcgMy4xNyAwIDAgMC0uMjE1IDEuMTc2YzAgLjQyMS4wNzEuODE3LjIxNSAxLjE4OC4xNDMuMzYzLjMzNy42NzkuNTgxLjk0OS4yNTMuMjYxLjU1Ny40NzIuOTEuNjMyLjM1NS4xNTIuNzM5LjIyOCAxLjE1Mi4yMjhaJy8+PC9zdmc+"); + background-position: 50% 50%; + background-repeat: no-repeat; + width: 214px; + height: 56px; + border: none; +} + +.proconnect-button:hover { + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScyMTEnIGhlaWdodD0nNTgnIGZpbGw9J25vbmUnPjxnIGNsaXAtcGF0aD0ndXJsKCNhKSc+PHBhdGggZmlsbD0nIzEyMTJGRicgZD0nTTIxMSAwSDB2NThoMjExVjBaJy8+PHBhdGggZmlsbD0nI2ZmZicgZD0nbTY5Ljk4NiAyNi4zNjggMS4xNTYtMS4wNzFjLjgzMyAxLjA1NCAxLjgxOSAxLjU5OCAyLjk0MSAxLjU5OCAxLjI5MiAwIDIuMDQtLjgxNiAyLjA0LTEuOTA0IDAtMi41NS01LjYyNy0yLjI0NC01LjYyNy02LjAzNSAwLTEuNzM0IDEuNDI4LTMuMTk2IDMuNDUxLTMuMTk2IDEuNjgzIDAgMi45MDcuNzY1IDMuNzkxIDEuOTM4bC0xLjE5IDEuMDM3Yy0uNjk3LTEuMDAzLTEuNTQ3LTEuNTQ3LTIuNTg0LTEuNTQ3LTEuMTA1IDAtMS44MzYuNzQ4LTEuODM2IDEuNzM0IDAgMi41NjcgNS42MjcgMi4yNDQgNS42MjcgNi4wNTIgMCAyLjAyMy0xLjU4MSAzLjM0OS0zLjY1NSAzLjM0OS0xLjc2OCAwLTMuMDc3LS42NjMtNC4xMTQtMS45NTVabTEwLjgxNy01LjcxMkg3OS40NmwxLjQ0NS00LjU1NmgxLjY0OWwtMS43NTEgNC41NTZabTQuODE4LTMuNDUxYy0uNTYgMC0xLjAyLS40NTktMS4wMi0xLjAyYTEuMDIgMS4wMiAwIDAgMSAxLjAyLTEuMDAzYy41NjEgMCAxLjAwMy40NTkgMS4wMDMgMS4wMDMgMCAuNTYxLS40NDIgMS4wMi0xLjAwMyAxLjAyWk04NC44OTEgMjh2LTguNTY4aDEuNDQ0VjI4SDg0Ljg5Wm0zLjc2Ny00LjI4NGMwLTIuNDk5IDEuNzE3LTQuNjI0IDQuNDAzLTQuNjI0IDEuMjQxIDAgMi4yNjEuNDU5IDMuMDQzIDEuMjkyVjE1LjI1aDEuNDQ1VjI4aC0xLjQ0NXYtLjk1MmMtLjc4Mi44MzMtMS44MDIgMS4yOTItMy4wNDMgMS4yOTItMi42ODYgMC00LjQwMy0yLjEyNS00LjQwMy00LjYyNFptMS41MyAwYzAgMS44MTkgMS4yMjQgMy4yNjQgMy4wNDMgMy4yNjQgMS4xOSAwIDIuMjEtLjU3OCAyLjg3My0xLjU5OFYyMi4wNWMtLjY4LTEuMDM3LTEuNy0xLjU5OC0yLjg3My0xLjU5OC0xLjgxOSAwLTMuMDQzIDEuNDQ1LTMuMDQzIDMuMjY0Wm0xOC4wMjMgMi44NzNjLS43OTkgMS4wNzEtMi4wNzQgMS43NTEtMy42NzIgMS43NTEtMi44OSAwLTQuNjc1LTIuMTI1LTQuNjc1LTQuNjI0IDAtMi42MDEgMS42NjYtNC42MjQgNC4zMTgtNC42MjQgMi4zMjkgMCAzLjg0MiAxLjU4MSAzLjg0MiAzLjcyMyAwIC4zNC0uMDUxLjY4LS4xMDIuOTE4aC02LjU2MnYuMDM0YzAgMS44ODcgMS4yOTIgMy4yNjQgMy4yMTMgMy4yNjQgMS4wODggMCAyLjAwNi0uNTEgMi41NjctMS4yNzVsMS4wNzEuODMzWm0tNC4wMTItNi4yNTZjLTEuMzk0IDAtMi4zOC43ODItMi43MiAyLjI2MWg1LjA4M2MtLjA1MS0xLjI0MS0uOTUyLTIuMjYxLTIuMzYzLTIuMjYxWk0xMTAuNDczIDI4di04LjU2OGgxLjQ0NXYuOTY5Yy42OTctLjc2NSAxLjU4MS0xLjMwOSAyLjg1Ni0xLjMwOSAxLjkyMSAwIDMuMzQ5IDEuMjkyIDMuMzQ5IDMuNzIzVjI4aC0xLjQ2MnYtNS4xMzRjMC0xLjUzLS44NS0yLjQxNC0yLjE3Ni0yLjQxNC0xLjI0MSAwLTIuMDIzLjcxNC0yLjU2NyAxLjYxNVYyOGgtMS40NDVabTExLjA1Mi0yLjg3M3YtNC4zNjloLTEuNjE1di0xLjMyNmgxLjYxNVYxNy4yOWgxLjQ2MnYyLjE0MmgyLjk3NXYxLjMyNmgtMi45NzV2NC4zNjljMCAxLjM0My42OCAxLjcxNyAxLjcxNyAxLjcxNy41NjEgMCAuOTUyLS4wNjggMS4yNzUtLjIwNHYxLjI5MmMtLjQwOC4xNy0uODY3LjIzOC0xLjQ3OS4yMzgtMS45MDQgMC0yLjk3NS0uOTUyLTIuOTc1LTMuMDQzWm03LjM3Ny03LjkyMmMtLjU2MSAwLTEuMDItLjQ1OS0xLjAyLTEuMDJhMS4wMiAxLjAyIDAgMCAxIDEuMDItMS4wMDNjLjU2MSAwIDEuMDAzLjQ1OSAxLjAwMyAxLjAwMyAwIC41NjEtLjQ0MiAxLjAyLTEuMDAzIDEuMDJaTTEyOC4xNzEgMjh2LTguNTY4aDEuNDQ1VjI4aC0xLjQ0NVptMy4zNzctOC41NjhoMS42MTV2LTEuMDU0YzAtMS44MzYgMS4yMDctMy4xMjggMy4wNDMtMy4xMjguOTUyIDAgMS43LjM0IDIuMjEuODMzbC0uOTAxIDEuMDU0YTEuNjMzIDEuNjMzIDAgMCAwLTEuMjkyLS41NzhjLS45MzUgMC0xLjU5OC42OC0xLjU5OCAxLjc4NXYxLjA4OGgyLjk3NXYxLjMyNmgtMi45NzVWMjhoLTEuNDYydi03LjI0MmgtMS42MTV2LTEuMzI2Wm04LjU0My0yLjIyN2MtLjU2MSAwLTEuMDItLjQ1OS0xLjAyLTEuMDJhMS4wMiAxLjAyIDAgMCAxIDEuMDItMS4wMDNjLjU2MSAwIDEuMDAzLjQ1OSAxLjAwMyAxLjAwMyAwIC41NjEtLjQ0MiAxLjAyLTEuMDAzIDEuMDJaTTEzOS4zNiAyOHYtOC41NjhoMS40NDVWMjhoLTEuNDQ1Wm0xMi4xMTUtMS40MTFjLS43OTkgMS4wNzEtMi4wNzQgMS43NTEtMy42NzIgMS43NTEtMi44OSAwLTQuNjc1LTIuMTI1LTQuNjc1LTQuNjI0IDAtMi42MDEgMS42NjYtNC42MjQgNC4zMTgtNC42MjQgMi4zMjkgMCAzLjg0MiAxLjU4MSAzLjg0MiAzLjcyMyAwIC4zNC0uMDUxLjY4LS4xMDIuOTE4aC02LjU2MnYuMDM0YzAgMS44ODcgMS4yOTIgMy4yNjQgMy4yMTMgMy4yNjQgMS4wODggMCAyLjAwNi0uNTEgMi41NjctMS4yNzVsMS4wNzEuODMzWm0tNC4wMTItNi4yNTZjLTEuMzk0IDAtMi4zOC43ODItMi43MiAyLjI2MWg1LjA4M2MtLjA1MS0xLjI0MS0uOTUyLTIuMjYxLTIuMzYzLTIuMjYxWk0xNTMuNzM3IDI4di04LjU2OGgxLjQ0NXYxLjA3MWMuNjI5LS43NDggMS40MTEtMS4yNDEgMi40OTktMS4yNDEuMjcyIDAgLjUyNy4wMzQuNzMxLjEwMnYxLjQ5NmEzLjEwNSAzLjEwNSAwIDAgMC0uODUtLjExOWMtMS4xMjIgMC0xLjg1My41NzgtMi4zOCAxLjQ0NVYyOGgtMS40NDVabTEzLjY4NS4zNGMtMS42ODMgMC0yLjgyMi0uOTUyLTIuODIyLTIuNDQ4IDAtMS4zMjYuOTg2LTIuMjc4IDIuODIyLTIuNTY3bDIuODczLS40NzZ2LS41OTVjMC0xLjE5LS44NS0xLjg3LTIuMDU3LTEuODctMS4wMDMgMC0xLjgzNi40NDItMi4zMjkgMS4xOWwtMS4wODgtLjgzM2MuNzQ4LTEuMDIgMS45NTUtMS42NDkgMy40NTEtMS42NDkgMi4xNzYgMCAzLjQ2OCAxLjI3NSAzLjQ2OCAzLjE2MlYyOGgtMS40NDV2LTEuMDg4Yy0uNjQ2LjkwMS0xLjcxNyAxLjQyOC0yLjg3MyAxLjQyOFptLTEuMzc3LTIuNDk5YzAgLjczMS42MjkgMS4yOTIgMS42MTUgMS4yOTIgMS4xMzkgMCAyLjA0LS41OTUgMi42MzUtMS41ODFWMjMuOTJsLTIuNTMzLjQ0MmMtMS4xOS4xODctMS43MTcuNzMxLTEuNzE3IDEuNDc5Wm03LjI1Mi02LjQwOWgxLjU2NGwyLjczNyA3LjA1NSAyLjczNy03LjA1NWgxLjU2NEwxNzguNTUgMjhoLTEuOTA0bC0zLjM0OS04LjU2OFptMTcuODU2IDcuMTU3Yy0uNzk5IDEuMDcxLTIuMDc0IDEuNzUxLTMuNjcyIDEuNzUxLTIuODkgMC00LjY3NS0yLjEyNS00LjY3NS00LjYyNCAwLTIuNjAxIDEuNjY2LTQuNjI0IDQuMzE4LTQuNjI0IDIuMzI5IDAgMy44NDIgMS41ODEgMy44NDIgMy43MjMgMCAuMzQtLjA1MS42OC0uMTAyLjkxOGgtNi41NjJ2LjAzNGMwIDEuODg3IDEuMjkyIDMuMjY0IDMuMjEzIDMuMjY0IDEuMDg4IDAgMi4wMDYtLjUxIDIuNTY3LTEuMjc1bDEuMDcxLjgzM1ptLTQuMDEyLTYuMjU2Yy0xLjM5NCAwLTIuMzguNzgyLTIuNzIgMi4yNjFoNS4wODNjLS4wNTEtMS4yNDEtLjk1Mi0yLjI2MS0yLjM2My0yLjI2MVptMTAuMTg1IDYuNjQ3YzEuMDU0IDAgMS45MDQtLjUxIDIuNDMxLTEuMjc1bDEuMTU2Ljg4NGMtLjc5OSAxLjA3MS0yLjA0IDEuNzUxLTMuNjA0IDEuNzUxLTIuODM5IDAtNC42NTgtMi4xMjUtNC42NTgtNC42MjQgMC0yLjQ5OSAxLjgxOS00LjYyNCA0LjY1OC00LjYyNCAxLjU0NyAwIDIuODA1LjY5NyAzLjYwNCAxLjc1MWwtMS4xNTYuODg0YTIuOTI1IDIuOTI1IDAgMCAwLTIuNDQ4LTEuMjc1Yy0xLjgzNiAwLTMuMTQ1IDEuNDQ1LTMuMTQ1IDMuMjY0IDAgMS44MzYgMS4zMDkgMy4yNjQgMy4xNjIgMy4yNjRaTTcwLjg1NCA0NVYzMi40aDQuMTU4YzIuNzcyIDAgNC40NjQgMS40MjIgNC40NjQgMy43NjIgMCAyLjMyMi0xLjY5MiAzLjc0NC00LjQ2NCAzLjc0NEg3My40MVY0NWgtMi41NTZabTQuMjY2LTEwLjQyMmgtMS43MXYzLjE1aDEuNzFjMS4wOCAwIDEuNzI4LS41NzYgMS43MjgtMS42MDIgMC0uOTU0LS42NDgtMS41NDgtMS43MjgtMS41NDhaTTgxLjI0OSA0NXYtOS4wNzJoMi4yODZ2LjljLjU5NC0uNjEyIDEuMzY4LTEuMDggMi4zOTQtMS4wOC4zMDYgMCAuNTc2LjA1NC43OTIuMTI2djIuMzk0YTMuOTM4IDMuOTM4IDAgMCAwLTEuMDA4LS4xMjZjLTEuMTE2IDAtMS44MzYuNjEyLTIuMTc4IDEuMTdWNDVoLTIuMjg2Wm0xMS4zODYtOS40MzJjMi45NTIgMCA0Ljk2OCAyLjE3OCA0Ljk2OCA0Ljg5NnMtMi4wMTYgNC44OTYtNC45NjggNC44OTYtNC45NjgtMi4xNzgtNC45NjgtNC44OTYgMi4wMTYtNC44OTYgNC45NjgtNC44OTZabS4wMzYgNy42MzJjMS40NTggMCAyLjU1Ni0xLjE3IDIuNTU2LTIuNzM2IDAtMS41ODQtMS4wOTgtMi43MzYtMi41NTYtMi43MzYtMS41MTIgMC0yLjYyOCAxLjE1Mi0yLjYyOCAyLjczNiAwIDEuNTg0IDEuMTE2IDIuNzM2IDIuNjI4IDIuNzM2Wm0xMy4xNzItLjIzNGMxLjQ0IDAgMi41NzQtLjcwMiAzLjI5NC0xLjcyOGwyLjAxNiAxLjU0OGMtMS4xNTIgMS41NjYtMy4wMjQgMi41NzQtNS4zMSAyLjU3NC0zLjk3OCAwLTYuNjk2LTMuMDYtNi42OTYtNi42NnMyLjcxOC02LjY2IDYuNjk2LTYuNjZjMi4yODYgMCA0LjE1OCAxLjAyNiA1LjMxIDIuNTU2bC0yLjAxNiAxLjU2NmMtLjcyLTEuMDI2LTEuODU0LTEuNzI4LTMuMjk0LTEuNzI4LTIuMzc2IDAtNC4wNjggMS44NTQtNC4wNjggNC4yNjZzMS42OTIgNC4yNjYgNC4wNjggNC4yNjZabTExLjM2Ni03LjM5OGMyLjk1MiAwIDQuOTY4IDIuMTc4IDQuOTY4IDQuODk2cy0yLjAxNiA0Ljg5Ni00Ljk2OCA0Ljg5Ni00Ljk2OC0yLjE3OC00Ljk2OC00Ljg5NiAyLjAxNi00Ljg5NiA0Ljk2OC00Ljg5NlptLjAzNiA3LjYzMmMxLjQ1OCAwIDIuNTU2LTEuMTcgMi41NTYtMi43MzYgMC0xLjU4NC0xLjA5OC0yLjczNi0yLjU1Ni0yLjczNi0xLjUxMiAwLTIuNjI4IDEuMTUyLTIuNjI4IDIuNzM2IDAgMS41ODQgMS4xMTYgMi43MzYgMi42MjggMi43MzZabTcuMDE4IDEuOHYtOS4wNzJoMi4yODZ2LjcyYy42My0uNjEyIDEuNDc2LTEuMDggMi42ODItMS4wOCAxLjk2MiAwIDMuNTI4IDEuMzUgMy41MjggNC4wMzJWNDVoLTIuMzIydi01LjMxYzAtMS4yMDYtLjY2Ni0xLjk2Mi0xLjc4Mi0xLjk2Mi0xLjE1MiAwLTEuNzY0Ljc3NC0yLjEwNiAxLjM1VjQ1aC0yLjI4NlptMTEuMDkxIDB2LTkuMDcyaDIuMjg2di43MmMuNjMtLjYxMiAxLjQ3Ni0xLjA4IDIuNjgyLTEuMDggMS45NjIgMCAzLjUyOCAxLjM1IDMuNTI4IDQuMDMyVjQ1aC0yLjMyMnYtNS4zMWMwLTEuMjA2LS42NjYtMS45NjItMS43ODItMS45NjItMS4xNTIgMC0xLjc2NC43NzQtMi4xMDYgMS4zNVY0NWgtMi4yODZabTE5LjQ0NC0xLjQ3NmMtLjg0NiAxLjEzNC0yLjI1IDEuODM2LTMuOTYgMS44MzYtMy4yMjIgMC01LjA0LTIuMjUtNS4wNC00Ljg5NiAwLTIuNjgyIDEuNjkyLTQuODk2IDQuNjYyLTQuODk2IDIuNTIgMCA0LjE3NiAxLjY5MiA0LjE3NiA0LjA2OCAwIC41MDQtLjA3Mi45OS0uMTQ0IDEuMjk2aC02LjM1NGMuMTQ0IDEuNDk0IDEuMTg4IDIuMzc2IDIuNzM2IDIuMzc2Ljk5IDAgMS44LS40MzIgMi4yODYtMS4wOGwxLjYzOCAxLjI5NlptLTQuMzM4LTYuMDQ4Yy0xLjExNiAwLTEuODcyLjU0LTIuMTc4IDEuNzI4aDQuMDg2Yy0uMDM2LS45LS43MDItMS43MjgtMS45MDgtMS43MjhabTEwLjY5NiA1LjcyNGMuODgyIDAgMS41ODQtLjQzMiAyLjAxNi0xLjA2MmwxLjgxOCAxLjM4NmMtLjg0NiAxLjExNi0yLjE3OCAxLjgzNi0zLjgzNCAxLjgzNi0zLjEzMiAwLTUuMDA0LTIuMjUtNS4wMDQtNC44OTZzMS44NzItNC44OTYgNS4wMDQtNC44OTZjMS42NTYgMCAyLjk4OC43MiAzLjgzNCAxLjgzNmwtMS44MTggMS4zODZjLS40MzItLjYzLTEuMTE2LTEuMDYyLTIuMDUyLTEuMDYyLTEuNDk0IDAtMi41OTIgMS4xNTItMi41OTIgMi43MzYgMCAxLjYwMiAxLjA5OCAyLjczNiAyLjYyOCAyLjczNlptNi4yMDQtMS41MTJ2LTMuNjcyaC0xLjY5MnYtMi4wODhoMS42OTJWMzMuNjZoMi4zMDR2Mi4yNjhoMi43NzJ2Mi4wODhoLTIuNzcydjMuNjcyYzAgMS4wMDguNTQgMS40MDQgMS40NCAxLjQwNC42MyAwIDEuMDQ0LS4wNzIgMS4zNS0uMTk4djEuOTk4Yy0uNDUuMTk4LS45OS4yODgtMS43NDYuMjg4LTIuMjY4IDAtMy4zNDgtMS4yNzgtMy4zNDgtMy40OTJaJy8+PHBhdGggZmlsbD0nIzAwMDA5MScgZD0nTTQ2Ljk5MiAxOS4wOTggMzEuOTk4IDEwLjQybC0xNC45OTQgOC43NmEuNjA2LjYwNiAwIDAgMC0uMzA2LjUyNXYxNi45NDhhLjY2Ni42NjYgMCAwIDAgLjMwNi41MjRsMTQuOTkyIDguNiAxNC45OTQtOC43MDZhLjY2Ni42NjYgMCAwIDAgLjMwNi0uNTI0VjE5LjYyNmEuNjA0LjYwNCAwIDAgMC0uMzA0LS41MjhaJy8+PHBhdGggZmlsbD0nI0ZDQzYzQScgZD0nbTI2LjY0MSAxOS41OTgtNS4wMjkgOC42MjgtNC41NTctOS4xNzUgNS4zOS0zLjExMyA0LjQ4OSAzLjE2LS4yOTMuNVptMjAuNjU2IDE2Ljk4VjE5LjYyYS42LjYgMCAwIDAtLjMwNi0uNTIzTDMxLjk5OCAxMC40MicvPjxwYXRoIGZpbGw9JyMwMDYzQ0InIGQ9J00xNi43IDM2LjU3OCAzMiAxMC40MnYzNS4zNjJsLTE0Ljk5Ni04LjYwNWEuNjY1LjY2NSAwIDAgMS0uMzA2LS41MjRWMTkuNzA2bC4wMDIgMTYuODcyWm0yNC42NjktMjAuNzM1IDUuNDU4IDMuMTU1LTQuNDg5IDkuMTUtNS4zODctOS4yMzYgNC40MTgtMy4wN1onLz48cGF0aCBmaWxsPScjZmZmJyBkPSdtNTEuNjA2IDE2LjMwMy0xOS4xOS0xMS4wMmEuOTMzLjkzMyAwIDAgMC0uODMyIDBsLTE5LjE5IDExLjAyYS44ODcuODg3IDAgMCAwLS4zOTQuNjk1djIyYS44ODUuODg1IDAgMCAwIC4zOTQuN2wxOS4xODkgMTEuMDJhLjkzMi45MzIgMCAwIDAgLjgzMiAwbDE5LjE5MS0xMS4wMmEuODg2Ljg4NiAwIDAgMCAuMzk0LS43di0yMmEuODg3Ljg4NyAwIDAgMC0uMzk0LS42OTVaTTIyLjc4OSAzNC4wNTloLjA3OWMtLjA0MiAwLS4wNzkuMDA3LS4wNzkuMDUgMCAuMS4xNTEgMCAuMi4xYS45MTIuOTEyIDAgMCAwLS42MjkuMjc2YzAgLjA1LjEuMDUuMTUxLjA1LS4wNzUuMS0uMjI2LjA1LS4yNzcuMTUyYS4xNzYuMTc2IDAgMCAwIC4xLjA1Yy0uMDUgMC0uMSAwLS4xLjA1di4xNTJjLS4xMjYgMC0uMTc2LjEtLjI3Ny4xNS4yLjE1Mi4zMjcgMCAuNTI4IDAtLjUyOC4yLS45NTYuNDc5LTEuNDg0LjYzLS4xIDAgMCAuMTUtLjEuMTUuMTUxLjEuMjI3LS4wNS4zNzctLjA1LS42NTQuMzc4LTEuMzMzLjctMi4wMzcgMS4xMzNhLjM1MS4zNTEgMCAwIDAtLjEuMmgtLjJjLS4xLjA1LS4wNS4xNzYtLjE1MS4yNzcuMjI2LjE1LjUtLjIuNjU0IDAgLjA1IDAtLjEuMDUtLjIuMDUtLjA1IDAtLjA1LjEtLjEuMWgtLjE1NGMtLjEuMDc1LS4yLjEyNi0uMi4yNzZhLjIyLjIyIDAgMCAwLS4yMjYuMSA5LjAzMSA5LjAzMSAwIDAgMCAzLjE0NC0uNTc4IDcuNjgzIDcuNjgzIDAgMCAwIDIuMDg4LTEuNTYuMTc2LjE3NiAwIDAgMSAuMDUuMWMtLjE0Ny40MzctLjQzLjgxNi0uODA2IDEuMDgtLjI3Ny4xNTItLjQ3OC4zNzgtLjcuNDc5YTQuMDU3IDQuMDU3IDAgMCAwLS40MjguMjc2Yy0uNjMyLjE5Ny0xLjI4MS4zMzUtMS45MzkuNDEybC0uMzA1LjA0NGMtLjIyNS4wMzMtLjQ0OS4wNjktLjY3MS4xMDhsLTEuOTkzLTEuMTM4YS42NDcuNjQ3IDAgMCAxLS4yODgtLjQxMS41Ny41NyAwIDAgMCAuMDk0LS4wNjMuMjY2LjI2NiAwIDAgMC0uMTEzLS4wNzF2LS42NWExMi43ODIgMTIuNzgyIDAgMCAwIDMuMDM4LS45NDIgOC43NDYgOC43NDYgMCAwIDAtMy4wMzctMS4zNDN2LTEuNTE1YTExLjY3IDExLjY3IDAgMCAxIDEuNjM5LjM5MiA2LjQyIDYuNDIgMCAwIDEgMS4xODIuNTc4Yy4xNDcuMTQuMzA3LjI2Ny40NzguMzc3YS45MS45MSAwIDAgMCAuOC4wNWguMzNhMy45NjEgMy45NjEgMCAwIDAgMS45MzctLjkwNWMwIC4wNS4wNS4wNS4xLjA1YTMuNjI5IDMuNjI5IDAgMCAxLS40MjggMS4xMzJjLjAwMy4wNS0uMDQ4LjE1Mi4wNTMuMjAyWm0yLjgxNyAzLjU3Yy4yNTEtLjEuNC0uMjc2LjYyOS0uMzc2LS4wNS4wNS0uMDUuMTUtLjEuMmEzLjY5OSAzLjY5OSAwIDAgMC0uNTI4LjQgMTUuOTY1IDE1Ljk2NSAwIDAgMC0xLjU4NSAxLjYxYy0uMjUyLjMtLjUyOC41NzgtLjguODU1LS4wOTYuMDktLjIuMTcyLS4zMS4yNDVsLTIuNTI3LTEuNDVjLjM2LjAzLjcyMS4wMTMgMS4wNzYtLjA1My4yOTQtLjA4My41OC0uMTkyLjg1NS0uMzI3di4xYy43LS4yNzcgMS4yMzItLjkwNiAxLjkzNy0xLjEzMi4wMjUgMCAuMTI2LjEuMjI2LjA1YTEuODgzIDEuODgzIDAgMCAxIDEuNTA5LS43YzAgLjA1IDAgLjEuMDUuMWguMDI1Yy0uMTUxLjEyNi0uMzI3LjI1LS41LjM3Ny0uMDU3LjA1Mi0uMDA3LjEwMi4wNDMuMTAyWm0tOC45MDgtNi4xNjN2LS4xODZhNS44MTcgNS44MTcgMCAwIDEgMS41ODgtLjE4OCAxLjUyIDEuNTIgMCAwIDEgLjQ3OCAwIDUuODYgNS44NiAwIDAgMC0yLjA2Ni4zNzRabTMwLjYgNS4wODhhLjY2NS42NjUgMCAwIDEtLjMwNi41MjRsLTEwLjA3OSA1Ljg1YTMyLjI5NiAzMi4yOTYgMCAwIDEtMy40MDgtMS4xODQgMi44MjYgMi44MjYgMCAwIDEtLjA1LTIuMjQ1Yy4wOC0uMzA4LjE5OC0uNjA1LjM1Mi0uODgzLjAyNS0uMDI1LjA1LS4wNS4wNS0uMDc2YS4wMjUuMDI1IDAgMCAwIC4wMjUtLjAyNSA0LjMyIDQuMzIgMCAwIDEgLjM3Ny0uNTU1bC4wMTUtLjAxNS4wMi0uMDIxLjAxNS0uMDE1YzAtLjAyNS4wMjUtLjA1LjA1LS4wNzYuMDI1LS4wNTEuMDc1LS4wNzYuMS0uMTI2LjE3Ni0uMTg2LjM3LS4zNTQuNTc5LS41LjIxMy0uMDc3LjQzMS0uMTM2LjY1NC0uMTc3LjgxMS4wNiAxLjYxNy4xNyAyLjQxNS4zMjhhLjc1Mi43NTIgMCAwIDEgLjI3Ny4xYy4zMDEuMDU5LjYxMi4wNDEuOTA1LS4wNWExLjEzNyAxLjEzNyAwIDAgMCAuODU1LS43MDYgMS4yMTIgMS4yMTIgMCAwIDAgLjA1LTEuMDZjLS4xNzgtLjI3NS0uMDEzLS40MzYuMTgxLS41OWwuMDY4LS4wNTRjLjA4Ni0uMDYxLjE2NC0uMTM0LjIzMS0uMjE2LjEyNi0uMjUyLS4xLS40LS4xNTEtLjYzLS4wNS0uMS0uMjI2LS4wNS0uMzI3LS4yLjM1Mi0uMTUxLjg1NS0uNDMuNjI5LS44NTctLjE1MS0uMjI3LS4zNzctLjYzLS4xLS44NTcuMzUyLS4yLjg1NS0uMTUxIDEuMDA2LS40OGExLjEzNyAxLjEzNyAwIDAgMC0uMjkyLTEuMDg0bC0uMDc1LS4xMDhhNC43NTQgNC43NTQgMCAwIDEtLjIxMS0uMzIgNi45MDUgNi45MDUgMCAwIDAtLjUyOC0uNzU3IDQuMjk3IDQuMjk3IDAgMCAxLS41MjgtMS4wMWMtLjE1MS0uMzc3LjA1LS43MDUuMDUtMS4wODNhNi4zNDcgNi4zNDcgMCAwIDAtLjMyNy0yLjE0NGMtLjEyNi0uMzUzLS4xNzYtLjczMS0uMzI3LTEuMDZhMS4xMiAxLjEyIDAgMCAwLS4yMjYtLjU4LjM3NC4zNzQgMCAwIDEgMC0uMzI3Yy4yMDUtLjE0NS4zOTktLjMwNS41NzktLjQ4YS41NjcuNTY3IDAgMCAwLS4yLS43MDVjLS4zMjctLjE1MS0uMy4zMjgtLjUyOC40MjloLS4xNTFjLS4wNS0uMTI2LjA1LS4xNzcuMTUxLS4yNzcgMC0uMDUgMC0uMTUxLS4wNS0uMTUxLS4yIDAtLjM3Ny0uMDUxLS40MjgtLjE1MWEzLjk1NyAzLjk1NyAwIDAgMC0xLjg2MS0xLjI4NmMuMTg4LjA1OC4zODIuMDkxLjU3OS4xLjMzOC4wNzEuNjkuMDM2IDEuMDA2LS4xLjIyNy0uMDc2LjI3Ny0uNDguMzc3LS43MDZhLjguOCAwIDAgMC0uMTUxLS42MzEgMi4xOSAyLjE5IDAgMCAwLS45MDYtLjc1NiA5LjEzIDkuMTMgMCAwIDEtLjY3OS0uMzUzLjk1Ni45NTYgMCAwIDAtLjI1MS0uMTI2Yy0yLjk2NS0xLjQ4NS05LjA2OS0uMi05LjUzNCAwaC0uMDA5YTguMjU0IDguMjU0IDAgMCAwLTEuMjQ5LjQ3NSAzLjkyMiAzLjkyMiAwIDAgMC0yLjM2NSAyLjQ2NSAzLjgzIDMuODMgMCAwIDAtMS4zMzMgMS41MDljLS40MjguOC0xLjA1NiAxLjUwOS0uOTU2IDIuNDE0LjEuNzguMjc3IDEuNDg0LjQyOCAyLjI4OS4wNDMuMjcyLjExLjU0LjIuOC4xLjI3NiAwIC42MjkuMTUxLjg1NS4wNzUuMTUuMDI1LjMyNy4yMjcuNDI4di4yYy4wNS4wNS4wNS4xLjE1MS4xdi4yYy40MzUuNDIzLjgwNy45MDYgMS4xMDcgMS40MzQuMS4yNzYtLjQ3OC4xNS0uNy4wNWE1Ljk3NyA1Ljk3NyAwIDAgMS0xLjEzMi0uOTU2LjE3Ni4xNzYgMCAwIDAtLjA1MS4xYy4yLjM1Mi45MDYuNzguNTI4IDEuMDA2LS4yLjEtLjQyOC0uMTUxLS42MjkuMDUtLjA1LjA3NiAwIC4xNzcgMCAuMjc3LS4yNzctLjItLjU3OC0uMS0uODU1LS4yLS4yLS4wNS0uMjUyLS40MjctLjQ3OC0uNDI3YTE1LjE5MSAxNS4xOTEgMCAwIDAtMS44MTEtLjMyNyAxNS4xNDQgMTUuMTQ0IDAgMCAwLTEuNzM5LS4xNlYxOS43MDdhLjYwNi42MDYgMCAwIDEgLjMwNi0uNTI0bDE0Ljk4Ny04Ljc2MSAxNC45OTQgOC42NzdhLjYwNS42MDUgMCAwIDEgLjMwNi41MjR2MTYuOTMyWm0tNy45NTQtOC4yNjFhLjMyNS4zMjUgMCAwIDEtLjI4Mi4xNDkgMi44NCAyLjg0IDAgMCAwLS4yODIuMjczYy4xIDAgMCAuMTQ5LjEuMTQ5LS4yMDUuMjIzLjA3Ny42OTQtLjIwNS43OTMtLjM3LjA5OS0uNzU4LjA5OS0xLjEyNyAwYS43MjcuNzI3IDAgMCAxIC4xNjctLjAxNmguMDg1YS4zODIuMzgyIDAgMCAwIC4zMzctLjEzMnYtLjJjMC0uMDUtLjA1MS0uMDUtLjEtLjA1YS4xNi4xNiAwIDAgMS0uMS4wNS4yMjMuMjIzIDAgMCAwLS4xNTQtLjIuODA2LjgwNiAwIDAgMS0uNzE4LS4yNzMuNjcuNjcgMCAwIDEgLjQzNi0uMDVjLjEyOCAwIC4wNzctLjIyMy4yMzEtLjMyMmguMTU0Yy4zMDctLjM3Mi44NzEtLjQ3MS45NzQtLjg0MyAwLS4xLS4yODItLjEtLjQ4Ny0uMTVhMi4yNiAyLjI2IDAgMCAwLS44Mi4wNWMtLjM2LjA1LS43MTIuMTQyLTEuMDUxLjI3NC4yOC0uMjA2LjU5Mi0uMzY1LjkyMy0uNDcxLjIzMi0uMDkuNDczLS4xNTcuNzE4LS4ybC4xMzItLjAyNi4xMzMtLjAyN2EuOTcuOTcgMCAwIDEgLjU1NiAwYy4yMzEuMS42MTUuMS42NjYuMjQ4LjEuMjczLS4xNTQuNTQ1LS40MzUuNzQ0LS4wNTcuMDguMTQ5LjEzNS4xNDkuMjNaJy8+PHBhdGggZmlsbD0nI0ZDQzYzQScgZD0nTTY0LjU2IDVIMzlhMiAyIDAgMCAwLTIgMnY5LjMwMmEyIDIgMCAwIDAgMiAyaDI1LjU2YTIgMiAwIDAgMCAyLTJWN2EyIDIgMCAwIDAtMi0yWicvPjxwYXRoIGZpbGw9JyMxNjE2MTYnIGQ9J00zOS41NjIgMTYuMTY4VjcuMzE2aDIuOTIxYy45NyAwIDEuNzMyLjIzNiAyLjI4OS43MDguNTY1LjQ3Mi44NDcgMS4xMTcuODQ3IDEuOTM1IDAgLjgxLS4yODIgMS40NS0uODQ3IDEuOTIyLS41NTcuNDcyLTEuMzIuNzA4LTIuMjg5LjcwOGgtMS4xMjV2My41NzloLTEuNzk2Wm0yLjk5Ny03LjMyMmgtMS4yMDF2Mi4yMTNoMS4yYy4zOCAwIC42NzUtLjA5Ny44ODYtLjI5LjIyLS4xOTUuMzI5LS40NzMuMzI5LS44MzYgMC0uMzM3LS4xMS0uNjAyLS4zMjktLjc5Ni0uMjEtLjE5NC0uNTA2LS4yOTEtLjg4NS0uMjkxWk00Ny4yMyAxNi4xNjhWNy4zMTZoMi43MDdjLjk3IDAgMS43MzYuMjM2IDIuMzAxLjcwOC41NjUuNDcyLjg0NyAxLjExNy44NDcgMS45MzUgMCAuNTMtLjEyNi45OTUtLjM3OSAxLjM5LS4yNDQuMzg5LS41OS42ODgtMS4wMzcuODk5bDIuNzgyIDMuOTJoLTIuMTVsLTIuMzUyLTMuNTc5aC0uOTIzdjMuNTc5aC0xLjc5NVptMi44MDgtNy4zMjJoLTEuMDEydjIuMjEzaDEuMDEyYy4zOCAwIC42NzQtLjA5Ny44ODUtLjI5LjIxLS4xOTUuMzE2LS40NzMuMzE2LS44MzYgMC0uMzM3LS4xMDUtLjYwMi0uMzE2LS43OTYtLjIxLS4xOTQtLjUwNi0uMjkxLS44ODUtLjI5MVpNNTkuNTQ5IDcuMDYzYy42OSAwIDEuMzIzLjEyNiAxLjg5Ni4zOC41ODIuMjUyIDEuMDguNTkgMS40OTIgMS4wMTEuNDE0LjQyMS43MzQuOTE5Ljk2MiAxLjQ5Mi4yMjcuNTY1LjM0MSAxLjE2NC4zNDEgMS43OTYgMCAuNjMyLS4xMTQgMS4yMzUtLjM0MSAxLjgwOGE0LjQ4NSA0LjQ4NSAwIDAgMS0uOTYyIDEuNDhjLS40MTMuNDIxLS45MS43NTgtMS40OTIgMS4wMTFhNC42NDggNC42NDggMCAwIDEtMS44OTYuMzggNC43MzggNC43MzggMCAwIDEtMy40MDItMS4zOTEgNC40ODQgNC40ODQgMCAwIDEtLjk2MS0xLjQ4IDQuODU1IDQuODU1IDAgMCAxLS4zNDItMS44MDhjMC0uNjMzLjExNC0xLjIzMS4zNDItMS43OTYuMjI3LS41NzMuNTQ4LTEuMDcuOTYxLTEuNDkyLjQxMy0uNDIyLjkxLS43NTkgMS40OTItMS4wMTJhNC43MzcgNC43MzcgMCAwIDEgMS45MS0uMzc5Wm0wIDcuNjc2YTIuOCAyLjggMCAwIDAgMS4xMzgtLjIyOGMuMzU0LS4xNi42NTMtLjM3Ljg5OC0uNjMyLjI1Mi0uMjcuNDUtLjU4Ni41OTQtLjk0OWEzLjI3IDMuMjcgMCAwIDAgLjIxNS0xLjE4OCAzLjE3IDMuMTcgMCAwIDAtLjIxNS0xLjE3NiAyLjc5MSAyLjc5MSAwIDAgMC0uNTk1LS45NDkgMi41NDggMi41NDggMCAwIDAtLjg5Ny0uNjMyIDIuNjczIDIuNjczIDAgMCAwLTEuMTM4LS4yNGMtLjQxMyAwLS43OTcuMDgtMS4xNTEuMjRhMi42NzggMi42NzggMCAwIDAtLjkxLjYzMiAyLjg5OSAyLjg5OSAwIDAgMC0uNTgyLjk0OSAzLjE3IDMuMTcgMCAwIDAtLjIxNSAxLjE3NmMwIC40MjEuMDcxLjgxNy4yMTUgMS4xODguMTQzLjM2My4zMzcuNjc5LjU4MS45NDkuMjUzLjI2MS41NTcuNDcyLjkxLjYzMi4zNTUuMTUyLjczOS4yMjggMS4xNTIuMjI4WicvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9J2EnPjxwYXRoIGZpbGw9JyNmZmYnIGQ9J00wIDBoMjExdjU4SDB6Jy8+PC9jbGlwUGF0aD48L2RlZnM+PC9zdmc+"); +} \ No newline at end of file diff --git a/templates/pages/login.html b/resources/templates/pages/login.html similarity index 85% rename from templates/pages/login.html rename to resources/templates/pages/login.html index 8ed2300..7a4c5dc 100644 --- a/templates/pages/login.html +++ b/resources/templates/pages/login.html @@ -43,7 +43,7 @@

{{ _("mas.login.headline") }}

{% if features.login_with_email_allowed %} - {% call(f) field.field(label="TCHAP", name="username", form_state=form) %} + {% call(f) field.field(label=_("mas.login.username_or_email"), name="username", form_state=form) %} {% endcall %} {% else %} @@ -76,10 +76,15 @@

{{ _("mas.login.headline") }}

{% set params = next["params"] | default({}) | to_params(prefix="?") %} {% for provider in providers %} {% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %} - +
+ +
+ {% endfor %} {% endif %} diff --git a/start-local-mas.sh b/start-local-mas.sh index c839438..e225ec1 100755 --- a/start-local-mas.sh +++ b/start-local-mas.sh @@ -9,6 +9,9 @@ if [ -z "$MAS_HOME" ]; then exit 1 fi -MAS_CONF=$PWD/conf +export MAS_TCHAP_HOME=$PWD cd $MAS_HOME -cargo run -- server -c $MAS_CONF/config.local.dev.yaml +# Build conf from conf.template.yaml +$MAS_TCHAP_HOME/tools/build_conf.sh + +cargo run -- server -c $MAS_TCHAP_HOME/tmp/config.local.dev.yaml diff --git a/templates/app.html b/templates/app.html deleted file mode 100644 index 52a6243..0000000 --- a/templates/app.html +++ /dev/null @@ -1,31 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2023, 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{# Must be kept in sync with frontend/index.html #} -{% set _ = translator(lang) %} - - - - - - - {{ _("app.name") }} - {% set config = { - 'graphqlEndpoint': app_config.graphqlEndpoint, - 'root': app_config.root, - } -%} - - {{ include_asset('src/main.tsx') | indent(4) | safe }} - - - -
- - diff --git a/templates/base.html b/templates/base.html deleted file mode 100644 index 51cb70d..0000000 --- a/templates/base.html +++ /dev/null @@ -1,36 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2021-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% set _ = translator(lang) %} - -{% import "components/button.html" as button %} -{% import "components/field.html" as field %} -{% import "components/back_to_client.html" as back_to_client %} -{% import "components/logout.html" as logout %} -{% import "components/errors.html" as errors %} -{% import "components/icon.html" as icon %} -{% import "components/scope.html" as scope %} -{% import "components/captcha.html" as captcha %} - - - - - - {% block title %}{{ _("app.name") }}{% endblock title %} - - {{ include_asset('src/shared.css') | indent(4) | safe }} - {{ include_asset('src/templates.css') | indent(4) | safe }} - {{ captcha.head() }} - - - - - diff --git a/templates/components/back_to_client.html b/templates/components/back_to_client.html deleted file mode 100644 index d767dd5..0000000 --- a/templates/components/back_to_client.html +++ /dev/null @@ -1,37 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2021-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% macro link(text, uri, mode, params, type="button", kind="primary", destructive=False) %} - {% if type == "button" %} - {% if destructive %} - {% set class = "cpd-button destructive" %} - {% else %} - {% set class = "cpd-button" %} - {% endif %} - {% elif type == "link" %} - {% set class = "cpd-link" %} - {% if destructive %} - {% set kind = "critical" %} - {% endif %} - {% else %} - {{ throw(message="Invalid type") }} - {% endif %} - - {% if mode == "form_post" %} -
- {% for key, value in params|items %} - - {% endfor %} - -
- {% elif mode == "fragment" or mode == "query" %} -
{{ text }} - {% else %} - {{ throw(message="Invalid mode") }} - {% endif %} -{% endmacro %} diff --git a/templates/components/button.html b/templates/components/button.html deleted file mode 100644 index 3b037f2..0000000 --- a/templates/components/button.html +++ /dev/null @@ -1,97 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2021-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% macro link(text, href="#", class="", size="lg") %} - {{ text }} -{% endmacro %} - -{% macro link_text(text, href="#", class="") %} - {{ text }} -{% endmacro %} - -{% macro link_outline(text, href="#", class="", size="lg") %} - {{ text }} -{% endmacro %} - -{% macro link_tertiary(text, href="#", class="", size="lg") %} - {{ text }} -{% endmacro %} - -{% macro button( - text, - name="", - type="submit", - class="", - value="", - disabled=False, - kind="primary", - size="lg", - autocomplete=False, - autocorrect=False, - autocapitalize=False) %} - -{% endmacro %} - -{% macro button_text( - text, - name="", - type="submit", - class="", - value="", - disabled=False, - autocomplete=False, - autocorrect=False, - autocapitalize=False) %} - -{% endmacro %} - -{% macro button_outline( - text, - name="", - type="submit", - class="", - value="", - disabled=False, - size="lg", - autocomplete=False, - autocorrect=False, - autocapitalize=False) %} - -{% endmacro %} diff --git a/templates/components/captcha.html b/templates/components/captcha.html deleted file mode 100644 index 09bb919..0000000 --- a/templates/components/captcha.html +++ /dev/null @@ -1,41 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% macro form(class="") -%} - {%- if captcha|default(False) -%} - - - {%- if captcha.service == "recaptcha_v2" -%} -
- {%- elif captcha.service == "cloudflare_turnstile" -%} -
- {%- elif captcha.service == "hcaptcha" -%} -
- {%- else -%} - {{ throw(message="Invalid captcha service setup") }} - {%- endif %} - {%- endif -%} -{% endmacro %} - -{% macro head() -%} - {%- if captcha|default(False) -%} - {%- if captcha.service == "recaptcha_v2" -%} - - {%- elif captcha.service == "cloudflare_turnstile" -%} - - {%- elif captcha.service == "hcaptcha" -%} - - {%- else -%} - {{ throw(message="Invalid captcha service setup") }} - {%- endif %} - {%- endif -%} -{%- endmacro %} diff --git a/templates/components/errors.html b/templates/components/errors.html deleted file mode 100644 index e622fdc..0000000 --- a/templates/components/errors.html +++ /dev/null @@ -1,23 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2022-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% macro form_error_message(error) -%} - {% if error.kind == "invalid_credentials" %} - {{ _("mas.errors.invalid_credentials") }} - {% elif error.kind == "password_mismatch" %} - {{ _("mas.errors.password_mismatch") }} - {% elif error.kind == "rate_limit_exceeded" %} - {{ _("mas.errors.rate_limit_exceeded") }} - {% elif error.kind == "policy" %} - {{ _("mas.errors.denied_policy", policy=error.message) }} - {% elif error.kind == "captcha" %} - {{ _("mas.errors.captcha") }} - {% else %} - {{ error.kind }} - {% endif %} -{%- endmacro %} diff --git a/templates/components/field.html b/templates/components/field.html deleted file mode 100644 index 4552068..0000000 --- a/templates/components/field.html +++ /dev/null @@ -1,110 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2021-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% set cnt = counter() %} - -{% macro new_id() -%} - form-{{- cnt.next() -}} -{%- endmacro %} - -{% macro attributes(field, default_value=None) -%} - {%- set value = field.value | default(default_value) -%} - name="{{ field.name }}" id="{{ field.id }}" - {%- if field.errors is not empty %} data-invalid{% endif %} - {%- if value %} value="{{ value }}" {% endif %} -{%- endmacro %} - -{% macro field(label, name, form_state=false, class="", inline=false) %} - {% set field_id = new_id() %} - {% if not form_state %} - {% set form_state = {"fields": {}} %} - {% endif %} - - {% set state = form_state.fields[name] | default({"errors": [], "value": ""}) %} - {% set field = { - "id": new_id(), - "name": name, - "errors": state.errors, - "value": state.value, - } %} - -
- {% if not inline %} - - - {{ caller(field) }} - {% else %} -
- {{ caller(field) }} -
- -
- - {% endif %} - - - {% if field.errors is not empty %} - {% for error in field.errors %} - {% if error.kind != "unspecified" %} -
- {% if error.kind == "required" %} - {{ _("mas.errors.field_required") }} - {% elif error.kind == "exists" and field.name == "username" %} - {{ _("mas.errors.username_taken") }} - {% elif error.kind == "policy" %} - {% if error.code == "username-too-short" %} - {{ _("mas.errors.username_too_short") }} - {% elif error.code == "username-too-long" %} - {{ _("mas.errors.username_too_long") }} - {% elif error.code == "username-invalid-chars" %} - {{ _("mas.errors.username_invalid_chars") }} - {% elif error.code == "username-all-numeric" %} - {{ _("mas.errors.username_all_numeric") }} - {% elif error.code == "username-banned" %} - {{ _("mas.errors.username_banned") }} - {% elif error.code == "username-not-allowed" %} - {{ _("mas.errors.username_not_allowed") }} - {% elif error.code == "email-domain-not-allowed" %} - {{ _("mas.errors.email_domain_not_allowed") }} - {% elif error.code == "email-domain-banned" %} - {{ _("mas.errors.email_domain_banned") }} - {% elif error.code == "email-not-allowed" %} - {{ _("mas.errors.email_not_allowed") }} - {% elif error.code == "email-banned" %} - {{ _("mas.errors.email_banned") }} - {% else %} - {{ _("mas.errors.denied_policy", policy=error.message) }} - {% endif %} - {% elif error.kind == "password_mismatch" %} - {{ _("mas.errors.password_mismatch") }} - {% else %} - {{ error.kind }} - {% endif %} -
- {% endif %} - {% endfor %} - {% endif %} - - {% if inline %} -
- {% endif %} -
-{% endmacro %} - - -{% macro separator() %} -
-
-

{{ _("mas.or_separator") }}

-
-
-{% endmacro %} diff --git a/templates/components/footer.html b/templates/components/footer.html deleted file mode 100644 index 02c0d31..0000000 --- a/templates/components/footer.html +++ /dev/null @@ -1,33 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2023, 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - - diff --git a/templates/components/icon.html b/templates/components/icon.html deleted file mode 100644 index 7781b2f..0000000 --- a/templates/components/icon.html +++ /dev/null @@ -1,803 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2023, 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{# Regenerate with the following shell script: - -for i in frontend/node_modules/@vector-im/compound-design-tokens/icons/*.svg; do - NAME=$(basename "$i" | sed 's/\.svg//' | tr '-' '_') - CONTENT=$(cat "$i") - cat < -{% endmacro %} - -{% macro arrow_down() %} - -{% endmacro %} - -{% macro arrow_left() %} - -{% endmacro %} - -{% macro arrow_right() %} - -{% endmacro %} - -{% macro arrow_up_right() %} - -{% endmacro %} - -{% macro arrow_up() %} - -{% endmacro %} - -{% macro ask_to_join_solid() %} - -{% endmacro %} - -{% macro ask_to_join() %} - -{% endmacro %} - -{% macro attachment() %} - -{% endmacro %} - -{% macro audio() %} - -{% endmacro %} - -{% macro block() %} - -{% endmacro %} - -{% macro bold() %} - -{% endmacro %} - -{% macro calendar() %} - -{% endmacro %} - -{% macro chart() %} - -{% endmacro %} - -{% macro chat_new() %} - -{% endmacro %} - -{% macro chat_problem() %} - -{% endmacro %} - -{% macro chat_solid() %} - -{% endmacro %} - -{% macro chat() %} - -{% endmacro %} - -{% macro check_circle_solid() %} - -{% endmacro %} - -{% macro check_circle() %} - -{% endmacro %} - -{% macro check() %} - -{% endmacro %} - -{% macro chevron_down() %} - -{% endmacro %} - -{% macro chevron_left() %} - -{% endmacro %} - -{% macro chevron_right() %} - -{% endmacro %} - -{% macro chevron_up_down() %} - -{% endmacro %} - -{% macro chevron_up() %} - -{% endmacro %} - -{% macro circle() %} - -{% endmacro %} - -{% macro close() %} - -{% endmacro %} - -{% macro cloud_solid() %} - -{% endmacro %} - -{% macro cloud() %} - -{% endmacro %} - -{% macro code() %} - -{% endmacro %} - -{% macro collapse() %} - -{% endmacro %} - -{% macro company() %} - -{% endmacro %} - -{% macro compose() %} - -{% endmacro %} - -{% macro computer() %} - -{% endmacro %} - -{% macro copy() %} - -{% endmacro %} - -{% macro dark_mode() %} - -{% endmacro %} - -{% macro delete() %} - -{% endmacro %} - -{% macro devices() %} - -{% endmacro %} - -{% macro dial_pad() %} - -{% endmacro %} - -{% macro document() %} - -{% endmacro %} - -{% macro download_ios() %} - -{% endmacro %} - -{% macro download() %} - -{% endmacro %} - -{% macro drag_grid() %} - -{% endmacro %} - -{% macro drag_list() %} - -{% endmacro %} - -{% macro edit_solid() %} - -{% endmacro %} - -{% macro edit() %} - -{% endmacro %} - -{% macro email_solid() %} - -{% endmacro %} - -{% macro email() %} - -{% endmacro %} - -{% macro end_call() %} - -{% endmacro %} - -{% macro error_solid() %} - -{% endmacro %} - -{% macro error() %} - -{% endmacro %} - -{% macro expand() %} - -{% endmacro %} - -{% macro explore() %} - -{% endmacro %} - -{% macro export_archive() %} - -{% endmacro %} - -{% macro extensions_solid() %} - -{% endmacro %} - -{% macro extensions() %} - -{% endmacro %} - -{% macro favourite_solid() %} - -{% endmacro %} - -{% macro favourite() %} - -{% endmacro %} - -{% macro file_error() %} - -{% endmacro %} - -{% macro files() %} - -{% endmacro %} - -{% macro filter() %} - -{% endmacro %} - -{% macro forward() %} - -{% endmacro %} - -{% macro grid() %} - -{% endmacro %} - -{% macro group() %} - -{% endmacro %} - -{% macro headphones_off_solid() %} - -{% endmacro %} - -{% macro headphones_solid() %} - -{% endmacro %} - -{% macro help_solid() %} - -{% endmacro %} - -{% macro help() %} - -{% endmacro %} - -{% macro history() %} - -{% endmacro %} - -{% macro home_solid() %} - -{% endmacro %} - -{% macro home() %} - -{% endmacro %} - -{% macro host() %} - -{% endmacro %} - -{% macro image_error() %} - -{% endmacro %} - -{% macro image() %} - -{% endmacro %} - -{% macro indent_decrease() %} - -{% endmacro %} - -{% macro indent_increase() %} - -{% endmacro %} - -{% macro info_solid() %} - -{% endmacro %} - -{% macro info() %} - -{% endmacro %} - -{% macro inline_code() %} - -{% endmacro %} - -{% macro italic() %} - -{% endmacro %} - -{% macro key_off_solid() %} - -{% endmacro %} - -{% macro key_off() %} - -{% endmacro %} - -{% macro key_solid() %} - -{% endmacro %} - -{% macro key() %} - -{% endmacro %} - -{% macro keyboard() %} - -{% endmacro %} - -{% macro labs() %} - -{% endmacro %} - -{% macro leave() %} - -{% endmacro %} - -{% macro link() %} - -{% endmacro %} - -{% macro linux() %} - -{% endmacro %} - -{% macro list_bulleted() %} - -{% endmacro %} - -{% macro list_numbered() %} - -{% endmacro %} - -{% macro list_view() %} - -{% endmacro %} - -{% macro location_navigator_centred() %} - -{% endmacro %} - -{% macro location_navigator() %} - -{% endmacro %} - -{% macro location_pin_solid() %} - -{% endmacro %} - -{% macro location_pin() %} - -{% endmacro %} - -{% macro lock_off() %} - -{% endmacro %} - -{% macro lock_solid() %} - -{% endmacro %} - -{% macro lock() %} - -{% endmacro %} - -{% macro mac() %} - -{% endmacro %} - -{% macro mark_as_read() %} - -{% endmacro %} - -{% macro mark_as_unread() %} - -{% endmacro %} - -{% macro mark_threads_as_read() %} - -{% endmacro %} - -{% macro marker_read_receipts() %} - -{% endmacro %} - -{% macro mention() %} - -{% endmacro %} - -{% macro menu() %} - -{% endmacro %} - -{% macro mic_off_solid() %} - -{% endmacro %} - -{% macro mic_off() %} - -{% endmacro %} - -{% macro mic_on_solid() %} - -{% endmacro %} - -{% macro mic_on() %} - -{% endmacro %} - -{% macro minus() %} - -{% endmacro %} - -{% macro mobile() %} - -{% endmacro %} - -{% macro notifications_off_solid() %} - -{% endmacro %} - -{% macro notifications_off() %} - -{% endmacro %} - -{% macro notifications_solid() %} - -{% endmacro %} - -{% macro notifications() %} - -{% endmacro %} - -{% macro offline() %} - -{% endmacro %} - -{% macro overflow_horizontal() %} - -{% endmacro %} - -{% macro overflow_vertical() %} - -{% endmacro %} - -{% macro pause_solid() %} - -{% endmacro %} - -{% macro pause() %} - -{% endmacro %} - -{% macro pin_solid() %} - -{% endmacro %} - -{% macro pin() %} - -{% endmacro %} - -{% macro play_solid() %} - -{% endmacro %} - -{% macro play() %} - -{% endmacro %} - -{% macro plus() %} - -{% endmacro %} - -{% macro polls_end() %} - -{% endmacro %} - -{% macro polls() %} - -{% endmacro %} - -{% macro pop_out() %} - -{% endmacro %} - -{% macro preferences() %} - -{% endmacro %} - -{% macro presence_outline_8x8() %} - -{% endmacro %} - -{% macro presence_solid_8x8() %} - -{% endmacro %} - -{% macro presence_strikethrough_8x8() %} - -{% endmacro %} - -{% macro public() %} - -{% endmacro %} - -{% macro qr_code() %} - -{% endmacro %} - -{% macro quote() %} - -{% endmacro %} - -{% macro raised_hand_solid() %} - -{% endmacro %} - -{% macro reaction_add() %} - -{% endmacro %} - -{% macro reaction_solid() %} - -{% endmacro %} - -{% macro reaction() %} - -{% endmacro %} - -{% macro reply() %} - -{% endmacro %} - -{% macro restart() %} - -{% endmacro %} - -{% macro room() %} - -{% endmacro %} - -{% macro search() %} - -{% endmacro %} - -{% macro send_solid() %} - -{% endmacro %} - -{% macro send() %} - -{% endmacro %} - -{% macro settings_solid() %} - -{% endmacro %} - -{% macro settings() %} - -{% endmacro %} - -{% macro share_android() %} - -{% endmacro %} - -{% macro share_ios() %} - -{% endmacro %} - -{% macro share_screen_solid() %} - -{% endmacro %} - -{% macro share_screen() %} - -{% endmacro %} - -{% macro share() %} - -{% endmacro %} - -{% macro sidebar() %} - -{% endmacro %} - -{% macro sign_out() %} - -{% endmacro %} - -{% macro spinner() %} - -{% endmacro %} - -{% macro spotlight_view() %} - -{% endmacro %} - -{% macro spotlight() %} - -{% endmacro %} - -{% macro strikethrough() %} - -{% endmacro %} - -{% macro switch_camera_solid() %} - -{% endmacro %} - -{% macro take_photo_solid() %} - -{% endmacro %} - -{% macro take_photo() %} - -{% endmacro %} - -{% macro text_formatting() %} - -{% endmacro %} - -{% macro threads_solid() %} - -{% endmacro %} - -{% macro threads() %} - -{% endmacro %} - -{% macro time() %} - -{% endmacro %} - -{% macro underline() %} - -{% endmacro %} - -{% macro unknown_solid() %} - -{% endmacro %} - -{% macro unknown() %} - -{% endmacro %} - -{% macro unpin() %} - -{% endmacro %} - -{% macro user_add_solid() %} - -{% endmacro %} - -{% macro user_add() %} - -{% endmacro %} - -{% macro user_profile_solid() %} - -{% endmacro %} - -{% macro user_profile() %} - -{% endmacro %} - -{% macro user_solid() %} - -{% endmacro %} - -{% macro user() %} - -{% endmacro %} - -{% macro verified() %} - -{% endmacro %} - -{% macro video_call_declined_solid() %} - -{% endmacro %} - -{% macro video_call_missed_solid() %} - -{% endmacro %} - -{% macro video_call_off_solid() %} - -{% endmacro %} - -{% macro video_call_off() %} - -{% endmacro %} - -{% macro video_call_solid() %} - -{% endmacro %} - -{% macro video_call() %} - -{% endmacro %} - -{% macro visibility_off() %} - -{% endmacro %} - -{% macro visibility_on() %} - -{% endmacro %} - -{% macro voice_call_solid() %} - -{% endmacro %} - -{% macro voice_call() %} - -{% endmacro %} - -{% macro volume_off_solid() %} - -{% endmacro %} - -{% macro volume_off() %} - -{% endmacro %} - -{% macro volume_on_solid() %} - -{% endmacro %} - -{% macro volume_on() %} - -{% endmacro %} - -{% macro warning() %} - -{% endmacro %} - -{% macro web_browser() %} - -{% endmacro %} - -{% macro windows() %} - -{% endmacro %} diff --git a/templates/components/idp_brand.html b/templates/components/idp_brand.html deleted file mode 100644 index e0226c0..0000000 --- a/templates/components/idp_brand.html +++ /dev/null @@ -1,53 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2023, 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% macro logo(brand, class="") -%} - {% if brand == "google" -%} - - - - - - - {% elif brand == "gitlab" %} - - - - - - - - - - {% elif brand == "twitter" %} - - - - {% elif brand == "github" %} - - - - {% elif brand == "facebook" %} - - - - - - - - - - - {% elif brand == "apple" %} - - - - {% elif brand == "discord" %} - - {% endif %} -{% endmacro %} diff --git a/templates/components/logout.html b/templates/components/logout.html deleted file mode 100644 index ef4daa4..0000000 --- a/templates/components/logout.html +++ /dev/null @@ -1,21 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2022-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% macro button(text, csrf_token, as_link=false, post_logout_action={}) %} -
- - {% for key, value in post_logout_action|items %} - - {% endfor %} - {% if as_link %} - - {% else %} - - {% endif %} -
-{% endmacro %} diff --git a/templates/components/scope.html b/templates/components/scope.html deleted file mode 100644 index 2f0ae10..0000000 --- a/templates/components/scope.html +++ /dev/null @@ -1,31 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2023, 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% macro list(scopes) %} -
    - {% for scope in (scopes | split(" ")) %} - {% if scope == "openid" %} -
  • {{ icon.user_profile() }}

    {{ _("mas.scope.view_profile") }}

  • - {% elif scope == "urn:mas:graphql:*" %} -
  • {{ icon.info() }}

    {{ _("mas.scope.edit_profile") }}

  • -
  • {{ icon.computer() }}

    {{ _("mas.scope.manage_sessions") }}

  • - {% elif scope == "urn:matrix:org.matrix.msc2967.client:api:*" %} -
  • {{ icon.chat() }}

    {{ _("mas.scope.view_messages") }}

  • -
  • {{ icon.send() }}

    {{ _("mas.scope.send_messages") }}

  • - {% elif scope == "urn:synapse:admin:*" %} -
  • {{ icon.error_solid() }}

    {{ _("mas.scope.synapse_admin") }}

  • - {% elif scope == "urn:mas:admin" %} -
  • {{ icon.error_solid() }}

    {{ _("mas.scope.mas_admin") }}

  • - {% elif scope is startingwith("urn:matrix:org.matrix.msc2967.client:device:") %} - {# We hide this scope #} - {% else %} -
  • {{ icon.info() }}

    {{ scope }}

  • - {% endif %} - {% endfor %} -
-{% endmacro %} diff --git a/templates/emails/recovery.html b/templates/emails/recovery.html deleted file mode 100644 index 0567ba7..0000000 --- a/templates/emails/recovery.html +++ /dev/null @@ -1,52 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{%- set _ = translator(lang) -%} - - - - - - - - - - {{ _("mas.emails.recovery.headline", server_name=branding.server_name) }}
-
- {{ _("mas.emails.recovery.click_button") }}
-
- {{ _("mas.emails.recovery.create_new_password") }}
-

- {{ _("mas.emails.recovery.fallback") }} {{ _("mas.emails.recovery.copy_link") }} -

-

- {{ recovery_link }} -

- {{ _("mas.emails.recovery.you_can_ignore") }} - - diff --git a/templates/emails/recovery.subject b/templates/emails/recovery.subject deleted file mode 100644 index a4c158d..0000000 --- a/templates/emails/recovery.subject +++ /dev/null @@ -1,14 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{%- set _ = translator(lang) -%} -{%- set mxid -%} - @{{ user.username }}:{{ branding.server_name }} -{%- endset -%} - -{{ _("mas.emails.recovery.subject", mxid=mxid) }} diff --git a/templates/emails/recovery.txt b/templates/emails/recovery.txt deleted file mode 100644 index 51d8f9d..0000000 --- a/templates/emails/recovery.txt +++ /dev/null @@ -1,16 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{%- set _ = translator(lang) -%} -{{ _("mas.emails.recovery.headline", server_name=branding.server_name) }} - -{{ _("mas.emails.recovery.copy_link") }} - - {{ recovery_link }} - -{{ _("mas.emails.recovery.you_can_ignore") }} diff --git a/templates/emails/verification.html b/templates/emails/verification.html deleted file mode 100644 index 58378dc..0000000 --- a/templates/emails/verification.html +++ /dev/null @@ -1,19 +0,0 @@ -{# -Copyright 2024, 2025 New Vector Ltd. -Copyright 2021-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{%- set _ = translator(lang) -%} - -{%- if browser_session is defined -%} - {%- set username = browser_session.user.username -%} -{%- elif user_registration is defined -%} - {%- set username = user_registration.username -%} -{%- endif -%} - -{{ _("mas.emails.greeting", username=(username|default("user"))) }}
-
-{{ _("mas.emails.verify.body_html", code=authentication_code.code) }}
diff --git a/templates/emails/verification.subject b/templates/emails/verification.subject deleted file mode 100644 index d4ed1b3..0000000 --- a/templates/emails/verification.subject +++ /dev/null @@ -1,11 +0,0 @@ -{# -Copyright 2024, 2025 New Vector Ltd. -Copyright 2021-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{%- set _ = translator(lang) -%} - -{{ _("mas.emails.verify.subject", code=authentication_code.code) }} diff --git a/templates/emails/verification.txt b/templates/emails/verification.txt deleted file mode 100644 index 7afb408..0000000 --- a/templates/emails/verification.txt +++ /dev/null @@ -1,19 +0,0 @@ -{# -Copyright 2024, 2025 New Vector Ltd. -Copyright 2021-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{%- set _ = translator(lang) -%} - -{%- if browser_session is defined -%} - {%- set username = browser_session.user.username -%} -{%- elif user_registration is defined -%} - {%- set username = user_registration.username -%} -{%- endif -%} - -{{ _("mas.emails.greeting", username=(username|default("user"))) }} - -{{ _("mas.emails.verify.body_text", code=authentication_code.code) }} diff --git a/templates/form_post.html b/templates/form_post.html deleted file mode 100644 index 2fb62ea..0000000 --- a/templates/form_post.html +++ /dev/null @@ -1,32 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2021-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} -
-
-

{{ _("common.loading") }}

-
-
- -
- {% for key, value in params|items %} - - {% endfor %} - - -
- - {# Submit the form in JavaScript on the next tick, so that if the browser - wants to display the placeholder instead of a blank page, it can #} - -{% endblock %} diff --git a/templates/pages/404.html b/templates/pages/404.html deleted file mode 100644 index fbd707a..0000000 --- a/templates/pages/404.html +++ /dev/null @@ -1,27 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2023, 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} -
-

{{ _("mas.not_found.heading") }}

-

{{ _("mas.not_found.description") }}

-
- {{ button.link_text(text=_("mas.back_to_homepage"), href="/") }} -
- -
- - -
{{ method }} {{ uri }} {{ version }}
-
-{{ version }} 404 Not Found
-
-
-{% endblock %} diff --git a/templates/pages/account/deactivated.html b/templates/pages/account/deactivated.html deleted file mode 100644 index cc3f520..0000000 --- a/templates/pages/account/deactivated.html +++ /dev/null @@ -1,26 +0,0 @@ -{# -Copyright 2025 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} -
-
-
- {{ icon.delete() }} -
- -
-

{{ _("mas.account.deactivated.heading") }}

- {% set mxid = "@" + user.username + ":" + branding.server_name %} -

{{ _("mas.account.deactivated.description", mxid=mxid) }}

-
- - {{ logout.button(text=_("action.sign_in"), csrf_token=csrf_token) }} -
-
-{% endblock %} diff --git a/templates/pages/account/locked.html b/templates/pages/account/locked.html deleted file mode 100644 index 24b7a8c..0000000 --- a/templates/pages/account/locked.html +++ /dev/null @@ -1,26 +0,0 @@ -{# -Copyright 2025 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} -
-
-
- {{ icon.block() }} -
- -
-

{{ _("mas.account.locked.heading") }}

- {% set mxid = "@" + user.username + ":" + branding.server_name %} -

{{ _("mas.account.locked.description", mxid=mxid) }}

-
- - {{ logout.button(text=_("action.sign_in"), csrf_token=csrf_token) }} -
-
-{% endblock %} diff --git a/templates/pages/account/logged_out.html b/templates/pages/account/logged_out.html deleted file mode 100644 index e625a4c..0000000 --- a/templates/pages/account/logged_out.html +++ /dev/null @@ -1,25 +0,0 @@ -{# -Copyright 2025 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} -
-
-
- {{ icon.leave() }} -
- -
-

{{ _("mas.account.logged_out.heading") }}

-

{{ _("mas.account.logged_out.description") }}

-
- - {{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token) }} -
-
-{% endblock %} diff --git a/templates/pages/consent.html b/templates/pages/consent.html deleted file mode 100644 index 7b02f76..0000000 --- a/templates/pages/consent.html +++ /dev/null @@ -1,76 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2022-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% set consent_page = true %} - -{% extends "base.html" %} - -{% block content %} - {% set client_name = client.client_name or client.client_id %} -
- {% if client.logo_uri %} - - {% else %} - - {% endif %} - -
-

{{ _("mas.consent.heading") }}

-

- {{ _("mas.consent.client_wants_access", client_name=client_name, redirect_uri=(grant.redirect_uri | simplify_url)) }} - {{ _("mas.consent.this_will_allow", client_name=client_name) }} -

-
-
- - - -
- {{ _("mas.consent.make_sure_you_trust", client_name=client_name) }} - {{ _("mas.consent.you_may_be_sharing") }} - {% if client.policy_uri or client.tos_uri %} - Find out how {{ client_name }} will handle your data by reviewing its - {% if client.policy_uri %} - privacy policy{% if not client.tos_uri %}.{% endif %} - {% endif %} - {% if client.policy_uri and client.tos_uri%} - and - {% endif %} - {% if client.tos_uri %} - terms of service. - {% endif %} - {% endif %} -
- -
-
- - {{ button.button(text=_("action.continue")) }} -
- -
-

- {{ _("mas.not_you", username=current_session.user.username) }} -

- - {{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token, post_logout_action=action, as_link=true) }} -
- - {{ back_to_client.link( - text=_("action.cancel"), - kind="tertiary", - uri=grant.redirect_uri, - mode=grant.response_mode, - params=dict(error="access_denied", state=grant.state) - ) }} -
-{% endblock content %} diff --git a/templates/pages/device_consent.html b/templates/pages/device_consent.html deleted file mode 100644 index e8abbdb..0000000 --- a/templates/pages/device_consent.html +++ /dev/null @@ -1,161 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2023, 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% set consent_page = true %} - -{% extends "base.html" %} - -{% block content %} - {% set client_name = client.client_name or client.client_id %} - - {% if grant.state == "pending" %} -
- {% if client.logo_uri %} - - {% else %} - - {% endif %} - -
-

{{ _("mas.consent.heading") }}

- -
-
-
- {% if grant.user_agent.device_type == "mobile" %} - {{ icon.mobile() }} - {% elif grant.user_agent.device_type == "tablet" %} - {{ icon.web_browser() }} - {% elif grant.user_agent.device_type == "pc" %} - {{ icon.computer() }} - {% else %} - {{ icon.unknown_solid() }} - {% endif %} -
- -
- {% if grant.user_agent.model %} -
{{ grant.user_agent.model }}
- {% endif %} - - {% if grant.user_agent.os %} -
- {{ grant.user_agent.os }} - {% if grant.user_agent.os_version %} - {{ grant.user_agent.os_version }} - {% endif %} -
- {% endif %} - - {# If we haven't detected a model, it's probably a browser, so show the name #} - {% if not grant.user_agent.model and grant.user_agent.name %} -
- {{ grant.user_agent.name }} - {% if grant.user_agent.version %} - {{ grant.user_agent.version }} - {% endif %} -
- {% endif %} - - {# If we couldn't detect anything, show a generic "Device" #} - {% if not grant.user_agent.model and not grant.user_agent.name and not grant.user_agent.os %} -
{{ _("mas.device_card.generic_device") }}
- {% endif %} -
-
- -
- -

- {{ _("mas.device_consent.another_device_access") }} - {{ _("mas.consent.this_will_allow", client_name=client_name) }} -

-
-
- - - -
- {{ _("mas.consent.make_sure_you_trust", client_name=client_name) }} - {{ _("mas.consent.you_may_be_sharing") }} - {% if client.policy_uri or client.tos_uri %} - Find out how {{ client_name }} will handle your data by reviewing its - {% if client.policy_uri %} - privacy policy{% if not client.tos_uri %}.{% endif %} - {% endif %} - {% if client.policy_uri and client.tos_uri%} - and - {% endif %} - {% if client.tos_uri %} - terms of service. - {% endif %} - {% endif %} -
- -
-
- - - -
- -
-

- {{ _("mas.not_you", username=current_session.user.username) }} -

- - {{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token, post_logout_action=action, as_link=true) }} -
-
- {% elif grant.state == "rejected" %} -
-
- {{ icon.block() }} -
- -
-

{{ _("mas.device_consent.denied.heading") }}

-

{{ _("mas.device_consent.denied.description", client_name=client_name) }}

-
-
- {% else %} -
-
- {{ icon.check() }} -
- -
-

{{ _("mas.device_consent.granted.heading") }}

-

{{ _("mas.device_consent.granted.description", client_name=client_name) }}

-
-
- {% endif %} -{% endblock content %} diff --git a/templates/pages/device_link.html b/templates/pages/device_link.html deleted file mode 100644 index c77219f..0000000 --- a/templates/pages/device_link.html +++ /dev/null @@ -1,42 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2023, 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} -
-
- {{ icon.link() }} -
- -
-

{{ _("mas.device_code_link.headline") }}

-

{{ _("mas.device_code_link.description") }}

-
-
- -
- {% call(f) field.field(label="Device code", name="code", class="mb-4 self-center", form_state=form_state) %} -
- - - {% for _ in range(6) %} - - {% endfor %} -
- {% endcall %} - - {{ button.button(text=_("action.continue")) }} -
-{% endblock content %} diff --git a/templates/pages/error.html b/templates/pages/error.html deleted file mode 100644 index d078563..0000000 --- a/templates/pages/error.html +++ /dev/null @@ -1,42 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2021-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{# Sometimes we don't have the language set, so we default to english #} -{% set lang = lang or "en" %} - -{% extends "base.html" %} - -{% block content %} -
-
-
- {{ icon.error_solid() }} -
- -
-

{{ _("error.unexpected") }}

- {% if code %} -

- {{ code }} -

- {% endif %} - {% if description %} -

- {{ description }} -

- {% endif %} -
-
- - {% if details %} -
- {# caution: do not introduce whitespace between
 and  #}
-      
{{ details }}
- {% endif %} -
-{% endblock %} diff --git a/templates/pages/index.html b/templates/pages/index.html deleted file mode 100644 index 03fb476..0000000 --- a/templates/pages/index.html +++ /dev/null @@ -1,37 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2021-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} -
-
-
-

{{ _("app.human_name") }}

-

- {{ _("app.technical_description", discovery_url=discovery_url) }} -

-
-
- - {% if current_session %} -

- {{ _("mas.navbar.signed_in_as", username=current_session.user.username) }} -

- - {{ button.link(text=_("mas.navbar.my_account"), href="/account/") }} - {{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token) }} - {% else %} - {{ button.link(text=_("action.sign_in"), href="/login") }} - - {% if features.password_registration %} - {{ button.link_outline(text=_("mas.navbar.register"), href="/register") }} - {% endif %} - {% endif %} -
-{% endblock content %} diff --git a/templates/pages/policy_violation.html b/templates/pages/policy_violation.html deleted file mode 100644 index cca0365..0000000 --- a/templates/pages/policy_violation.html +++ /dev/null @@ -1,52 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2022-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} -
-
- {{ icon.error_solid() }} -
- -
-

{{ _("mas.policy_violation.heading") }}

-

{{ _("mas.policy_violation.description") }}

-
-
- -
-
-
- {% if client.logo_uri %} - - {% endif %} -
- {{ client.client_name or client.client_id }} -
- -
-

- {{ _("mas.policy_violation.logged_as", username=current_session.user.username) }} -

- - {{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token, post_logout_action=action, as_link=True) }} -
- - {# We only show the cancel button if we're in an authorization code flow, not in the device code flow. #} - {% if grant.grant_type == "authorization_code" %} - {{ back_to_client.link( - text=_("action.cancel"), - destructive=True, - uri=grant.redirect_uri, - mode=grant.response_mode, - params=dict(error="access_denied", state=grant.state) - ) }} - {% endif %} -
-{% endblock content %} diff --git a/templates/pages/reauth.html b/templates/pages/reauth.html deleted file mode 100644 index 3a71a10..0000000 --- a/templates/pages/reauth.html +++ /dev/null @@ -1,54 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2021-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} -
-
- {{ icon.lock() }} -
- -
-

Hi {{ current_session.user.username }}

-

To continue, please verify it's you:

-
-
- -
-
- - {# TODO: errors #} - - {% call(f) field.field(label=_("common.password"), name="password", form_state=form) %} - - {% endcall %} - - {{ button.button(text=_("action.continue")) }} -
- - {% if next and next.kind == "continue_authorization_grant" %} - {{ back_to_client.link( - text="Cancel", - destructive=True, - uri=next.grant.redirect_uri, - mode=next.grant.response_mode, - params=dict(error="access_denied", state=next.grant.state) - ) }} - {% endif %} - -
-

- Not {{ current_session.user.username }}? -

- - {% set post_logout_action = next["params"] | default({}) %} - {{ logout.button(text="Sign out", csrf_token=csrf_token, post_logout_action=post_logout_action, as_link=true) }} -
-
-{% endblock content %} diff --git a/templates/pages/recovery/consumed.html b/templates/pages/recovery/consumed.html deleted file mode 100644 index e289f3f..0000000 --- a/templates/pages/recovery/consumed.html +++ /dev/null @@ -1,24 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} -
-
- {{ icon.error_solid() }} -
- -
-

{{ _("mas.recovery.consumed.heading") }}

-

{{ _("mas.recovery.consumed.description") }}

-
- - {{ button.link_outline(text=_("action.start_over"), href="/login") }} -
-{% endblock content %} diff --git a/templates/pages/recovery/disabled.html b/templates/pages/recovery/disabled.html deleted file mode 100644 index a4a942c..0000000 --- a/templates/pages/recovery/disabled.html +++ /dev/null @@ -1,24 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} -
-
- {{ icon.lock_solid() }} -
- -
-

{{ _("mas.recovery.disabled.heading") }}

-

{{ _("mas.recovery.disabled.description") }}

-
- - {{ button.link_outline(text=_("action.back"), href="/login") }} -
-{% endblock content %} diff --git a/templates/pages/recovery/expired.html b/templates/pages/recovery/expired.html deleted file mode 100644 index 2900444..0000000 --- a/templates/pages/recovery/expired.html +++ /dev/null @@ -1,32 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} -
-
- {{ icon.error_solid() }} -
- -
-

{{ _("mas.recovery.expired.heading") }}

-

{{ _("mas.recovery.expired.description", email=session.email) }}

-
-
- -
-
- - - {{ button.button(text=_("mas.recovery.expired.resend_email"), type="submit") }} -
- - {{ button.link_outline(text=_("action.start_over"), href="/login") }} -
-{% endblock content %} diff --git a/templates/pages/recovery/finish.html b/templates/pages/recovery/finish.html deleted file mode 100644 index 923aee9..0000000 --- a/templates/pages/recovery/finish.html +++ /dev/null @@ -1,47 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} -
-
- {{ icon.lock_solid() }} -
- -
-

{{ _("mas.recovery.finish.heading") }}

-

{{ _("mas.recovery.finish.description") }}

-
-
- -
- {# Hidden username field so that password manager can save the username #} - - - {% if form.errors is not empty %} - {% for error in form.errors %} -
- {{ errors.form_error_message(error=error) }} -
- {% endfor %} - {% endif %} - - - - {% call(f) field.field(label=_("mas.recovery.finish.new"), name="new_password", form_state=form) %} - - {% endcall %} - - {% call(f) field.field(label=_("mas.recovery.finish.confirm"), name="new_password_confirm", form_state=form) %} - - {% endcall %} - - {{ button.button(text=_("mas.recovery.finish.save_and_continue"), type="submit") }} -
-{% endblock content %} diff --git a/templates/pages/recovery/progress.html b/templates/pages/recovery/progress.html deleted file mode 100644 index e929e63..0000000 --- a/templates/pages/recovery/progress.html +++ /dev/null @@ -1,37 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} -
-
- {{ icon.send_solid() }} -
- -
-

{{ _("mas.recovery.progress.heading") }}

-

{{ _("mas.recovery.progress.description", email=session.email) }}

-
-
- -
- {% if resend_failed_due_to_rate_limit | default(false) %} -
- {{ _("mas.errors.rate_limit_exceeded") }} -
- {% endif %} -
- - - {{ button.button_outline(text=_("mas.recovery.progress.resend_email"), type="submit") }} -
- - {{ button.link_tertiary(text=_("mas.recovery.progress.change_email"), href="/recover") }} -
-{% endblock content %} diff --git a/templates/pages/recovery/start.html b/templates/pages/recovery/start.html deleted file mode 100644 index 96e9ea3..0000000 --- a/templates/pages/recovery/start.html +++ /dev/null @@ -1,40 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} -
-
- {{ icon.email_solid() }} -
- -
-

{{ _("mas.recovery.start.heading") }}

-

{{ _("mas.recovery.start.description") }}

-
-
- -
- {% if form.errors is not empty %} - {% for error in form.errors %} -
- {{ errors.form_error_message(error=error) }} -
- {% endfor %} - {% endif %} - - - - {% call(f) field.field(label=_("common.email_address"), name="email", form_state=form) %} - - {% endcall %} - - {{ button.button(text=_("action.continue"), type="submit") }} -
-{% endblock content %} diff --git a/templates/pages/register/index.html b/templates/pages/register/index.html deleted file mode 100644 index 1f1d16e..0000000 --- a/templates/pages/register/index.html +++ /dev/null @@ -1,62 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2021-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% from "components/idp_brand.html" import logo %} - -{% block content %} -
-
-
- {{ icon.user_profile_solid() }} -
- -
-

{{ _("mas.register.create_account.heading") }}

- - {% if features.password_registration %} -

{{ _("mas.register.create_account.description") }}

- {% endif %} -
-
- - {% if features.password_registration %} - {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %} - -
- @username:{{ branding.server_name }} -
- {% endcall %} - {% endif %} - -
- {% for key, value in next["params"] | default({}) | items %} - - {% endfor %} - - {% if features.password_registration %} - {{ button.button(text=_("mas.register.continue_with_email")) }} - {% endif %} - - {% if providers %} - {% set params = next["params"] | default({}) | to_params(prefix="?") %} - {% for provider in providers %} - {% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %} - - {{ logo(provider.brand_name) }} - {{ _("mas.login.continue_with_provider", provider=name) }} - - {% endfor %} - {% endif %} - - {% set params = next["params"] | default({}) | to_params(prefix="?") %} - {{ button.link_tertiary(text=_("mas.register.call_to_login"), href="/login" ~ params) }} -
-
-{% endblock content %} diff --git a/templates/pages/register/password.html b/templates/pages/register/password.html deleted file mode 100644 index 8769c36..0000000 --- a/templates/pages/register/password.html +++ /dev/null @@ -1,79 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2021-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} -
-
- {{ icon.user_profile_solid() }} -
- -
-

{{ _("mas.register.create_account.heading") }}

-
-
- -
- {% for error in form.errors %} - {# Special case for the captcha error, as we want to put it at the bottom #} - {% if error.kind != "captcha" %} -
- {{ errors.form_error_message(error=error) }} -
- {% endif %} - {% endfor %} - - - - {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %} - - {% endcall %} - - {% call(f) field.field(label=_("common.email_address"), name="email", form_state=form) %} - - {% endcall %} - - {% call(f) field.field(label=_("common.password"), name="password", form_state=form) %} - - {% endcall %} - - {% call(f) field.field(label=_("common.password_confirm"), name="password_confirm", form_state=form) %} - - {% endcall %} - - {% if branding.tos_uri %} - {% call(f) field.field(label=_("mas.register.terms_of_service", tos_uri=branding.tos_uri), name="accept_terms", form_state=form, inline=true, class="my-4") %} -
-
- -
- {{ icon.check() }} -
-
-
- {% endcall %} - {% endif %} - - {{ captcha.form(class="mb-4 self-center") }} - - {% for error in form.errors %} - {# Special case for the captcha error #} - {% if error.kind == "captcha" %} -
- {{ errors.form_error_message(error=error) }} -
- {% endif %} - {% endfor %} - - {{ button.button(text=_("action.continue")) }} - - {% set params = next["params"] | default({}) | to_params(prefix="?") %} - {{ button.link_tertiary(text=_("mas.register.call_to_login"), href="/login" ~ params) }} -
-{% endblock content %} diff --git a/templates/pages/register/steps/display_name.html b/templates/pages/register/steps/display_name.html deleted file mode 100644 index b4e774c..0000000 --- a/templates/pages/register/steps/display_name.html +++ /dev/null @@ -1,52 +0,0 @@ -{# -Copyright 2025 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} -
-
- {{ icon.visibility_on() }} -
-
-

{{ _("mas.choose_display_name.headline") }}

-

{{ _("mas.choose_display_name.description") }}

-
-
- -
-
- {% if form.errors is not empty %} - {% for error in form.errors %} -
- {{ errors.form_error_message(error=error) }} -
- {% endfor %} - {% endif %} - - - - - {% call(f) field.field(label=_("common.display_name"), name="display_name", form_state=form, class="mb-4") %} - - {% endcall %} - - {{ button.button(text=_("action.continue")) }} -
- -
- - - {{ button.button(text=_("action.skip"), kind="tertiary") }} -
-
-{% endblock content %} diff --git a/templates/pages/register/steps/email_in_use.html b/templates/pages/register/steps/email_in_use.html deleted file mode 100644 index ce9a0bd..0000000 --- a/templates/pages/register/steps/email_in_use.html +++ /dev/null @@ -1,30 +0,0 @@ -{# -Copyright 2025 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} -
-
-
- {{ icon.error_solid() }} -
- -
-

- {{ _("mas.email_in_use.title", email=email) }} -

-

- {{ _("mas.email_in_use.description") }} -

-
-
- - {% set params = action | default({}) | to_params(prefix="?") %} - {{ button.link_outline(text=_("action.start_over"), href="/register" ~ params) }} -
-{% endblock %} diff --git a/templates/pages/register/steps/verify_email.html b/templates/pages/register/steps/verify_email.html deleted file mode 100644 index 39b1c9a..0000000 --- a/templates/pages/register/steps/verify_email.html +++ /dev/null @@ -1,53 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2022-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} -
-
- {{ icon.send_solid() }} -
-
-

{{ _("mas.verify_email.headline") }}

-

{{ _("mas.verify_email.description", email=authentication.email) }}

-
-
- -
- {% if form.errors is not empty %} - {% for error in form.errors %} -
- {{ errors.form_error_message(error=error) }} -
- {% endfor %} - {% endif %} - - - - {% call(f) field.field(label=_("mas.verify_email.6_digit_code"), name="code", form_state=form, class="mb-4 self-center") %} -
- - - {% for _ in range(6) %} - - {% endfor %} -
- {% endcall %} - - {{ button.button(text=_("action.continue")) }} -
-{% endblock content %} diff --git a/templates/pages/sso.html b/templates/pages/sso.html deleted file mode 100644 index 5104dc5..0000000 --- a/templates/pages/sso.html +++ /dev/null @@ -1,48 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2022-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} - {% set client_name = login.redirect_uri | simplify_url %} - -
- - -
-

Allow access to your account?

-

{{ client_name }} wants to access your account. This will allow {{ client_name }} to:

-
-
- - - -
- Make sure that you trust {{ client_name }}. - You may be sharing sensitive information with this site or app. -
- -
-
- - {{ button.button(text=_("action.continue")) }} -
- -
-

- {{ _("mas.not_you", username=current_session.user.username) }} -

- - {{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token, post_logout_action=action, as_link=true) }} -
-
-{% endblock content %} diff --git a/templates/pages/upstream_oauth2/do_register.html b/templates/pages/upstream_oauth2/do_register.html deleted file mode 100644 index c9b8935..0000000 --- a/templates/pages/upstream_oauth2/do_register.html +++ /dev/null @@ -1,199 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2022-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% from "components/idp_brand.html" import logo %} - -{% block content %} - {% if force_localpart %} -
-
- {{ icon.download() }} -
- -
-

- {{ _("mas.upstream_oauth2.register.import_data.heading") }} -

-

- {{ _("mas.upstream_oauth2.register.import_data.description", server_name=branding.server_name) }} -

-
-
- {% elif upstream_oauth_provider.human_name %} -
-
- {{ icon.user_profile_solid() }} -
- -
-

- {{ _("mas.upstream_oauth2.register.signup_with_upstream.heading", human_name=upstream_oauth_provider.human_name) }} -

-
-
- {% else %} -
-
- {{ icon.mention() }} -
- -
-

- {{ _("mas.upstream_oauth2.register.choose_username.heading") }} -

-

- {{ _("mas.upstream_oauth2.register.choose_username.description") }} -

-
-
- {% endif %} - - {% if upstream_oauth_provider.human_name %} - - {% endif %} - -
- - - - {% if form_state.errors is not empty %} - {% for error in form_state.errors %} -
- {{- errors.form_error_message(error=error) -}} -
- {% endfor %} - {% endif %} - - - {% if force_localpart %} - {% call(f) field.field(label=_("common.mxid"), name="mxid") %} - - -
- {{- _("mas.upstream_oauth2.register.enforced_by_policy") -}} -
- {% endcall %} - {% else %} - {% call(f) field.field(label=_("common.username"), name="username", form_state=form_state) %} - - - {% if f.errors is empty %} -
- @{{ imported_localpart or (_("common.username") | lower) }}:{{ branding.server_name }} -
- {% endif %} - {% endcall %} - {% endif %} - - {% if imported_email %} -
- {% call(f) field.field(label=_("common.email_address"), name="email", class="flex-1") %} - - -
- {% if upstream_oauth_provider.human_name %} - {{- _("mas.upstream_oauth2.register.imported_from_upstream_with_name", human_name=upstream_oauth_provider.human_name) -}} - {% else %} - {{- _("mas.upstream_oauth2.register.imported_from_upstream") -}} - {% endif %} -
- {% endcall %} - - {% if not force_email %} -
-
-
- -
- {{ icon.check() }} -
-
-
- -
- {% endif %} -
- {% endif %} - - {% if imported_display_name %} -
- {% call(f) field.field(label=_("common.display_name"), name="display_name", class="flex-1") %} - - -
- {% if upstream_oauth_provider.human_name %} - {{- _("mas.upstream_oauth2.register.imported_from_upstream_with_name", human_name=upstream_oauth_provider.human_name) -}} - {% else %} - {{- _("mas.upstream_oauth2.register.imported_from_upstream") -}} - {% endif %} -
- {% endcall %} - - {% if not force_display_name %} -
-
-
- -
- {{ icon.check() }} -
-
-
-
- -
-
- {% endif %} -
- {% endif %} - - {% if branding.tos_uri %} - {% call(f) field.field(label=_("mas.register.terms_of_service", tos_uri=branding.tos_uri), name="accept_terms", form_state=form_state, inline=true, class="my-4") %} -
-
- -
- {{ icon.check() }} -
-
-
- {% endcall %} - {% endif %} - - - {{ button.button(text=_("action.create_account")) }} -
- - {# Leave this for now as we don't have that fully designed yet - {{ field.separator() }} - {{ button.link_outline(text=_("mas.upstream_oauth2.register.link_existing"), href=login_link) }} - #} -{% endblock content %} diff --git a/templates/pages/upstream_oauth2/link_mismatch.html b/templates/pages/upstream_oauth2/link_mismatch.html deleted file mode 100644 index 4ab1dc8..0000000 --- a/templates/pages/upstream_oauth2/link_mismatch.html +++ /dev/null @@ -1,25 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2022-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} -
-
- {{ icon.warning() }} -
- -
-

- {{ _("mas.upstream_oauth2.link_mismatch.heading") }} -

-
-
- - {{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token) }} -{% endblock content %} diff --git a/templates/pages/upstream_oauth2/suggest_link.html b/templates/pages/upstream_oauth2/suggest_link.html deleted file mode 100644 index a226092..0000000 --- a/templates/pages/upstream_oauth2/suggest_link.html +++ /dev/null @@ -1,34 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2022-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block content %} -
-
- {{ icon.link() }} -
- -
-

{{ _("mas.upstream_oauth2.suggest_link.heading") }}

-
-
- -
-
- - - - {{ button.button(text=_("mas.upstream_oauth2.suggest_link.action")) }} -
- - {{ field.separator() }} - - {{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token, post_logout_action=post_logout_action) }} -
-{% endblock content %} diff --git a/templates/swagger/doc.html b/templates/swagger/doc.html deleted file mode 100644 index 59a7763..0000000 --- a/templates/swagger/doc.html +++ /dev/null @@ -1,27 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - - - - - - - API documentation - - {{ include_asset('src/swagger.ts') | indent(4) | safe }} - - - -
- - diff --git a/templates/swagger/oauth2-redirect.html b/templates/swagger/oauth2-redirect.html deleted file mode 100644 index e00644e..0000000 --- a/templates/swagger/oauth2-redirect.html +++ /dev/null @@ -1,89 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{# This is taken from the swagger-ui/dist/oauth2-redirect.html file #} - - - - - API documentation: OAuth2 Redirect - - - - - diff --git a/tools/build_conf.sh b/tools/build_conf.sh new file mode 100755 index 0000000..0fb7c87 --- /dev/null +++ b/tools/build_conf.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +set -e +# New template directory +MAS_TCHAP_DATA="$MAS_TCHAP_HOME/tmp" +# Create data directory +if [ ! -d "$MAS_TCHAP_DATA" ]; then + mkdir -p "$MAS_TCHAP_DATA" +fi + +# Copy all MAS templates to a new template directory +cp -r "$MAS_HOME/templates" "$MAS_TCHAP_DATA" + +# Override MAS template with custom tchap template +cp -r "$MAS_TCHAP_HOME/resources/templates" "$MAS_TCHAP_DATA" + +# Create MAS conf file +template_yaml_file="$MAS_TCHAP_HOME/conf/config.template.yaml" +yaml_file="$MAS_TCHAP_DATA/config.local.dev.yaml" +cp $template_yaml_file $yaml_file + +MAS_TCHAP_TEMPLATES="$MAS_TCHAP_DATA/templates" +sed -i '' -E "/^templates:/,/^[^[:space:]]/ s|^[[:space:]]*path:.*| path: \"$MAS_TCHAP_TEMPLATES\"|" "$yaml_file" From 2b2a97448c9fe6f55b43cdbc7cdece36a143ee1e Mon Sep 17 00:00:00 2001 From: Olivier D Date: Tue, 6 May 2025 16:10:43 +0200 Subject: [PATCH 10/71] add tests and mock for identity server (#1) * add tests and mock for identity server add screenshots por test add tests from element app fix api /info to /internal-info add legacy conf fix name add register and login tests update gitignore * move files up level * create .env for docker compose * detach container * add keycloak hostname * force recreate containers * remove proconnect button * update readme --- .env.sample | 9 + .gitignore | 9 +- README.md | 68 +++++- conf/config.local.dev.yaml | 240 +++++++++++++++++++++ conf/config.template.yaml | 5 +- docker-compose.yml | 21 +- playwright/.env.element | 33 +++ playwright/.env.local | 33 +++ playwright/.env.mas01 | 29 +++ playwright/.env.mas02 | 29 +++ playwright/.env.mas03 | 29 +++ playwright/.env.pr1226 | 32 +++ playwright/.env.sample | 18 +- playwright/README.md | 1 - playwright/fixtures/auth-fixture.ts | 77 ++++++- playwright/package-lock.json | 2 +- playwright/package.json | 7 +- playwright/playwright.config.ts | 8 +- playwright/tests/element-oidc-auth.spec.ts | 50 +++++ playwright/tests/login-oidc.spec.ts | 54 +++++ playwright/tests/login-password.spec.ts | 40 ++++ playwright/tests/oidc-auth.spec.ts | 72 ------- playwright/tests/register-oidc.spec.ts | 121 +++++++++++ playwright/tests/utils/auth-helpers.ts | 83 +++++-- playwright/tests/utils/config.ts | 47 +++- playwright/tests/utils/keycloak-admin.ts | 8 +- playwright/tests/utils/mas-admin.ts | 108 ++++++---- resources/templates/pages/login.html | 7 +- start-local-mas.sh | 8 + start-local-stack.sh | 7 + tools/keycloak/proconnect-mock-realm.json | 2 +- tools/test-identity-mock.sh | 61 ++++++ wiremock/mappings/invited-mapping.json | 27 +++ wiremock/mappings/not-invited-mapping.json | 27 +++ wiremock/mappings/user-mapping.json | 27 +++ wiremock/mappings/wrong-hs-mapping.json | 27 +++ 36 files changed, 1261 insertions(+), 165 deletions(-) create mode 100644 .env.sample create mode 100644 conf/config.local.dev.yaml create mode 100644 playwright/.env.element create mode 100644 playwright/.env.local create mode 100644 playwright/.env.mas01 create mode 100644 playwright/.env.mas02 create mode 100644 playwright/.env.mas03 create mode 100644 playwright/.env.pr1226 create mode 100644 playwright/tests/element-oidc-auth.spec.ts create mode 100644 playwright/tests/login-oidc.spec.ts create mode 100644 playwright/tests/login-password.spec.ts delete mode 100644 playwright/tests/oidc-auth.spec.ts create mode 100644 playwright/tests/register-oidc.spec.ts create mode 100755 tools/test-identity-mock.sh create mode 100644 wiremock/mappings/invited-mapping.json create mode 100644 wiremock/mappings/not-invited-mapping.json create mode 100644 wiremock/mappings/user-mapping.json create mode 100644 wiremock/mappings/wrong-hs-mapping.json diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..0086739 --- /dev/null +++ b/.env.sample @@ -0,0 +1,9 @@ +#wiremock port for mocking identity server (sydent) +IDENTITY_MOCK_PORT=8083 + +# postgres port +POSTGRES_PORT=5432 + +# keycloak config for mocking proconnect +KEYCLOAK_PORT=8082 +KEYCLOAK_HOSTNAME=https://sso.tchapgouv.com \ No newline at end of file diff --git a/.gitignore b/.gitignore index c02e423..38f8aa3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ -tchap/ -tmp/ \ No newline at end of file +tmp/ +playwright/node_modules/ +playwright/playwright-report/ +playwright/playwright-results/ +playwright/test-results/ +playwright/.env +.env \ No newline at end of file diff --git a/README.md b/README.md index c43ec56..f33c457 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,75 @@ -## folder structure +# folder structure - /.github custom CI (if needed) - /conf : configuration du MAS special tchap - /playwright : test E2E -- /templates : custom html templates, css and manifest.json +- /ressources : custom html templates, css and manifest.json - /tools : for local dev - /translations : tchap custom labels +- /wiremock : mock http response for identity server sydent export $MAS_HOME : path of /matrix-authentication-service, fork léger https://github.com/tchapgouv/matrix-authentication-service -export $MAS_TCHAP_HOME : path of /matrix-authentication-service-tchap \ No newline at end of file +export $MAS_TCHAP_HOME : path of /matrix-authentication-service-tchap + + +# playwright tests + +## setup synapse + +checkout la branche : https://github.com/tchapgouv/element-docker-demo/tree/local-dev + +```element-docker-demo % ./setup_tchap.sh ``` + > tchapgouv.com + > Use local mkcert CA for SSL? [y/n] y + +.env and SSL configured; you can now docker compose up + +```element-docker-demo % ./start_tchap_light.sh ``` + + +> dependency failed to start: container element-docker-demo-postgres-1 is unhealthy + +Postgres met du temps à démarrer, relancer synapse puis nginx à la main + +créer un compte à https://element.tchapgouv.com/ + +> L’inscription a été désactivée sur ce serveur d’accueil. + +c'est normal le MAS n'est pas démarré + +## setup MAS + + +- launch postres services + +Create .env and personalize it if needed + +```bash +cp .env.sample .env +matrix-authentication-service-tchap % ./start-local-stack.sh +``` + +keycloak container might take some time to start up + +```bash +export MAS_HOME=/Users/olivier/workspace/beta/Tchap/matrix-authentication-service +./start-local-mas.sh +``` + +Recharger la page https://element.tchapgouv.com/, le MAS est accessible + +## launch tests + +```bash +playwright % npm test +``` + +to launch a custom config run (see config.json) + +```bash +npm run test:mas01 +``` + + diff --git a/conf/config.local.dev.yaml b/conf/config.local.dev.yaml new file mode 100644 index 0000000..2e0f6be --- /dev/null +++ b/conf/config.local.dev.yaml @@ -0,0 +1,240 @@ +http: + listeners: + - name: web + resources: + - name: discovery + - name: human + - name: oauth + - name: compat + - name: graphql + - name: assets + # for api admin calls + - name: adminapi + binds: + - address: '[::]:8080' + proxy_protocol: false + - name: internal + resources: + - name: health + # for api admin calls + - name: adminapi + binds: + - host: localhost + port: 8081 + proxy_protocol: false + trusted_proxies: + - 192.168.0.0/16 + - 172.16.0.0/12 + - 10.0.0.0/10 + - 127.0.0.1/8 + - fd00::/8 + - ::1/128 + # public_base: http://[::]:8080/ + # issuer: http://[::]:8080/ + public_base: https://auth.tchapgouv.com/ + issuer: https://auth.tchapgouv.com/ +database: + uri: postgresql://postgres:postgres@localhost/postgres + max_connections: 10 + min_connections: 0 + connect_timeout: 30 + idle_timeout: 600 + max_lifetime: 1800 +email: + from: '"Authentication Service" ' + reply_to: '"Authentication Service" ' + transport: smtp + mode: plain + hostname: 127.0.0.1 + port: 1025 + +secrets: + encryption: eb38b8b9087842b3345269f3c6ca92b2a8d6aa63e3a773d23ed1c9cb45c5ef83 + keys: + - kid: dyrZtIyXSA + key: | + -----BEGIN RSA PRIVATE KEY----- + MIIEogIBAAKCAQEAukLyWSv9KOeBsmIG4ntL/BP+wj5L4GbOyAOEzRBBO0ZbBYVn + 1L/aYQ65cQpQKK0MzH5cn7TvIY0JBh/ZsSm3e7DdhGJzoqPrW6E0/6QqXEl4gN1q + DUCMj2CwAcT9OX1Wt6cNq70+gbqp6+yT+Nn++KPylHa/V9wRxkaV3fyM+XYVeddB + amDR+fBjHgVXQ3xk2ezUBS3AmyKBgETnHufKCkxJ5mXdU9HT0ewg8J2PrgiRBwDj + XP7C5Zif/+NfYPO2mM/b0Y91pl9aXuUHX+/zlqtpcwX/WprEsCaRUa9qWHuiftMf + uelsflCgZ0KumZjzr3wPDEfu8n7WbVEObNxF+QIDAQABAoIBAEl82mNGUMbPuEMq + G+9FmDAnr27x5zvtNA6EHORPUn1Rf94IyXOOEloS1iV8XS3/QLp57I9ycpq5K2NI + M7qLbAIYQP3XXipAJD7ttpxaKABrWGj3cr0xx4NWMXsxPnttMUaaWXF14/CJNjuI + BsW7NLbi8HWU+F9wy26AMOb5mqFdQfk15H3LaM3L3hMuV5DOcA467luwHmuGGTji + VjZg3yZZF2ROtTwwVSB7UCokmW3FZys/U4SyzXSrboUxF9T3PW3Hxm9e1JfFQuLh + rn85Q4IDpRtK2O5ECmKjY0cyvQOVItQytTeVXtSEzgMfK5VpnYMefdCFvhExcsuV + JHT1c/UCgYEAziAPCJYNTXsvo3yEW1iWnAjfi6R7g9aluNDjf4xmva5DTOdZSH1d + AJkzXZtPYXXlR42RT4FzE/ICdjo81aDMrMHwoIiH9p1n7IdDTt3GONYi3VPKGvZd + Ghgdq5jgdedDhF9MximZcWdZOZLCymKME61RuCg18p/MMIJ/FRNBE38CgYEA51R6 + CjNc93qO7hL7AGu+evs3/PVrHsg5iBf8GcGeyNNGkA5TJcYTO075VBDQuWsMZbvx + d0jBjROs8U2AJzgbh6+N/rZACflk8W4LyD3Pw+1coIcckH4hmgN37vkh63qiGX8K + YVe8CrbGliBB2OccXsdJVDe0f45kte0eJh6cAocCgYAH9d7+wuTCoEZHtxBZgsNW + RVV0zCZlAg4mZBLVIzP4kVlSCAE/tm+4DTKZo9zd87KmH8aD3oj2NTt5G2isC2i8 + J0VGvd8aXBveW57y1cfI/CQejhTZE7imwFWtAdtxUjweSZvqb0LYyVf9zDgvnryw + KdplFVB4DUnSece0pai2uwKBgDzV335dQZ6nsXz0quPScfZ/qJqyo+gledPLkvXn + EG35+f2ads1hSN95BmLQRUPt3gXHJlpbXONQAFQ5MHGf9MV7KpmIrlCxMJW5fgm8 + D66T9p8UyTNKqGWLcff7tqrpxkV0PnOZEg+zP4htlUOIi9J1EFjAiYxeEygw4pPd + yuNzAoGAI0lLE32iIm+j5byFCKRuS6cQmDBzUJxZiCuLsuZEINYdz2nxKZqd9VtA + rOXzo8vJuGLF1hjf1/C66F3EnPxcclPL10vLCE8RbfQYVwBxw7MG9BLJXIlMjb2V + 7CjVDE4Gaa96tChg1pepuJcOEuWez3o3Ard9oZ4Z9sm7VJzmuoY= + -----END RSA PRIVATE KEY----- + - kid: O1hkajPW2v + key: | + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEINsgMBFDIrIzqOIWuR94TCi5MTH1FS8wfgatu9BsO2jVoAoGCCqGSM49 + AwEHoUQDQgAEldEtvZaXtblpUdHpKKQiH7z9ADC55H0yrCYyQsLXbt14lI2NuseX + MWsvSLBzkbEetDxkmKh0bhOfrdwv9x5SwA== + -----END EC PRIVATE KEY----- + - kid: 2nhe3z2925 + key: | + -----BEGIN EC PRIVATE KEY----- + MIGkAgEBBDDTVngHypOwUnPOGXeskQJhdSLLPBCM+mkSvzr2SZ7Kjm3hftvs2s7J + gZBOZwXyoaKgBwYFK4EEACKhZANiAAQ3WGOQs3EqO2x4X7PBWs6Lw3qdmRLHqblc + Zplh3wYPDOoUMvD99Snxz43t5sK6kphLBL262/srx/UPT1McLUxBMlBvBUbBEKHX + a8icrL13yIwflquj0EHrE7czFJw1txs= + -----END EC PRIVATE KEY----- + - kid: 50aRR3QVqx + key: | + -----BEGIN EC PRIVATE KEY----- + MHQCAQEEIN8bErXY1sWEJ1y9KoYcpcUImIjpS/ay3pEugYPfr3Y/oAcGBSuBBAAK + oUQDQgAEMHcshHVFbMSEyyt3ptIdAhnrg+XlQskZ33hZvdtzm6I0wW8H8zslMp+I + t0KYCeIQ7HTPtgJAOsKxEPBfmVXZmA== + -----END EC PRIVATE KEY----- +passwords: + enabled: true + schemes: + - version: 1 + algorithm: bcrypt + secret: "secret01" + - version: 2 + algorithm: argon2id + minimum_complexity: 3 +matrix: + homeserver: tchapgouv.com + secret: '/DjWc4D3yyqgjYN8tum65g' + endpoint: https://matrix.tchapgouv.com/ + +clients: + - client_id: 0000000000000000000SYNAPSE + client_auth_method: client_secret_basic + client_secret: '/DjWc4D3yyqgjYN8tum65g' + + # for api admin calls + - client_id: 01J44RKQYM4G3TNVANTMTDYTX6 + client_auth_method: client_secret_basic + client_secret: phoo8ahneir3ohY2eigh4xuu6Oodaewi + + +policy: + data: + admin_clients: + # for api admin calls + - 01J44RKQYM4G3TNVANTMTDYTX6 + +account: + # Whether users are allowed to change their email addresses. + # + # Defaults to `true`. + email_change_allowed: false + + # Whether users are allowed to change their display names + # + # Defaults to `true`. + # This should be in sync with the policy in the homeserver configuration. + displayname_change_allowed: false + + # Whether to enable self-service password registration + # + # Defaults to `false`. + # This has no effect if password login is disabled. + password_registration_enabled: true + + # Whether users are allowed to change their passwords + # + # Defaults to `true`. + # This has no effect if password login is disabled. + password_change_allowed: true + + # Whether email-based password recovery is enabled + # + # Defaults to `false`. + # This has no effect if password login is disabled. + password_recovery_enabled: true + + # Whether users can log in with their email address. + # + # Defaults to `false`. + # This has no effect if password login is disabled. + login_with_email_allowed: true + +templates: + # From where to load the templates + # This is relative to the current working directory, *not* the config file + path: "WILL BE REPLACED BY build_conf.sh" + + # Path to the frontend assets manifest file + # assets_manifest: "/to/manifest.json" + + # # From where to load the translation files + # # Default in Docker distribution: `/usr/local/share/mas-cli/translations/` + # # Default in pre-built binaries: `./share/translations/` + # # Default in locally-built binaries: `./translations/` + # translations_path: /to/translations + +upstream_oauth2: + providers: + - id: "01JK5MR1SD21MAQY4PWMFG283W" + human_name: Proconnect (mock) + issuer: "https://sso.tchapgouv.com/realms/proconnect-mock" + token_endpoint_auth_method: client_secret_basic + client_id: "matrix-authentication-service" + client_secret: "HrJ1NZ0AbkHuWWjyRHh7X2lzn3S8eagt" + scope: "openid profile email" + claims_imports: + localpart: + action: require + template: "{{ user.email | email_to_mxid_localpart }}" + displayname: + action: require + template: "{{ user.email | email_to_display_name }}" + email: + action: require + template: "{{ user.email }}" + set_email_verification: always + allow_existing_users: true + +telemetry: + tracing: + # # List of propagators to use for extracting and injecting trace contexts + # propagators: + # # Propagate according to the W3C Trace Context specification + # - tracecontext + # # Propagate according to the W3C Baggage specification + # - baggage + # # Propagate trace context with Jaeger compatible headers + # - jaeger + + # # The default: don't export traces + exporter: none + + # Export traces to an OTLP-compatible endpoint + #exporter: otlp + #endpoint: https://localhost:4318 + metrics: + # The default: don't export metrics + exporter: none + + # Export metrics to an OTLP-compatible endpoint + #exporter: otlp + #endpoint: https://localhost:4317 + + # Export metrics by exposing a Prometheus endpoint + # This requires mounting the `prometheus` resource to an HTTP listener + #exporter: prometheus + + # sentry: + # # DSN to use for sending errors and crashes to Sentry + # dsn: https://public@host:port/1 + diff --git a/conf/config.template.yaml b/conf/config.template.yaml index bbcec99..2e0f6be 100644 --- a/conf/config.template.yaml +++ b/conf/config.template.yaml @@ -8,6 +8,7 @@ http: - name: compat - name: graphql - name: assets + # for api admin calls - name: adminapi binds: - address: '[::]:8080' @@ -15,6 +16,7 @@ http: - name: internal resources: - name: health + # for api admin calls - name: adminapi binds: - host: localhost @@ -187,7 +189,7 @@ upstream_oauth2: human_name: Proconnect (mock) issuer: "https://sso.tchapgouv.com/realms/proconnect-mock" token_endpoint_auth_method: client_secret_basic - client_id: "mas" + client_id: "matrix-authentication-service" client_secret: "HrJ1NZ0AbkHuWWjyRHh7X2lzn3S8eagt" scope: "openid profile email" claims_imports: @@ -201,6 +203,7 @@ upstream_oauth2: action: require template: "{{ user.email }}" set_email_verification: always + allow_existing_users: true telemetry: tracing: diff --git a/docker-compose.yml b/docker-compose.yml index 3d2b683..78517b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,10 +3,23 @@ services: image: sj26/mailcatcher network_mode: host + identity-mock: + image: wiremock/wiremock:2.35.0 + ports: + - "${IDENTITY_MOCK_PORT}:8080" + volumes: + - ./wiremock:/home/wiremock + command: + - --verbose + - --global-response-templating + networks: + - tchap-network + # This service mocks the Matrix Identity Server API + postgres: image: postgres ports: - - "5432:5432" + - "${POSTGRES_PORT}:5432" environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres @@ -24,9 +37,9 @@ services: retries: 50 keycloak: - image: quay.io/keycloak/keycloak:latest + image: quay.io/keycloak/keycloak:26.2 ports: - - "8082:8080" + - "${KEYCLOAK_PORT}:8080" environment: - KEYCLOAK_ADMIN=admin - KEYCLOAK_ADMIN_PASSWORD=admin @@ -36,7 +49,7 @@ services: - KC_DB_PASSWORD=postgres volumes: - ./tools/keycloak/proconnect-mock-realm.json:/opt/keycloak/data/import/proconnect-mock-realm.json - command: start-dev --import-realm --hostname=https://sso.tchapgouv.com + command: "start-dev --import-realm --hostname=${KEYCLOAK_HOSTNAME}" networks: - tchap-network depends_on: diff --git a/playwright/.env.element b/playwright/.env.element new file mode 100644 index 0000000..ab83729 --- /dev/null +++ b/playwright/.env.element @@ -0,0 +1,33 @@ +# URLs +MAS_URL=https://auth.tchapgouv.com +KEYCLOAK_URL=https://sso.tchapgouv.com +ELEMENT_URL=https://app.element.io + +# Keycloak Admin Credentials +KEYCLOAK_ADMIN_USERNAME=admin +KEYCLOAK_ADMIN_PASSWORD=admin +KEYCLOAK_REALM=proconnect-mock + +# MAS Admin API Credentials +MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 +MAS_ADMIN_CLIENT_SECRET=phoo8ahneir3ohY2eigh4xuu6Oodaewi + +# Test User Credentials +TEST_USER_PREFIX=playwright_test_user +TEST_USER_PASSWORD=Test@123456 + +# Test User Credentials +TEST_USER_PREFIX=user.local +TEST_USER_PASSWORD=Test@123456 + +# Email domains for test users +STANDARD_EMAIL_DOMAIN=tchapgouv.com +INVITED_EMAIL_DOMAIN=invited.externe.com +NOT_INVITED_EMAIL_DOMAIN=not.invited.externe.com +WRONG_SERVER_EMAIL_DOMAIN=wrong.server.com + +# Browser Locale +BROWSER_LOCALE=fr-FR + +# Screenshots Directory +SCREENSHOTS_DIR=playwright-results/local diff --git a/playwright/.env.local b/playwright/.env.local new file mode 100644 index 0000000..06ace38 --- /dev/null +++ b/playwright/.env.local @@ -0,0 +1,33 @@ +# URLs +MAS_URL=https://auth.tchapgouv.com +KEYCLOAK_URL=https://sso.tchapgouv.com +ELEMENT_URL=https://element.tchapgouv.com + +# Keycloak Admin Credentials +KEYCLOAK_ADMIN_USERNAME=admin +KEYCLOAK_ADMIN_PASSWORD=admin +KEYCLOAK_REALM=proconnect-mock + +# MAS Admin API Credentials +MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 +MAS_ADMIN_CLIENT_SECRET=phoo8ahneir3ohY2eigh4xuu6Oodaewi + +# Test User Credentials +TEST_USER_PREFIX=playwright_test_user +TEST_USER_PASSWORD=Test@123456 + +# Test User Credentials +TEST_USER_PREFIX=user.local +TEST_USER_PASSWORD=Test@123456 + +# Email domains for test users +STANDARD_EMAIL_DOMAIN=tchapgouv.com +INVITED_EMAIL_DOMAIN=invited.externe.com +NOT_INVITED_EMAIL_DOMAIN=not.invited.externe.com +WRONG_SERVER_EMAIL_DOMAIN=wrong.server.com + +# Browser Locale +BROWSER_LOCALE=fr-FR + +# Screenshots Directory +SCREENSHOTS_DIR=playwright-results/local diff --git a/playwright/.env.mas01 b/playwright/.env.mas01 new file mode 100644 index 0000000..3d890c5 --- /dev/null +++ b/playwright/.env.mas01 @@ -0,0 +1,29 @@ +# URLs +MAS_URL=https://auth.mas01.tchap.incubateur.net +KEYCLOAK_URL=https://tchap-connect.mas01.tchap.incubateur.net +ELEMENT_URL=https://www.mas01.tchap.incubateur.net + +# Keycloak Admin Credentials +KEYCLOAK_ADMIN_USERNAME=admin +KEYCLOAK_ADMIN_PASSWORD=admin +KEYCLOAK_REALM=proconnect-mock + +# MAS Admin API Credentials +MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 +MAS_ADMIN_CLIENT_SECRET=phoo8ahneir3ohY2eigh4xuu6Oodaewi + +# Test User Credentials +TEST_USER_PREFIX=user.mas +TEST_USER_PASSWORD=Test@123456 + +# Email domains for test users +STANDARD_EMAIL_DOMAIN=beta.gouv.fr +INVITED_EMAIL_DOMAIN=invited.yopmail.com +NOT_INVITED_EMAIL_DOMAIN=yopmail.com +WRONG_SERVER_EMAIL_DOMAIN=agent2.incubateur.net + +# Browser Locale +BROWSER_LOCALE=fr-FR + +# Screenshots Directory +SCREENSHOTS_DIR=playwright-results/mas01 diff --git a/playwright/.env.mas02 b/playwright/.env.mas02 new file mode 100644 index 0000000..f82ef0c --- /dev/null +++ b/playwright/.env.mas02 @@ -0,0 +1,29 @@ +# URLs +MAS_URL=https://auth.mas01.tchap.incubateur.net +KEYCLOAK_URL=https://tchap-connect.mas01.tchap.incubateur.net +ELEMENT_URL=https://www.mas02.tchap.incubateur.net + +# Keycloak Admin Credentials +KEYCLOAK_ADMIN_USERNAME=admin +KEYCLOAK_ADMIN_PASSWORD=admin +KEYCLOAK_REALM=proconnect-mock + +# MAS Admin API Credentials +MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 +MAS_ADMIN_CLIENT_SECRET=phoo8ahneir3ohY2eigh4xuu6Oodaewi + +# Test User Credentials +TEST_USER_PREFIX=user.mas +TEST_USER_PASSWORD=Test@123456 + +# Email domains for test users +STANDARD_EMAIL_DOMAIN=beta.gouv.fr +INVITED_EMAIL_DOMAIN=invited.yopmail.com +NOT_INVITED_EMAIL_DOMAIN=yopmail.com +WRONG_SERVER_EMAIL_DOMAIN=agent2.incubateur.net + +# Browser Locale +BROWSER_LOCALE=fr-FR + +# Screenshots Directory +SCREENSHOTS_DIR=playwright-results/mas02 diff --git a/playwright/.env.mas03 b/playwright/.env.mas03 new file mode 100644 index 0000000..df5ecd6 --- /dev/null +++ b/playwright/.env.mas03 @@ -0,0 +1,29 @@ +# URLs +MAS_URL=https://auth.mas03.tchap.incubateur.net +KEYCLOAK_URL=https://tchap-connect.mas03.tchap.incubateur.net +ELEMENT_URL=https://www.mas03.tchap.incubateur.net + +# Keycloak Admin Credentials +KEYCLOAK_ADMIN_USERNAME=admin +KEYCLOAK_ADMIN_PASSWORD=admin +KEYCLOAK_REALM=proconnect-mock + +# MAS Admin API Credentials +MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 +MAS_ADMIN_CLIENT_SECRET=phoo8ahneir3ohY2eigh4xuu6Oodaewi + +# Test User Credentials +TEST_USER_PREFIX=user.mas +TEST_USER_PASSWORD=Test@123456 + +# Email domains for test users +STANDARD_EMAIL_DOMAIN=beta.gouv.fr +INVITED_EMAIL_DOMAIN=invited.yopmail.com +NOT_INVITED_EMAIL_DOMAIN=yopmail.com +WRONG_SERVER_EMAIL_DOMAIN=agent2.incubateur.net + +# Browser Locale +BROWSER_LOCALE=fr-FR + +# Screenshots Directory +SCREENSHOTS_DIR=playwright-results/mas03 diff --git a/playwright/.env.pr1226 b/playwright/.env.pr1226 new file mode 100644 index 0000000..a44c480 --- /dev/null +++ b/playwright/.env.pr1226 @@ -0,0 +1,32 @@ +# URLs +MAS_URL=https://auth.mas01.tchap.incubateur.net +KEYCLOAK_URL=https://tchap-connect.mas01.tchap.incubateur.net +ELEMENT_URL=https://tchap-v4-preprod-pr1226.osc-secnum-fr1.scalingo.io/ + +# old login page flow +TCHAP_LEGACY = true + +# Keycloak Admin Credentials +KEYCLOAK_ADMIN_USERNAME=admin +KEYCLOAK_ADMIN_PASSWORD=admin +KEYCLOAK_REALM=proconnect-mock + +# MAS Admin API Credentials +MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 +MAS_ADMIN_CLIENT_SECRET=phoo8ahneir3ohY2eigh4xuu6Oodaewi + +# Test User Credentials +TEST_USER_PREFIX=user.mas +TEST_USER_PASSWORD=Test@123456 + +# Email domains for test users +STANDARD_EMAIL_DOMAIN=beta.gouv.fr +INVITED_EMAIL_DOMAIN=invited.yopmail.com +NOT_INVITED_EMAIL_DOMAIN=yopmail.com +WRONG_SERVER_EMAIL_DOMAIN=agent2.incubateur.net + +# Browser Locale +BROWSER_LOCALE=fr-FR + +# Screenshots Directory +SCREENSHOTS_DIR=playwright-results/mas01 diff --git a/playwright/.env.sample b/playwright/.env.sample index 638cbed..d1f7e40 100644 --- a/playwright/.env.sample +++ b/playwright/.env.sample @@ -1,12 +1,15 @@ # URLs MAS_URL=https://auth.tchapgouv.com KEYCLOAK_URL=https://sso.tchapgouv.com +ELEMENT_URL=https://element.tchapgouv.com + +# old login page flow +TCHAP_LEGACY = false # Keycloak Admin Credentials KEYCLOAK_ADMIN_USERNAME=admin KEYCLOAK_ADMIN_PASSWORD= KEYCLOAK_REALM=proconnect-mock -KEYCLOAK_CLIENT_ID=mas # MAS Admin API Credentials MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 @@ -15,3 +18,16 @@ MAS_ADMIN_CLIENT_SECRET= # Test User Credentials TEST_USER_PREFIX=playwright_test_user TEST_USER_PASSWORD=Test@123456 + +# Email domains for test users +STANDARD_EMAIL_DOMAIN=incubateur.net +INVITED_EMAIL_DOMAIN=invited.yopmail.com +NOT_INVITED_EMAIL_DOMAIN=yopmail.com +WRONG_SERVER_EMAIL_DOMAIN=agent2.incubateur.net + + +# Screenshots Directory +SCREENSHOTS_DIR=playwright-results + +# Browser Locale +BROWSER_LOCALE=fr-FR diff --git a/playwright/README.md b/playwright/README.md index 56f4231..3e1dddc 100644 --- a/playwright/README.md +++ b/playwright/README.md @@ -70,7 +70,6 @@ KEYCLOAK_URL=https://sso.tchapgouv.com KEYCLOAK_ADMIN_USERNAME=admin KEYCLOAK_ADMIN_PASSWORD=admin KEYCLOAK_REALM=proconnect-mock -KEYCLOAK_CLIENT_ID=mas # MAS Admin API Credentials MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 diff --git a/playwright/fixtures/auth-fixture.ts b/playwright/fixtures/auth-fixture.ts index 1aa21a0..0adcafb 100644 --- a/playwright/fixtures/auth-fixture.ts +++ b/playwright/fixtures/auth-fixture.ts @@ -2,21 +2,25 @@ import { test as base } from '@playwright/test'; import { createKeycloakTestUser, cleanupKeycloakTestUser, TestUser } from '../tests/utils/auth-helpers'; import { disposeApiContext as disposeKeycloakApiContext } from '../tests/utils/keycloak-admin'; import { disposeApiContext as disposeMasApiContext } from '../tests/utils/mas-admin'; +import { generateTestUser } from '../tests/utils/config'; + +import { + STANDARD_EMAIL_DOMAIN, + INVITED_EMAIL_DOMAIN, + NOT_INVITED_EMAIL_DOMAIN, + WRONG_SERVER_EMAIL_DOMAIN +} from '../tests/utils/config'; /** - * Extend the basic test fixtures with our authentication fixtures + * Function to create a test user fixture with a specific domain */ -export const test = base.extend<{ - testUser: TestUser; -}>({ - /** - * Create a test user in Keycloak before the test and clean it up after - */ - testUser: async ({}, use) => { +function createTestUserFixture(domain: string) { + return async ({}, use: (user: TestUser) => Promise) => { try { + const testUser = generateTestUser(domain); + // Create a test user in Keycloak - const user = await createKeycloakTestUser(); - console.log(`Created test user: ${user.username} (${user.email})`); + const user = await createKeycloakTestUser(testUser); // Use the test user in the test await use(user); @@ -32,7 +36,58 @@ export const test = base.extend<{ ]); console.log('API contexts disposed'); } - }, + }; +} + +function createTestLinkUserFixture(domain: string) { + return async ({}, use: (user: TestUser) => Promise) => { + try { + const randomSuffix = Math.floor(Math.random() * 10000); + + const testUser:TestUser = { + username: `test.user${randomSuffix}-${domain}`, + email: `test.user${randomSuffix}@${domain}`, + password: '1234!' + } + + // Create a test user in Keycloak + const user = await createKeycloakTestUser(testUser); + + // Use the test user in the test + await use(user); + + // Clean up the test user after the test + await cleanupKeycloakTestUser(user); + console.log(`Cleaned up test user: ${user.username}`); + } finally { + // Dispose API contexts + await Promise.all([ + disposeKeycloakApiContext(), + disposeMasApiContext() + ]); + console.log('API contexts disposed'); + } + }; +} + +/** + * Extend the basic test fixtures with our authentication fixtures + */ +export const test = base.extend<{ + testUser: TestUser; + testExternalUser: TestUser; + testExternalUserWitoutInvit: TestUser; + testUserOnWrongServer: TestUser; + testLink:TestUser; +}>({ + /** + * Create a test user in Keycloak before the test and clean it up after + */ + testUser: createTestUserFixture(STANDARD_EMAIL_DOMAIN), + testExternalUser: createTestUserFixture(INVITED_EMAIL_DOMAIN), + testExternalUserWitoutInvit: createTestUserFixture(NOT_INVITED_EMAIL_DOMAIN), + testUserOnWrongServer: createTestUserFixture(WRONG_SERVER_EMAIL_DOMAIN), + testLink:createTestLinkUserFixture(STANDARD_EMAIL_DOMAIN) }); export { expect } from '@playwright/test'; diff --git a/playwright/package-lock.json b/playwright/package-lock.json index 5347503..dbad1e0 100644 --- a/playwright/package-lock.json +++ b/playwright/package-lock.json @@ -410,4 +410,4 @@ } } } -} +} \ No newline at end of file diff --git a/playwright/package.json b/playwright/package.json index a18d145..61a6b65 100644 --- a/playwright/package.json +++ b/playwright/package.json @@ -7,7 +7,12 @@ "test": "playwright test", "test:headed": "playwright test --headed", "test:debug": "playwright test --debug", - "test:ui": "playwright test --ui" + "test:ui": "playwright test --ui", + "test:local": "TEST_ENV=local playwright test", + "test:mas01": "TEST_ENV=mas01 playwright test", + "test:mas02": "TEST_ENV=mas02 playwright test", + "test:mas03": "TEST_ENV=mas03 playwright test", + "test:pr1226": "TEST_ENV=pr1226 playwright test" }, "keywords": [ "playwright", diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts index 4fe7047..40f8150 100644 --- a/playwright/playwright.config.ts +++ b/playwright/playwright.config.ts @@ -1,5 +1,6 @@ import { defineConfig, devices } from '@playwright/test'; import dotenv from 'dotenv'; +import { BROWSER_LOCALE } from './tests/utils/config'; // Load environment variables from .env file dotenv.config(); @@ -10,9 +11,9 @@ dotenv.config(); export default defineConfig({ testDir: './tests', /* Maximum time one test can run for */ - timeout: 60 * 1000, + timeout: 15 * 1000, /* Run tests in files in parallel */ - fullyParallel: true, + fullyParallel: false, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ @@ -24,6 +25,9 @@ export default defineConfig({ /* Base URL to use in actions like `await page.goto('/')` */ baseURL: process.env.MAS_URL || 'https://auth.tchapgouv.com', + /* Set locale to French */ + locale: BROWSER_LOCALE, + /* Collect trace when retrying the failed test */ trace: 'on-first-retry', diff --git a/playwright/tests/element-oidc-auth.spec.ts b/playwright/tests/element-oidc-auth.spec.ts new file mode 100644 index 0000000..19fe6f0 --- /dev/null +++ b/playwright/tests/element-oidc-auth.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from '../fixtures/auth-fixture'; +import { + performOidcLogin, + verifyUserInMas, + createMasTestUser, + cleanupMasTestUser, + performPasswordLogin, + TestUser, + performOidcLoginFromElement +} from './utils/auth-helpers'; +import { checkMasUserExistsByEmail } from './utils/mas-admin'; +import { SCREENSHOTS_DIR, TCHAP_LEGACY } from './utils/config'; + +test.describe('Element OIDC register flows', () => { + test('element : register via oidc and create user in MAS', async ({ page, testUser }) => { + const screenshot_path = 'element_register_oidc'; + + // Verify the test user doesn't exist in MAS yet + const existsBeforeLogin = await checkMasUserExistsByEmail(testUser.email); + expect(existsBeforeLogin).toBe(false); + + // Perform the OIDC login flow + await performOidcLoginFromElement(page, testUser,screenshot_path, TCHAP_LEGACY); + + // Click the create account button + await page.locator('button[type="submit"]').click(); + + // Verify we're successfully logged in, confirgmation page + await expect(page.locator('text=Continuer')).toBeVisible(); + + // Take a screenshot of the authenticated state + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/05-confirmation.png` }); + + await page.locator('button[type="submit"]').filter({hasText:'Continuer'}).click(); + + await expect(page.locator('text=Configuration')).toBeVisible(); + + // Take a screenshot of the authenticated state + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/06-auth-success.png` }); + + // Verify the user was created in MAS + await verifyUserInMas(testUser); + + // Double-check with the API + const existsAfterLogin = await checkMasUserExistsByEmail(testUser.email); + expect(existsAfterLogin).toBe(true); + + console.log(`Successfully authenticated and verified user ${testUser.username} (${testUser.email})`); + }); +}); diff --git a/playwright/tests/login-oidc.spec.ts b/playwright/tests/login-oidc.spec.ts new file mode 100644 index 0000000..ab67d67 --- /dev/null +++ b/playwright/tests/login-oidc.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from '../fixtures/auth-fixture'; +import { + performOidcLogin, + verifyUserInMas, + createMasTestUser, + cleanupMasTestUser, + performPasswordLogin, + TestUser +} from './utils/auth-helpers'; +import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser } from './utils/mas-admin'; +import { SCREENSHOTS_DIR } from './utils/config'; + +test.describe('Oidc login flows', () => { + + test('should link existing MAS account when logging in via OIDC with same email', async ({ page, testLink }) => { + const screenshot_path = 'link_oidc_account'; + + // Create a user in MAS with the same email as the Keycloak user + console.log(`Creating MAS user with same email as Keycloak user: ${testLink.email}`); + + const masUserId = await createMasUserWithPassword(testLink.username, testLink.email, testLink.password); + testLink.masId = masUserId; + + try { + // Verify the user exists in MAS + const existsBeforeLogin = await checkMasUserExistsByEmail(testLink.email); + expect(existsBeforeLogin).toBe(true); + console.log(`Confirmed MAS user exists with email: ${testLink.email}`); + + // Perform the OIDC login flow + await performOidcLogin(page, testLink, screenshot_path); + + // Click the link account button + await page.locator('button[type="submit"]').click(); + + // Since the account already exists, we should be automatically logged in + // Verify we're successfully logged in + await expect(page.locator('text=Mon compte')).toBeVisible(); + + // Take a screenshot of the authenticated state + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); + + // Verify the user in MAS is still the same (account was linked, not created new) + const userAfterLogin = await getMasUserByEmail(testLink.email); + expect(userAfterLogin.id).toBe(masUserId); + + console.log(`Successfully verified account linking for user with email: ${testLink.email}`); + } finally { + // Clean up the MAS user + await deactivateMasUser(masUserId); + console.log(`Cleaned up MAS user: ${testLink.username}`); + } + }); +}); diff --git a/playwright/tests/login-password.spec.ts b/playwright/tests/login-password.spec.ts new file mode 100644 index 0000000..c638ad0 --- /dev/null +++ b/playwright/tests/login-password.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from '../fixtures/auth-fixture'; +import { + performOidcLogin, + verifyUserInMas, + createMasTestUser, + cleanupMasTestUser, + performPasswordLogin, + TestUser +} from './utils/auth-helpers'; +import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser } from './utils/mas-admin'; +import { SCREENSHOTS_DIR } from './utils/config'; + +test.describe('password login flows', () => { + + test('should authenticate with username and password', async ({ page }) => { + const screenshot_path = 'login_pwd'; + + // Create a test user with a password in MAS + const user = await createMasTestUser("exemple.com"); + + try { + console.log(`Created test user in MAS: ${user.username} (${user.email})`); + + // Perform password login + await performPasswordLogin(page, user,screenshot_path); + + // Verify we're successfully logged in + await expect(page.locator('text=Mon compte')).toBeVisible(); + + // Take a screenshot of the authenticated state + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-password-auth-success.png` }); + + console.log(`Successfully authenticated with password for user: ${user.username}`); + } finally { + // Clean up the test user + await cleanupMasTestUser(user); + console.log(`Cleaned up test user: ${user.username}`); + } + }); +}); diff --git a/playwright/tests/oidc-auth.spec.ts b/playwright/tests/oidc-auth.spec.ts deleted file mode 100644 index aba8759..0000000 --- a/playwright/tests/oidc-auth.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { test, expect } from '../fixtures/auth-fixture'; -import { - performOidcLogin, - verifyUserInMas, - createMasTestUser, - cleanupMasTestUser, - performPasswordLogin, - TestUser -} from './utils/auth-helpers'; -import { checkMasUserExistsByEmail } from './utils/mas-admin'; - -test.describe('Authentication Flows', () => { - // Create a directory for screenshots - test.beforeAll(async ({ }, testInfo) => { - const { exec } = require('child_process'); - exec('mkdir -p playwright-results'); - }); - - test('should authenticate via Keycloak and create user in MAS', async ({ page, testUser }) => { - // Verify the test user doesn't exist in MAS yet - const existsBeforeLogin = await checkMasUserExistsByEmail(testUser.email); - expect(existsBeforeLogin).toBe(false); - - // Perform the OIDC login flow - await performOidcLogin(page, testUser); - - // Click the create account button - await page.locator('button[type="submit"]').click(); - - // Verify we're successfully logged in - // This could be checking for a specific element that's only visible when logged in - await expect(page.locator('text=Sign out')).toBeVisible(); - - // Take a screenshot of the authenticated state - await page.screenshot({ path: 'playwright-results/04-authenticated.png' }); - - // Verify the user was created in MAS - await verifyUserInMas(testUser); - - // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(testUser.email); - expect(existsAfterLogin).toBe(true); - - console.log(`Successfully authenticated and verified user ${testUser.username} (${testUser.email})`); - console.log(`User ID in Keycloak: ${testUser.keycloakId}`); - console.log(`User ID in MAS: ${testUser.masId}`); - }); - - test('should authenticate with username and password', async ({ page }) => { - // Create a test user with a password in MAS - const user = await createMasTestUser(); - - try { - console.log(`Created test user in MAS: ${user.username} (${user.email})`); - - // Perform password login - await performPasswordLogin(page, user); - - // Verify we're successfully logged in - await expect(page.locator('text=Sign out')).toBeVisible(); - - // Take a screenshot of the authenticated state - await page.screenshot({ path: 'playwright-results/04-password-auth-success.png' }); - - console.log(`Successfully authenticated with password for user: ${user.username}`); - } finally { - // Clean up the test user - await cleanupMasTestUser(user); - console.log(`Cleaned up test user: ${user.username}`); - } - }); -}); diff --git a/playwright/tests/register-oidc.spec.ts b/playwright/tests/register-oidc.spec.ts new file mode 100644 index 0000000..dbf4af1 --- /dev/null +++ b/playwright/tests/register-oidc.spec.ts @@ -0,0 +1,121 @@ +import { test, expect } from '../fixtures/auth-fixture'; +import { + performOidcLogin, + verifyUserInMas, + createMasTestUser, + cleanupMasTestUser, + performPasswordLogin, + TestUser +} from './utils/auth-helpers'; +import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser } from './utils/mas-admin'; +import { SCREENSHOTS_DIR } from './utils/config'; + +test.describe('OIDC register Flows', () => { + + test('register via oidc and create user in MAS', async ({ page, testUser }) => { + const screenshot_path = 'register_oidc'; + + // Verify the test user doesn't exist in MAS yet + const existsBeforeLogin = await checkMasUserExistsByEmail(testUser.email); + expect(existsBeforeLogin).toBe(false); + + // Perform the OIDC login flow + await performOidcLogin(page, testUser,screenshot_path); + + // Click the create account button + await page.locator('button[type="submit"]').click(); + + // Verify we're successfully logged in + // This could be checking for a specific element that's only visible when logged in + await expect(page.locator('text=Mon compte')).toBeVisible(); + + // Take a screenshot of the authenticated state + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-authenticated.png` }); + + // Verify the user was created in MAS + await verifyUserInMas(testUser); + + // Double-check with the API + const existsAfterLogin = await checkMasUserExistsByEmail(testUser.email); + expect(existsAfterLogin).toBe(true); + + console.log(`Successfully authenticated and verified user ${testUser.username} (${testUser.email})`); + }); + + test('register via oidc and get error because extern has no invit', async ({ page, testExternalUserWitoutInvit }) => { + const screenshot_path = 'register_oidc_no_invit'; + + // Verify the test user doesn't exist in MAS yet + const existsBeforeLogin = await checkMasUserExistsByEmail(testExternalUserWitoutInvit.email); + expect(existsBeforeLogin).toBe(false); + + // Perform the OIDC login flow + await performOidcLogin(page, testExternalUserWitoutInvit, screenshot_path); + + // Get error + await page.locator('text=invitation_missing'); + + // Take a screenshot of the authenticated state + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-error-no-invit.png` }); + + + // Double-check with the API + const existsAfterLogin = await checkMasUserExistsByEmail(testExternalUserWitoutInvit.email); + expect(existsAfterLogin).toBe(false); + + console.log(`Successfully authenticated and verified user ${testExternalUserWitoutInvit.username} (${testExternalUserWitoutInvit.email})`); + }); + + test('register via oidc and create user external with invitation in MAS', async ({ page, testExternalUser }) => { + const screenshot_path = 'register_oidc_invit'; + + // Verify the test user doesn't exist in MAS yet + const existsBeforeLogin = await checkMasUserExistsByEmail(testExternalUser.email); + expect(existsBeforeLogin).toBe(false); + + // Perform the OIDC login flow + await performOidcLogin(page, testExternalUser, screenshot_path); + + // Click the create account button + await page.locator('button[type="submit"]').click(); + + // Verify we're successfully logged in + await expect(page.locator('text=Mon compte')).toBeVisible(); + + // Take a screenshot of the authenticated state + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-authenticated-external.png` }); + + // Verify the user was created in MAS + await verifyUserInMas(testExternalUser); + + // Double-check with the API + const existsAfterLogin = await checkMasUserExistsByEmail(testExternalUser.email); + expect(existsAfterLogin).toBe(true); + + console.log(`Successfully authenticated and verified external user ${testExternalUser.username} (${testExternalUser.email})`); + }); + + test('should authenticate user via oidc and get error because wrong server', async ({ page, testUserOnWrongServer }) => { + const screenshot_path = 'register_oidc_wrong_server'; + + // Verify the test user doesn't exist in MAS yet + const existsBeforeLogin = await checkMasUserExistsByEmail(testUserOnWrongServer.email); + expect(existsBeforeLogin).toBe(false); + + // Perform the OIDC login flow + await performOidcLogin(page, testUserOnWrongServer, screenshot_path); + + // Get error + await page.locator('text=wrong_server'); + + // Take a screenshot of the authenticated state + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-error-wrong-server.png` }); + + // Double-check with the API + const existsAfterLogin = await checkMasUserExistsByEmail(testUserOnWrongServer.email); + expect(existsAfterLogin).toBe(false); + + console.log(`Successfully authenticated and verified user ${testUserOnWrongServer.username} (${testUserOnWrongServer.email})`); + }); + +}); diff --git a/playwright/tests/utils/auth-helpers.ts b/playwright/tests/utils/auth-helpers.ts index 94ce867..16a1454 100644 --- a/playwright/tests/utils/auth-helpers.ts +++ b/playwright/tests/utils/auth-helpers.ts @@ -1,7 +1,7 @@ import { Page } from '@playwright/test'; import { createKeycloakUser, deleteKeycloakUser } from './keycloak-admin'; import { waitForMasUser, createMasUserWithPassword, deactivateMasUser } from './mas-admin'; -import { generateTestUser } from './config'; +import { ELEMENT_URL, generateTestUser, KEYCLOAK_URL, MAS_URL, SCREENSHOTS_DIR } from './config'; /** * Test user type @@ -17,8 +17,7 @@ export interface TestUser { /** * Create a test user in Keycloak */ -export async function createKeycloakTestUser(): Promise { - const user = generateTestUser(); +export async function createKeycloakTestUser(user:TestUser): Promise { const keycloakId = await createKeycloakUser(user.username, user.email, user.password); return { ...user, keycloakId }; } @@ -40,12 +39,12 @@ export async function cleanupKeycloakTestUser(user: TestUser): Promise { * 3. Fill in credentials on the Keycloak login page * 4. Wait for successful authentication and redirect back to MAS */ -export async function performOidcLogin(page: Page, user: TestUser): Promise { +export async function performOidcLogin(page: Page, user: TestUser, screenshot_path:string): Promise { // Navigate to the login page await page.goto('/login'); // Take a screenshot of the login page - await page.screenshot({ path: 'playwright-results/01-login-page.png' }); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-login-page.png` }); // Find and click the OIDC provider button (adjust the selector as needed) // This is based on the login.html template which shows provider buttons @@ -53,10 +52,10 @@ export async function performOidcLogin(page: Page, user: TestUser): Promise url.toString().includes('sso.tchapgouv.com')); + await page.waitForURL(url => url.toString().includes(KEYCLOAK_URL)); // Take a screenshot of the Keycloak login page - await page.screenshot({ path: 'playwright-results/02-keycloak-login.png' }); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-keycloak-login.png` }); // Fill in the username and password await page.locator('#username').fill(user.username); @@ -67,12 +66,66 @@ export async function performOidcLogin(page: Page, user: TestUser): Promise url.toString().includes('auth.tchapgouv.com')); + await page.waitForURL(url => url.toString().includes(MAS_URL)); // Take a screenshot after successful login - await page.screenshot({ path: 'playwright-results/03-after-login.png' }); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-after-login.png` }); } +/** + * Perform OIDC login starting from Element client + * This function handles the entire authentication flow: + * 1. Navigate to Element login page + * 2. Click on "Continuer" button + * 3. Get redirected to MAS login page + * 4. Continue with the standard OIDC flow + */ +export async function performOidcLoginFromElement(page: Page, user: TestUser, screenshot_path: string, tchap_legacy:boolean=false): Promise { + // Navigate to Element login page + await page.goto(`${ELEMENT_URL}/#/login`); + + // Take a screenshot of the Element login page + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-element-login-page.png` }); + + if(tchap_legacy){ + // Click on "Continuer" button + await page.getByRole('button').filter({ hasText: 'Créez un compte' }).click(); + }else{ + // Click on "Continuer" button + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + } + + // Wait for navigation to MAS + await page.waitForURL(url => url.toString().includes(MAS_URL)); + + // Take a screenshot of the MAS login page + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-mas-login-page.png` }); + + // Find and click the OIDC provider button + const oidcButton = page.locator('a.cpd-button[href*="/upstream/authorize/"]'); + await oidcButton.click(); + + // Wait for navigation to Keycloak + await page.waitForURL(url => url.toString().includes(KEYCLOAK_URL)); + + // Take a screenshot of the Keycloak login page + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-keycloak-login.png` }); + + // Fill in the username and password + await page.locator('#username').fill(user.username); + await page.locator('#password').fill(user.password); + + // Click the login button + await page.locator('button[type="submit"]').click(); + + // Wait for redirect back to MAS + await page.waitForURL(url => url.toString().includes(MAS_URL)); + + // Take a screenshot after successful login + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-after-login.png` }); +} + + /** * Verify that a user was created in MAS after OIDC authentication */ @@ -84,8 +137,8 @@ export async function verifyUserInMas(user: TestUser): Promise { /** * Create a test user directly in MAS with password */ -export async function createMasTestUser(): Promise { - const user = generateTestUser(); +export async function createMasTestUser(domain:string): Promise { + const user = generateTestUser(domain); const masId = await createMasUserWithPassword(user.username, user.email, user.password); return { ...user, masId }; } @@ -107,21 +160,21 @@ export async function cleanupMasTestUser(user: TestUser): Promise { * 3. Submit the form * 4. Wait for successful authentication */ -export async function performPasswordLogin(page: Page, user: TestUser): Promise { +export async function performPasswordLogin(page: Page, user: TestUser, screenshot_path:string): Promise { console.log(`[Auth] Performing password login for user: ${user.username}`); // Navigate to the login page await page.goto('/login'); // Take a screenshot of the login page - await page.screenshot({ path: 'playwright-results/01-password-login-page.png' }); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-password-login-page.png` }); // Fill in the username and password await page.locator('input[name="username"]').fill(user.username); await page.locator('input[name="password"]').fill(user.password); // Take a screenshot before submitting - await page.screenshot({ path: 'playwright-results/02-password-login-filled.png' }); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-password-login-filled.png` }); // Click the login button (submit the form) await page.locator('button[type="submit"]').click(); @@ -130,7 +183,7 @@ export async function performPasswordLogin(page: Page, user: TestUser): Promise< await page.waitForURL(url => !url.toString().includes('/login')); // Take a screenshot after successful login - await page.screenshot({ path: 'playwright-results/03-password-login-success.png' }); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-password-login-success.png` }); console.log(`[Auth] Password login successful for user: ${user.username}`); } diff --git a/playwright/tests/utils/config.ts b/playwright/tests/utils/config.ts index 0437b71..1965b95 100644 --- a/playwright/tests/utils/config.ts +++ b/playwright/tests/utils/config.ts @@ -1,32 +1,69 @@ import dotenv from 'dotenv'; +import path from 'path'; + + +// Determine which environment to use +const env = process.env.TEST_ENV || 'local'; +console.log(`Loading environment configuration for: ${env}`); + +// Load environment variables from the appropriate .env file +dotenv.config({ path: path.resolve(__dirname, `../../.env.${env}`) }); // Load environment variables from .env file -dotenv.config(); +//dotenv.config(); // URLs export const MAS_URL = process.env.MAS_URL || 'https://auth.tchapgouv.com'; export const KEYCLOAK_URL = process.env.KEYCLOAK_URL || 'https://sso.tchapgouv.com'; +export const ELEMENT_URL = process.env.ELEMENT_URL || 'https://element.tchapgouv.com'; + +export const TCHAP_LEGACY:boolean = Boolean(process.env.TCHAP_LEGACY); // Keycloak Admin Credentials export const KEYCLOAK_ADMIN_USERNAME = process.env.KEYCLOAK_ADMIN_USERNAME || 'admin'; export const KEYCLOAK_ADMIN_PASSWORD = process.env.KEYCLOAK_ADMIN_PASSWORD || 'admin'; export const KEYCLOAK_REALM = process.env.KEYCLOAK_REALM || 'proconnect-mock'; -export const KEYCLOAK_CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID || 'mas'; // MAS Admin API Credentials export const MAS_ADMIN_CLIENT_ID = process.env.MAS_ADMIN_CLIENT_ID || '01J44RKQYM4G3TNVANTMTDYTX6'; export const MAS_ADMIN_CLIENT_SECRET = process.env.MAS_ADMIN_CLIENT_SECRET || 'phoo8ahneir3ohY2eigh4xuu6Oodaewi'; // Test User Credentials -export const TEST_USER_PREFIX = process.env.TEST_USER_PREFIX || 'playwright_test_user'; +export const TEST_USER_PREFIX = process.env.TEST_USER_PREFIX || 'user.test'; export const TEST_USER_PASSWORD = process.env.TEST_USER_PASSWORD || 'Test@123456'; +// Email domains for test users +export const STANDARD_EMAIL_DOMAIN = process.env.STANDARD_EMAIL_DOMAIN || 'tchapgouv.com'; +export const INVITED_EMAIL_DOMAIN = process.env.INVITED_EMAIL_DOMAIN || 'invited.externe.com'; +export const NOT_INVITED_EMAIL_DOMAIN = process.env.NOT_INVITED_EMAIL_DOMAIN || 'not.invited.externe.com'; +export const WRONG_SERVER_EMAIL_DOMAIN = process.env.WRONG_SERVER_EMAIL_DOMAIN || 'wrong.server.com'; + +// Screenshots directory +export const SCREENSHOTS_DIR = process.env.SCREENSHOTS_DIR || 'playwright-results'; + +// Browser locale +export const BROWSER_LOCALE = process.env.BROWSER_LOCALE || 'fr-FR'; + +// Generate a unique username and email for testing +export function generateTestUser(domain:string) { + const timestamp = new Date().getTime(); + const randomSuffix = Math.floor(Math.random() * 10000); + const username = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}`; + const email = `${username}@${domain}`; + + return { + username, + email, + password: TEST_USER_PASSWORD + }; +} + // Generate a unique username and email for testing -export function generateTestUser() { +export function generateExternTestUser() { const timestamp = new Date().getTime(); const randomSuffix = Math.floor(Math.random() * 10000); const username = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}`; - const email = `${username}@example.com`; + const email = `${username}@tchapgouv.com`; return { username, diff --git a/playwright/tests/utils/keycloak-admin.ts b/playwright/tests/utils/keycloak-admin.ts index 456af29..5db4dac 100644 --- a/playwright/tests/utils/keycloak-admin.ts +++ b/playwright/tests/utils/keycloak-admin.ts @@ -11,7 +11,7 @@ let apiContext: APIRequestContext | null = null; async function getApiContext(): Promise { if (!apiContext) { - console.log(`[Keycloak API] Creating new API context with baseURL: ${KEYCLOAK_URL}`); + //console.log(`[Keycloak API] Creating new API context with baseURL: ${KEYCLOAK_URL}`); apiContext = await request.newContext({ baseURL: KEYCLOAK_URL, ignoreHTTPSErrors: true @@ -44,7 +44,7 @@ export async function getKeycloakAdminToken(): Promise { } const data = await response.json() as { access_token: string }; - console.log(`[Keycloak API] Successfully obtained admin token`); + //console.log(`[Keycloak API] Successfully obtained admin token`); return data.access_token; } @@ -195,10 +195,10 @@ export async function checkKeycloakUserExists(username: string): Promise { if (apiContext) { - console.log(`[Keycloak API] Disposing API context`); + //console.log(`[Keycloak API] Disposing API context`); await apiContext.dispose(); apiContext = null; - console.log(`[Keycloak API] API context disposed`); + //console.log(`[Keycloak API] API context disposed`); } else { console.log(`[Keycloak API] No API context to dispose`); } diff --git a/playwright/tests/utils/mas-admin.ts b/playwright/tests/utils/mas-admin.ts index 13cebb8..9139bc3 100644 --- a/playwright/tests/utils/mas-admin.ts +++ b/playwright/tests/utils/mas-admin.ts @@ -10,7 +10,7 @@ let apiContext: APIRequestContext | null = null; async function getApiContext(): Promise { if (!apiContext) { - console.log(`[MAS API] Creating new API context with baseURL: ${MAS_URL}`); + //console.log(`[MAS API] Creating new API context with baseURL: ${MAS_URL}`); apiContext = await request.newContext({ baseURL: MAS_URL, ignoreHTTPSErrors: true @@ -23,7 +23,7 @@ async function getApiContext(): Promise { * Get an admin access token for MAS */ export async function getMasAdminToken(): Promise { - console.log(`[MAS API] Requesting admin token with client ID: ${MAS_ADMIN_CLIENT_ID}`); + //console.log(`[MAS API] Requesting admin token with client ID: ${MAS_ADMIN_CLIENT_ID}`); const apiRequestContext = await getApiContext(); const authHeader = Buffer.from(`${MAS_ADMIN_CLIENT_ID}:${MAS_ADMIN_CLIENT_SECRET}`).toString('base64'); @@ -45,19 +45,20 @@ export async function getMasAdminToken(): Promise { } const data = await response.json() as { access_token: string }; - console.log(`[MAS API] Successfully obtained admin token`); + console.log(`[MAS API] Successfully obtained admin token ${data.access_token}`); return data.access_token; } /** - * Check if a user exists in MAS by email + * Get user details from MAS by email */ -export async function checkMasUserExistsByEmail(email: string): Promise { - console.log(`[MAS API] Checking if user exists with email: ${email}`); +export async function getMasUserByEmail(email: string): Promise { + console.log(`[MAS API] Getting user details for email: ${email}`); const token = await getMasAdminToken(); const apiRequestContext = await getApiContext(); - const response = await apiRequestContext.get( + // Step 1: Get user ID from email + const emailResponse = await apiRequestContext.get( `/api/admin/v1/user-emails?filter[email]=${encodeURIComponent(email)}`, { headers: { @@ -66,28 +67,25 @@ export async function checkMasUserExistsByEmail(email: string): Promise } ); - if (!response.ok()) { - const errorText = await response.text(); - console.error(`[MAS API] Failed to check user: ${response.status()} - ${errorText}`); - throw new Error(`Failed to check MAS user: ${response.status()} - ${errorText}`); + if (!emailResponse.ok()) { + const errorText = await emailResponse.text(); + console.error(`[MAS API] Failed to get user email: ${emailResponse.status()} - ${errorText}`); + throw new Error(`Failed to get MAS user email: ${emailResponse.status()} - ${errorText}`); } - const result = await response.json() as { data: Array<{ id: string }> }; - const exists = result.data.length > 0; - console.log(`[MAS API] User with email ${email} exists: ${exists}`); - return exists; -} + const emailResult = await emailResponse.json(); + if (emailResult.data.length === 0) { + console.log(`[MAS API] No user found with email: ${email}`); + return null; + } -/** - * Get user details from MAS by email - */ -export async function getMasUserByEmail(email: string): Promise { - console.log(`[MAS API] Getting user details for email: ${email}`); - const token = await getMasAdminToken(); - const apiRequestContext = await getApiContext(); - - const response = await apiRequestContext.get( - `/api/admin/v1/user-emails?filter[email]=${encodeURIComponent(email)}`, + // Extract user_id from the attributes + const userId = emailResult.data[0].attributes.user_id; + console.log(`[MAS API] Found user ID: ${userId} for email: ${email}`); + + // Step 2: Get complete user details using the user ID + const userResponse = await apiRequestContext.get( + `/api/admin/v1/users/${userId}`, { headers: { 'Authorization': `Bearer ${token}` @@ -95,21 +93,37 @@ export async function getMasUserByEmail(email: string): Promise { } ); - if (!response.ok()) { - const errorText = await response.text(); - console.error(`[MAS API] Failed to get user details: ${response.status()} - ${errorText}`); - throw new Error(`Failed to get MAS user: ${response.status()} - ${errorText}`); + if (!userResponse.ok()) { + const errorText = await userResponse.text(); + console.error(`[MAS API] Failed to get user details: ${userResponse.status()} - ${errorText}`); + throw new Error(`Failed to get MAS user details: ${userResponse.status()} - ${errorText}`); } - const result = await response.json() as { data: Array }; - const user = result.data.length > 0 ? result.data[0] : null; - console.log(`[MAS API] User found: ${user !== null ? 'Yes' : 'No'}`); - if (user) { - console.log(`[MAS API] User ID: ${user.id}, Username: ${user.attributes?.username || 'N/A'}`); - } + const userResult = await userResponse.json(); + const user = userResult.data; + + console.log(`[MAS API] User found: Yes`); + console.log(`[MAS API] User ID: ${user.id}, Username: ${user.attributes.username || 'N/A'}`); + return user; } +/** + * Check if a user exists in MAS by email + */ +export async function checkMasUserExistsByEmail(email: string): Promise { + console.log(`[MAS API] Checking if user exists with email: ${email}`); + try { + const user = await getMasUserByEmail(email); + const exists = user !== null; + console.log(`[MAS API] User with email ${email} exists: ${exists}`); + return exists; + } catch (error) { + console.error(`[MAS API] Error checking user existence: ${error}`); + return false; + } +} + /** * Wait for a user to be created in MAS * This is useful after OIDC authentication, as there might be a slight delay @@ -184,6 +198,24 @@ export async function createMasUserWithPassword(username: string, email: string, console.error(`[MAS API] Failed to set password for user: ${responsePwd.status()} - ${errorText}`); throw new Error(`Failed to set password for user: ${responsePwd.status()} - ${errorText}`); } + + const responseEmail = await apiRequestContext.post(`/api/admin/v1/user-emails`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + data: { + "user_id": userId, + "email": email + } + }); + + if (!responseEmail.ok()) { + const errorText = await responseEmail.text(); + console.error(`[MAS API] Failed to set email for user: ${responseEmail.status()} - ${errorText}`); + throw new Error(`Failed to set email for user: ${responseEmail.status()} - ${errorText}`); + } + console.log(`[MAS API] User created successfully with ID: ${userId}`); return userId; @@ -217,10 +249,10 @@ export async function deactivateMasUser(userId: string): Promise { */ export async function disposeApiContext(): Promise { if (apiContext) { - console.log(`[MAS API] Disposing API context`); + //console.log(`[MAS API] Disposing API context`); await apiContext.dispose(); apiContext = null; - console.log(`[MAS API] API context disposed`); + //console.log(`[MAS API] API context disposed`); } else { console.log(`[MAS API] No API context to dispose`); } diff --git a/resources/templates/pages/login.html b/resources/templates/pages/login.html index 7a4c5dc..aa9ff53 100644 --- a/resources/templates/pages/login.html +++ b/resources/templates/pages/login.html @@ -76,15 +76,16 @@

{{ _("mas.login.headline") }}

{% set params = next["params"] | default({}) | to_params(prefix="?") %} {% for provider in providers %} {% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %} - diff --git a/start-local-mas.sh b/start-local-mas.sh index e225ec1..bffabd6 100755 --- a/start-local-mas.sh +++ b/start-local-mas.sh @@ -2,6 +2,14 @@ set -e +# Source the .env file to load environment variables +if [ -f .env ]; then + source .env +else + echo "Error: .env file not found. Please create a .env file with the required environment variables." + exit 1 +fi + # Check if MAS_HOME is defined if [ -z "$MAS_HOME" ]; then echo "Error: MAS_HOME environment variable is not defined" diff --git a/start-local-stack.sh b/start-local-stack.sh index e4f5d74..f5cbd00 100755 --- a/start-local-stack.sh +++ b/start-local-stack.sh @@ -22,5 +22,12 @@ #docker run -d --name keycloak -p 8082:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin -v $(pwd)/tchap/keycloak/proconnect-mock-realm.json:/opt/keycloak/data/import/proconnect-mock-realm.json quay.io/keycloak/keycloak:latest start-dev --import-realm --hostname=https://sso.tchapgouv.com mkdir tmp + +# Stops containers and removes containers, networks, volumes, and images +docker compose down + +# (re)creates, starts, and attaches to containers in the background and leaves them running. docker compose up -d + +# View output from containers docker compose logs -f diff --git a/tools/keycloak/proconnect-mock-realm.json b/tools/keycloak/proconnect-mock-realm.json index 2933329..0fe9350 100644 --- a/tools/keycloak/proconnect-mock-realm.json +++ b/tools/keycloak/proconnect-mock-realm.json @@ -68,7 +68,7 @@ }, "clients": [ { - "clientId": "mas", + "clientId": "matrix-authentication-service", "name": "matrix authentication service", "description": "matrix authentication service", "rootUrl": "http://localhost:8080", diff --git a/tools/test-identity-mock.sh b/tools/test-identity-mock.sh new file mode 100755 index 0000000..2463f0a --- /dev/null +++ b/tools/test-identity-mock.sh @@ -0,0 +1,61 @@ +#!/bin/bash + +# Test script for the Matrix Identity Server mock +# This script sends requests to the mock server with different email addresses +# and displays the responses + +# Set the base URL for the mock server +MOCK_URL="http://localhost:8083" + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to make a request and display the result +test_email() { + local email=$1 + local description=$2 + + echo -e "${YELLOW}Testing: $description${NC}" + echo "Email: $email" + + # Make the request + response=$(curl -s "$MOCK_URL/_matrix/identity/api/v1/internal-info?medium=email&address=$email") + + # Display the response + echo "Response:" + echo -e "${GREEN}$response${NC}" + echo "----------------------------------------" +} + +# Check if the mock server is running +echo "Checking if the mock server is running..." +if ! curl -s "$MOCK_URL/__admin/mappings" > /dev/null; then + echo "Error: Mock server is not running at $MOCK_URL" + echo "Please start the mock server with: docker-compose up identity-mock" + exit 1 +fi + +# Display the available mappings +echo "Available mappings:" +curl -s "$MOCK_URL/__admin/mappings" | grep -o '"name":"[^"]*"' | cut -d'"' -f4 || echo "No named mappings found" + +echo "Mock server is running at $MOCK_URL" +echo "----------------------------------------" + +# Test different email addresses +test_email "user@tchapgouv.com" "Agent" +test_email "user@invited.externe.com" "Externe avec invit" +test_email "user@not.invited.externe.com" "Externe sans invit" +test_email "user@wrong.server.com" "User on wrong server" + +# Test with a custom email to demonstrate the template functionality +echo -e "${YELLOW}Enter a custom email address to test (or press Enter to skip):${NC}" +read custom_email + +if [ ! -z "$custom_email" ]; then + test_email "$custom_email" "Custom email test" +fi + +echo "All tests completed!" diff --git a/wiremock/mappings/invited-mapping.json b/wiremock/mappings/invited-mapping.json new file mode 100644 index 0000000..5ae926b --- /dev/null +++ b/wiremock/mappings/invited-mapping.json @@ -0,0 +1,27 @@ +{ + "name": "Invited User Mapping", + "request": { + "method": "GET", + "urlPathPattern": "/_matrix/identity/api/v1/internal-info", + "queryParameters": { + "medium": { + "equalTo": "email" + }, + "address": { + "contains": "@invited.externe.com" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "hs": "tchapgouv.com", + "requires_invite": true, + "invited": true + }, + "headers": { + "Content-Type": "application/json" + } + }, + "priority": 100 +} diff --git a/wiremock/mappings/not-invited-mapping.json b/wiremock/mappings/not-invited-mapping.json new file mode 100644 index 0000000..cce3b52 --- /dev/null +++ b/wiremock/mappings/not-invited-mapping.json @@ -0,0 +1,27 @@ +{ + "name": "Not Invited User Mapping", + "request": { + "method": "GET", + "urlPathPattern": "/_matrix/identity/api/v1/internal-info", + "queryParameters": { + "medium": { + "equalTo": "email" + }, + "address": { + "contains": "@not.invited.externe.com" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "hs": "tchapgouv.com", + "requires_invite": true, + "invited": false + }, + "headers": { + "Content-Type": "application/json" + } + }, + "priority": 100 +} diff --git a/wiremock/mappings/user-mapping.json b/wiremock/mappings/user-mapping.json new file mode 100644 index 0000000..48a856c --- /dev/null +++ b/wiremock/mappings/user-mapping.json @@ -0,0 +1,27 @@ +{ + "name": "User Standard Mapping", + "request": { + "method": "GET", + "urlPathPattern": "/_matrix/identity/api/v1/internal-info", + "queryParameters": { + "medium": { + "equalTo": "email" + }, + "address": { + "contains": "tchapgouv.com" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "hs": "tchapgouv.com", + "requires_invite": false, + "invited": false + }, + "headers": { + "Content-Type": "application/json" + } + }, + "priority": 100 +} diff --git a/wiremock/mappings/wrong-hs-mapping.json b/wiremock/mappings/wrong-hs-mapping.json new file mode 100644 index 0000000..1138b66 --- /dev/null +++ b/wiremock/mappings/wrong-hs-mapping.json @@ -0,0 +1,27 @@ +{ + "name": "User Standard Mapping", + "request": { + "method": "GET", + "urlPathPattern": "/_matrix/identity/api/v1/internal-info", + "queryParameters": { + "medium": { + "equalTo": "email" + }, + "address": { + "contains": "wrong.server.com" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "hs": "wrong.server.com", + "requires_invite": false, + "invited": false + }, + "headers": { + "Content-Type": "application/json" + } + }, + "priority": 100 +} From d27e52b152250b58b3cdb54a875d56654deb1fbb Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 13 May 2025 15:31:47 +0200 Subject: [PATCH 11/71] add tests for email reconciliation --- playwright/fixtures/auth-fixture.ts | 21 ++-- playwright/playwright.config.ts | 2 +- playwright/tests/element-oidc-auth.spec.ts | 6 +- playwright/tests/login-oidc.spec.ts | 112 ++++++++++++++++++--- playwright/tests/login-password.spec.ts | 6 +- playwright/tests/register-oidc.spec.ts | 24 ++--- playwright/tests/utils/auth-helpers.ts | 29 +++--- playwright/tests/utils/config.ts | 11 +- playwright/tests/utils/mas-admin.ts | 70 +++++++++++-- 9 files changed, 210 insertions(+), 71 deletions(-) diff --git a/playwright/fixtures/auth-fixture.ts b/playwright/fixtures/auth-fixture.ts index 0adcafb..f0b061b 100644 --- a/playwright/fixtures/auth-fixture.ts +++ b/playwright/fixtures/auth-fixture.ts @@ -8,7 +8,8 @@ import { STANDARD_EMAIL_DOMAIN, INVITED_EMAIL_DOMAIN, NOT_INVITED_EMAIL_DOMAIN, - WRONG_SERVER_EMAIL_DOMAIN + WRONG_SERVER_EMAIL_DOMAIN, + NUMERIQUE_EMAIL_DOMAIN } from '../tests/utils/config'; /** @@ -27,7 +28,7 @@ function createTestUserFixture(domain: string) { // Clean up the test user after the test await cleanupKeycloakTestUser(user); - console.log(`Cleaned up test user: ${user.username}`); + console.log(`Cleaned up test user: ${user.kc_username}`); } finally { // Dispose API contexts await Promise.all([ @@ -39,15 +40,15 @@ function createTestUserFixture(domain: string) { }; } -function createTestLinkUserFixture(domain: string) { +function createLegacyUserFixture(domain: string) { return async ({}, use: (user: TestUser) => Promise) => { try { const randomSuffix = Math.floor(Math.random() * 10000); const testUser:TestUser = { - username: `test.user${randomSuffix}-${domain}`, - email: `test.user${randomSuffix}@${domain}`, - password: '1234!' + kc_username: `test.user${randomSuffix}-${domain}`, + kc_email: `test.user${randomSuffix}@${domain}`, + kc_password: '1234!' } // Create a test user in Keycloak @@ -58,7 +59,7 @@ function createTestLinkUserFixture(domain: string) { // Clean up the test user after the test await cleanupKeycloakTestUser(user); - console.log(`Cleaned up test user: ${user.username}`); + console.log(`Cleaned up test user: ${user.kc_username}`); } finally { // Dispose API contexts await Promise.all([ @@ -78,7 +79,8 @@ export const test = base.extend<{ testExternalUser: TestUser; testExternalUserWitoutInvit: TestUser; testUserOnWrongServer: TestUser; - testLink:TestUser; + userLegacy:TestUser; + testLinkByFallbackRules: TestUser; }>({ /** * Create a test user in Keycloak before the test and clean it up after @@ -87,7 +89,8 @@ export const test = base.extend<{ testExternalUser: createTestUserFixture(INVITED_EMAIL_DOMAIN), testExternalUserWitoutInvit: createTestUserFixture(NOT_INVITED_EMAIL_DOMAIN), testUserOnWrongServer: createTestUserFixture(WRONG_SERVER_EMAIL_DOMAIN), - testLink:createTestLinkUserFixture(STANDARD_EMAIL_DOMAIN) + userLegacy:createLegacyUserFixture(STANDARD_EMAIL_DOMAIN), + testLinkByFallbackRules:createLegacyUserFixture(NUMERIQUE_EMAIL_DOMAIN) }); export { expect } from '@playwright/test'; diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts index 40f8150..805dc3b 100644 --- a/playwright/playwright.config.ts +++ b/playwright/playwright.config.ts @@ -11,7 +11,7 @@ dotenv.config(); export default defineConfig({ testDir: './tests', /* Maximum time one test can run for */ - timeout: 15 * 1000, + timeout: 30 * 1000, /* Run tests in files in parallel */ fullyParallel: false, /* Fail the build on CI if you accidentally left test.only in the source code. */ diff --git a/playwright/tests/element-oidc-auth.spec.ts b/playwright/tests/element-oidc-auth.spec.ts index 19fe6f0..58e9a0d 100644 --- a/playwright/tests/element-oidc-auth.spec.ts +++ b/playwright/tests/element-oidc-auth.spec.ts @@ -16,7 +16,7 @@ test.describe('Element OIDC register flows', () => { const screenshot_path = 'element_register_oidc'; // Verify the test user doesn't exist in MAS yet - const existsBeforeLogin = await checkMasUserExistsByEmail(testUser.email); + const existsBeforeLogin = await checkMasUserExistsByEmail(testUser.kc_email); expect(existsBeforeLogin).toBe(false); // Perform the OIDC login flow @@ -42,9 +42,9 @@ test.describe('Element OIDC register flows', () => { await verifyUserInMas(testUser); // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(testUser.email); + const existsAfterLogin = await checkMasUserExistsByEmail(testUser.kc_email); expect(existsAfterLogin).toBe(true); - console.log(`Successfully authenticated and verified user ${testUser.username} (${testUser.email})`); + console.log(`Successfully authenticated and verified user ${testUser.kc_username} (${testUser.kc_email})`); }); }); diff --git a/playwright/tests/login-oidc.spec.ts b/playwright/tests/login-oidc.spec.ts index ab67d67..652713c 100644 --- a/playwright/tests/login-oidc.spec.ts +++ b/playwright/tests/login-oidc.spec.ts @@ -7,28 +7,65 @@ import { performPasswordLogin, TestUser } from './utils/auth-helpers'; -import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser } from './utils/mas-admin'; +import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser,oauthLinkExistsByUserId, oauthLinkExistsBySubject } from './utils/mas-admin'; import { SCREENSHOTS_DIR } from './utils/config'; test.describe('Oidc login flows', () => { - test('should link existing MAS account when logging in via OIDC with same email', async ({ page, testLink }) => { - const screenshot_path = 'link_oidc_account'; + test('should link existing MAS account when logging in via OIDC by username', async ({ page, userLegacy: userLegacy }) => { + const screenshot_path = 'link_oidc_account_by_username'; // Create a user in MAS with the same email as the Keycloak user - console.log(`Creating MAS user with same email as Keycloak user: ${testLink.email}`); + console.log(`Creating MAS user with same username as Keycloak user: ${userLegacy.kc_username}`); - const masUserId = await createMasUserWithPassword(testLink.username, testLink.email, testLink.password); - testLink.masId = masUserId; + userLegacy.masId = await createMasUserWithPassword(userLegacy.kc_username, userLegacy.kc_email, userLegacy.kc_password); + + try { + + + // Perform the OIDC login flow + await performOidcLogin(page, userLegacy, screenshot_path); + + // Click the link account button + await page.locator('button[type="submit"]').click(); + + // Since the account already exists, we should be automatically logged in + // Verify we're successfully logged in + await expect(page.locator('text=Mon compte')).toBeVisible(); + + // Take a screenshot of the authenticated state + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); + + // Verify the user in MAS is still the same (account was linked, not created new) + const userAfterLogin = await getMasUserByEmail(userLegacy.kc_email); + expect(userAfterLogin.id).toBe(userLegacy.masId); + expect(await oauthLinkExistsByUserId(userLegacy.masId)).toBe(true); + + console.log(`Successfully verified account linking for user with email: ${userLegacy.kc_email}`); + } finally { + // Clean up the MAS user + await deactivateMasUser(userLegacy.masId); + console.log(`Cleaned up MAS user: ${userLegacy.kc_username}`); + } + }); + + + test('should link existing MAS account when logging in via OIDC with same email but different username', async ({ page, userLegacy: userLegacy }) => { + const screenshot_path = 'link_oidc_account_by_email'; + + // Create a user in MAS with the same email as the Keycloak user + console.log(`Creating MAS user with same email as Keycloak user: ${userLegacy.kc_email}`); + + userLegacy.masId = await createMasUserWithPassword(userLegacy.kc_username+"different_from_email", userLegacy.kc_email, userLegacy.kc_password); try { // Verify the user exists in MAS - const existsBeforeLogin = await checkMasUserExistsByEmail(testLink.email); + const existsBeforeLogin = await checkMasUserExistsByEmail(userLegacy.kc_email); expect(existsBeforeLogin).toBe(true); - console.log(`Confirmed MAS user exists with email: ${testLink.email}`); + console.log(`Confirmed MAS user exists with email: ${userLegacy.kc_email}`); // Perform the OIDC login flow - await performOidcLogin(page, testLink, screenshot_path); + await performOidcLogin(page, userLegacy, screenshot_path); // Click the link account button await page.locator('button[type="submit"]').click(); @@ -41,14 +78,61 @@ test.describe('Oidc login flows', () => { await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); // Verify the user in MAS is still the same (account was linked, not created new) - const userAfterLogin = await getMasUserByEmail(testLink.email); - expect(userAfterLogin.id).toBe(masUserId); + const userAfterLogin = await getMasUserByEmail(userLegacy.kc_email); + expect(userAfterLogin.id).toBe(userLegacy.masId); + //expect(await oauthLinkExistsByUserId(userLegacy.masId)).toBe(true); + expect(await oauthLinkExistsBySubject(userLegacy.kc_username)).toBe(true); + + console.log(`Successfully verified account linking for user with email: ${userLegacy.kc_email}`); + } finally { + // Clean up the MAS user + await deactivateMasUser(userLegacy.masId); + console.log(`Cleaned up MAS user: ${userLegacy.kc_username}`); + } + }); + + + test('should link existing MAS account when logging in via OIDC by email using fallback rules', async ({ page, testLinkByFallbackRules: userLegacy }) => { + const screenshot_path = 'link_oidc_account_by_email_with_fallback_rules'; + + const old_email_domain = "@beta.gouv.fr"; + const old_email = userLegacy.kc_email.replace(/@.*/, old_email_domain); + + + // Create a user in MAS with the same email as the Keycloak user + console.log(`Creating MAS user with old email: ${old_email} whereas email in keycloak is : ${userLegacy.kc_email}`); + + userLegacy.masId = await createMasUserWithPassword(userLegacy.kc_username+"different_from_email", old_email, userLegacy.kc_password); + + try { + // Verify the user exists in MAS + const existsBeforeLogin = await checkMasUserExistsByEmail(old_email); + expect(existsBeforeLogin).toBe(true); + console.log(`Confirmed MAS user exists with email: ${old_email}`); - console.log(`Successfully verified account linking for user with email: ${testLink.email}`); + // Perform the OIDC login flow + await performOidcLogin(page, userLegacy, screenshot_path); + + // Click the link account button + await page.locator('button[type="submit"]').click(); + + // Since the account already exists, we should be automatically logged in + // Verify we're successfully logged in + await expect(page.locator('text=Mon compte')).toBeVisible(); + + // Take a screenshot of the authenticated state + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); + + // Verify the user in MAS is still the same (account was linked, not created new) + const userAfterLogin = await getMasUserByEmail(old_email); + expect(userAfterLogin.id).toBe(userLegacy.masId); + expect(await oauthLinkExistsByUserId(userLegacy.masId)).toBe(true); + + console.log(`Successfully verified account linking for user with email: ${old_email}`); } finally { // Clean up the MAS user - await deactivateMasUser(masUserId); - console.log(`Cleaned up MAS user: ${testLink.username}`); + await deactivateMasUser(userLegacy.masId); + console.log(`Cleaned up MAS user: ${userLegacy.kc_username}`); } }); }); diff --git a/playwright/tests/login-password.spec.ts b/playwright/tests/login-password.spec.ts index c638ad0..83ab4a6 100644 --- a/playwright/tests/login-password.spec.ts +++ b/playwright/tests/login-password.spec.ts @@ -19,7 +19,7 @@ test.describe('password login flows', () => { const user = await createMasTestUser("exemple.com"); try { - console.log(`Created test user in MAS: ${user.username} (${user.email})`); + console.log(`Created test user in MAS: ${user.kc_username} (${user.kc_email})`); // Perform password login await performPasswordLogin(page, user,screenshot_path); @@ -30,11 +30,11 @@ test.describe('password login flows', () => { // Take a screenshot of the authenticated state await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-password-auth-success.png` }); - console.log(`Successfully authenticated with password for user: ${user.username}`); + console.log(`Successfully authenticated with password for user: ${user.kc_username}`); } finally { // Clean up the test user await cleanupMasTestUser(user); - console.log(`Cleaned up test user: ${user.username}`); + console.log(`Cleaned up test user: ${user.kc_username}`); } }); }); diff --git a/playwright/tests/register-oidc.spec.ts b/playwright/tests/register-oidc.spec.ts index dbf4af1..3e89366 100644 --- a/playwright/tests/register-oidc.spec.ts +++ b/playwright/tests/register-oidc.spec.ts @@ -16,7 +16,7 @@ test.describe('OIDC register Flows', () => { const screenshot_path = 'register_oidc'; // Verify the test user doesn't exist in MAS yet - const existsBeforeLogin = await checkMasUserExistsByEmail(testUser.email); + const existsBeforeLogin = await checkMasUserExistsByEmail(testUser.kc_email); expect(existsBeforeLogin).toBe(false); // Perform the OIDC login flow @@ -36,17 +36,17 @@ test.describe('OIDC register Flows', () => { await verifyUserInMas(testUser); // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(testUser.email); + const existsAfterLogin = await checkMasUserExistsByEmail(testUser.kc_email); expect(existsAfterLogin).toBe(true); - console.log(`Successfully authenticated and verified user ${testUser.username} (${testUser.email})`); + console.log(`Successfully authenticated and verified user ${testUser.kc_username} (${testUser.kc_email})`); }); test('register via oidc and get error because extern has no invit', async ({ page, testExternalUserWitoutInvit }) => { const screenshot_path = 'register_oidc_no_invit'; // Verify the test user doesn't exist in MAS yet - const existsBeforeLogin = await checkMasUserExistsByEmail(testExternalUserWitoutInvit.email); + const existsBeforeLogin = await checkMasUserExistsByEmail(testExternalUserWitoutInvit.kc_email); expect(existsBeforeLogin).toBe(false); // Perform the OIDC login flow @@ -60,17 +60,17 @@ test.describe('OIDC register Flows', () => { // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(testExternalUserWitoutInvit.email); + const existsAfterLogin = await checkMasUserExistsByEmail(testExternalUserWitoutInvit.kc_email); expect(existsAfterLogin).toBe(false); - console.log(`Successfully authenticated and verified user ${testExternalUserWitoutInvit.username} (${testExternalUserWitoutInvit.email})`); + console.log(`Successfully authenticated and verified user ${testExternalUserWitoutInvit.kc_username} (${testExternalUserWitoutInvit.kc_email})`); }); test('register via oidc and create user external with invitation in MAS', async ({ page, testExternalUser }) => { const screenshot_path = 'register_oidc_invit'; // Verify the test user doesn't exist in MAS yet - const existsBeforeLogin = await checkMasUserExistsByEmail(testExternalUser.email); + const existsBeforeLogin = await checkMasUserExistsByEmail(testExternalUser.kc_email); expect(existsBeforeLogin).toBe(false); // Perform the OIDC login flow @@ -89,17 +89,17 @@ test.describe('OIDC register Flows', () => { await verifyUserInMas(testExternalUser); // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(testExternalUser.email); + const existsAfterLogin = await checkMasUserExistsByEmail(testExternalUser.kc_email); expect(existsAfterLogin).toBe(true); - console.log(`Successfully authenticated and verified external user ${testExternalUser.username} (${testExternalUser.email})`); + console.log(`Successfully authenticated and verified external user ${testExternalUser.kc_username} (${testExternalUser.kc_email})`); }); test('should authenticate user via oidc and get error because wrong server', async ({ page, testUserOnWrongServer }) => { const screenshot_path = 'register_oidc_wrong_server'; // Verify the test user doesn't exist in MAS yet - const existsBeforeLogin = await checkMasUserExistsByEmail(testUserOnWrongServer.email); + const existsBeforeLogin = await checkMasUserExistsByEmail(testUserOnWrongServer.kc_email); expect(existsBeforeLogin).toBe(false); // Perform the OIDC login flow @@ -112,10 +112,10 @@ test.describe('OIDC register Flows', () => { await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-error-wrong-server.png` }); // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(testUserOnWrongServer.email); + const existsAfterLogin = await checkMasUserExistsByEmail(testUserOnWrongServer.kc_email); expect(existsAfterLogin).toBe(false); - console.log(`Successfully authenticated and verified user ${testUserOnWrongServer.username} (${testUserOnWrongServer.email})`); + console.log(`Successfully authenticated and verified user ${testUserOnWrongServer.kc_username} (${testUserOnWrongServer.kc_email})`); }); }); diff --git a/playwright/tests/utils/auth-helpers.ts b/playwright/tests/utils/auth-helpers.ts index 16a1454..6c45102 100644 --- a/playwright/tests/utils/auth-helpers.ts +++ b/playwright/tests/utils/auth-helpers.ts @@ -7,9 +7,9 @@ import { ELEMENT_URL, generateTestUser, KEYCLOAK_URL, MAS_URL, SCREENSHOTS_DIR } * Test user type */ export interface TestUser { - username: string; - email: string; - password: string; + kc_username: string; + kc_email: string; + kc_password: string; keycloakId?: string; masId?: string; } @@ -18,7 +18,7 @@ export interface TestUser { * Create a test user in Keycloak */ export async function createKeycloakTestUser(user:TestUser): Promise { - const keycloakId = await createKeycloakUser(user.username, user.email, user.password); + const keycloakId = await createKeycloakUser(user.kc_username, user.kc_email, user.kc_password); return { ...user, keycloakId }; } @@ -58,13 +58,12 @@ export async function performOidcLogin(page: Page, user: TestUser, screenshot_pa await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-keycloak-login.png` }); // Fill in the username and password - await page.locator('#username').fill(user.username); - await page.locator('#password').fill(user.password); + await page.locator('#username').fill(user.kc_username); + await page.locator('#password').fill(user.kc_password); // Click the login button await page.locator('button[type="submit"]').click(); - // Wait for redirect back to MAS await page.waitForURL(url => url.toString().includes(MAS_URL)); @@ -112,8 +111,8 @@ export async function performOidcLoginFromElement(page: Page, user: TestUser, sc await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-keycloak-login.png` }); // Fill in the username and password - await page.locator('#username').fill(user.username); - await page.locator('#password').fill(user.password); + await page.locator('#username').fill(user.kc_username); + await page.locator('#password').fill(user.kc_password); // Click the login button await page.locator('button[type="submit"]').click(); @@ -130,7 +129,7 @@ export async function performOidcLoginFromElement(page: Page, user: TestUser, sc * Verify that a user was created in MAS after OIDC authentication */ export async function verifyUserInMas(user: TestUser): Promise { - const masUser = await waitForMasUser(user.email); + const masUser = await waitForMasUser(user.kc_email); user.masId = masUser.id; } @@ -139,7 +138,7 @@ export async function verifyUserInMas(user: TestUser): Promise { */ export async function createMasTestUser(domain:string): Promise { const user = generateTestUser(domain); - const masId = await createMasUserWithPassword(user.username, user.email, user.password); + const masId = await createMasUserWithPassword(user.kc_username, user.kc_email, user.kc_password); return { ...user, masId }; } @@ -161,7 +160,7 @@ export async function cleanupMasTestUser(user: TestUser): Promise { * 4. Wait for successful authentication */ export async function performPasswordLogin(page: Page, user: TestUser, screenshot_path:string): Promise { - console.log(`[Auth] Performing password login for user: ${user.username}`); + console.log(`[Auth] Performing password login for user: ${user.kc_username}`); // Navigate to the login page await page.goto('/login'); @@ -170,8 +169,8 @@ export async function performPasswordLogin(page: Page, user: TestUser, screensho await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-password-login-page.png` }); // Fill in the username and password - await page.locator('input[name="username"]').fill(user.username); - await page.locator('input[name="password"]').fill(user.password); + await page.locator('input[name="username"]').fill(user.kc_username); + await page.locator('input[name="password"]').fill(user.kc_password); // Take a screenshot before submitting await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-password-login-filled.png` }); @@ -185,5 +184,5 @@ export async function performPasswordLogin(page: Page, user: TestUser, screensho // Take a screenshot after successful login await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-password-login-success.png` }); - console.log(`[Auth] Password login successful for user: ${user.username}`); + console.log(`[Auth] Password login successful for user: ${user.kc_username}`); } diff --git a/playwright/tests/utils/config.ts b/playwright/tests/utils/config.ts index 1965b95..09fb794 100644 --- a/playwright/tests/utils/config.ts +++ b/playwright/tests/utils/config.ts @@ -37,6 +37,7 @@ export const STANDARD_EMAIL_DOMAIN = process.env.STANDARD_EMAIL_DOMAIN || 'tchap export const INVITED_EMAIL_DOMAIN = process.env.INVITED_EMAIL_DOMAIN || 'invited.externe.com'; export const NOT_INVITED_EMAIL_DOMAIN = process.env.NOT_INVITED_EMAIL_DOMAIN || 'not.invited.externe.com'; export const WRONG_SERVER_EMAIL_DOMAIN = process.env.WRONG_SERVER_EMAIL_DOMAIN || 'wrong.server.com'; +export const NUMERIQUE_EMAIL_DOMAIN = process.env.NUMERIQUE_EMAIL_DOMAIN || 'numerique.gouv.fr'; // Screenshots directory export const SCREENSHOTS_DIR = process.env.SCREENSHOTS_DIR || 'playwright-results'; @@ -48,13 +49,13 @@ export const BROWSER_LOCALE = process.env.BROWSER_LOCALE || 'fr-FR'; export function generateTestUser(domain:string) { const timestamp = new Date().getTime(); const randomSuffix = Math.floor(Math.random() * 10000); - const username = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}`; - const email = `${username}@${domain}`; + const kc_username = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}`; + const kc_email = `${kc_username}@${domain}`; return { - username, - email, - password: TEST_USER_PASSWORD + kc_username: kc_username, + kc_email: kc_email, + kc_password: TEST_USER_PASSWORD }; } diff --git a/playwright/tests/utils/mas-admin.ts b/playwright/tests/utils/mas-admin.ts index 9139bc3..7f26750 100644 --- a/playwright/tests/utils/mas-admin.ts +++ b/playwright/tests/utils/mas-admin.ts @@ -45,7 +45,7 @@ export async function getMasAdminToken(): Promise { } const data = await response.json() as { access_token: string }; - console.log(`[MAS API] Successfully obtained admin token ${data.access_token}`); + //console.log(`[MAS API] Successfully obtained admin token ${data.access_token}`); return data.access_token; } @@ -53,7 +53,7 @@ export async function getMasAdminToken(): Promise { * Get user details from MAS by email */ export async function getMasUserByEmail(email: string): Promise { - console.log(`[MAS API] Getting user details for email: ${email}`); + //console.log(`[MAS API] Getting user details for email: ${email}`); const token = await getMasAdminToken(); const apiRequestContext = await getApiContext(); @@ -81,7 +81,7 @@ export async function getMasUserByEmail(email: string): Promise { // Extract user_id from the attributes const userId = emailResult.data[0].attributes.user_id; - console.log(`[MAS API] Found user ID: ${userId} for email: ${email}`); + //console.log(`[MAS API] Found user ID: ${userId} for email: ${email}`); // Step 2: Get complete user details using the user ID const userResponse = await apiRequestContext.get( @@ -102,8 +102,8 @@ export async function getMasUserByEmail(email: string): Promise { const userResult = await userResponse.json(); const user = userResult.data; - console.log(`[MAS API] User found: Yes`); - console.log(`[MAS API] User ID: ${user.id}, Username: ${user.attributes.username || 'N/A'}`); + //console.log(`[MAS API] User found: Yes`); + console.log(`[MAS API] User found : ID: ${user.id}, Username: ${user.attributes.username || 'N/A'}`); return user; } @@ -116,7 +116,7 @@ export async function checkMasUserExistsByEmail(email: string): Promise try { const user = await getMasUserByEmail(email); const exists = user !== null; - console.log(`[MAS API] User with email ${email} exists: ${exists}`); + //console.log(`[MAS API] User with email ${email} exists: ${exists}`); return exists; } catch (error) { console.error(`[MAS API] Error checking user existence: ${error}`); @@ -157,7 +157,7 @@ export async function waitForMasUser(email: string, maxAttempts = 10, delayMs = * Create a user in MAS with a password */ export async function createMasUserWithPassword(username: string, email: string, password: string): Promise { - console.log(`[MAS API] Creating user with password: ${username} (${email})`); + console.log(`[MAS API] Creating user with username:${username}, email:${email}, password:${password}`); const token = await getMasAdminToken(); const apiRequestContext = await getApiContext(); @@ -179,7 +179,7 @@ export async function createMasUserWithPassword(username: string, email: string, } const data = await response.json(); - console.log(data.data) + //console.log(data.data) const userId = data.data.id; const responsePwd = await apiRequestContext.post(`/api/admin/v1/users/${userId}/set-password`, { @@ -216,9 +216,11 @@ export async function createMasUserWithPassword(username: string, email: string, throw new Error(`Failed to set email for user: ${responseEmail.status()} - ${errorText}`); } + // Verify the user exists in MAS + const existsBeforeLogin = await checkMasUserExistsByEmail(email); console.log(`[MAS API] User created successfully with ID: ${userId}`); - return userId; + return existsBeforeLogin ? userId : "error"; } /** @@ -244,6 +246,56 @@ export async function deactivateMasUser(userId: string): Promise { console.log(`[MAS API] User deleted successfully`); } +/** + * Check if a oauth link exists + */ +export async function oauthLinkExistsByUserId(userId: string): Promise { + const token = await getMasAdminToken(); + const apiRequestContext = await getApiContext(); + + const response = await apiRequestContext.get(`/api/admin/v1/upstream-oauth-links?filter[user]=${userId}`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok()) { + const errorText = await response.text(); + console.error(`[MAS API] Failed to delete user: ${response.status()} - ${errorText}`); + throw new Error(`Failed to delete MAS user: ${response.status()} - ${errorText}`); + } + const data = await response.json(); + //console.log(data.data) + const links = data.data; + console.log(`[MAS API] Oauth links for user ${userId} : ${JSON.stringify(links)}`); + return links.length == 1 +} + +/** + * Check if a oauth link exists + */ +export async function oauthLinkExistsBySubject(subject: string): Promise { + const token = await getMasAdminToken(); + const apiRequestContext = await getApiContext(); + + const response = await apiRequestContext.get(`/api/admin/v1/upstream-oauth-links?filter[subject]=${subject}`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok()) { + const errorText = await response.text(); + console.error(`[MAS API] Failed to delete user: ${response.status()} - ${errorText}`); + throw new Error(`Failed to delete MAS user: ${response.status()} - ${errorText}`); + } + const data = await response.json(); + //console.log(data.data) + const links = data.data; + console.log(`[MAS API] Oauth links for user ${subject} : ${JSON.stringify(links)}`); + return links.length == 1 +} + /** * Dispose the API context when done */ From 2cdadfc46c4d2dbe6a519912aec8919f0fd90f25 Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 13 May 2025 15:39:06 +0200 Subject: [PATCH 12/71] userLegacy for tests --- playwright/fixtures/auth-fixture.ts | 6 ++++-- playwright/tests/login-oidc.spec.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/playwright/fixtures/auth-fixture.ts b/playwright/fixtures/auth-fixture.ts index f0b061b..6ede975 100644 --- a/playwright/fixtures/auth-fixture.ts +++ b/playwright/fixtures/auth-fixture.ts @@ -40,6 +40,8 @@ function createTestUserFixture(domain: string) { }; } +//legacy users have a username derived from email : +//email : username@domain.com -> username : username-domain.com function createLegacyUserFixture(domain: string) { return async ({}, use: (user: TestUser) => Promise) => { try { @@ -80,7 +82,7 @@ export const test = base.extend<{ testExternalUserWitoutInvit: TestUser; testUserOnWrongServer: TestUser; userLegacy:TestUser; - testLinkByFallbackRules: TestUser; + userLegacyWithFallbackRules: TestUser; }>({ /** * Create a test user in Keycloak before the test and clean it up after @@ -90,7 +92,7 @@ export const test = base.extend<{ testExternalUserWitoutInvit: createTestUserFixture(NOT_INVITED_EMAIL_DOMAIN), testUserOnWrongServer: createTestUserFixture(WRONG_SERVER_EMAIL_DOMAIN), userLegacy:createLegacyUserFixture(STANDARD_EMAIL_DOMAIN), - testLinkByFallbackRules:createLegacyUserFixture(NUMERIQUE_EMAIL_DOMAIN) + userLegacyWithFallbackRules:createLegacyUserFixture(NUMERIQUE_EMAIL_DOMAIN) }); export { expect } from '@playwright/test'; diff --git a/playwright/tests/login-oidc.spec.ts b/playwright/tests/login-oidc.spec.ts index 652713c..b336896 100644 --- a/playwright/tests/login-oidc.spec.ts +++ b/playwright/tests/login-oidc.spec.ts @@ -92,7 +92,7 @@ test.describe('Oidc login flows', () => { }); - test('should link existing MAS account when logging in via OIDC by email using fallback rules', async ({ page, testLinkByFallbackRules: userLegacy }) => { + test('should link existing MAS account when logging in via OIDC by email using fallback rules', async ({ page, userLegacyWithFallbackRules: userLegacy }) => { const screenshot_path = 'link_oidc_account_by_email_with_fallback_rules'; const old_email_domain = "@beta.gouv.fr"; From e5eb4d42a5dc6738cb420cddaba2ead9b15347ff Mon Sep 17 00:00:00 2001 From: mcalinghee Date: Tue, 13 May 2025 17:59:38 +0200 Subject: [PATCH 13/71] change default IDENTITY_MOCK_PORT (#4) --- .env.sample | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.sample b/.env.sample index 0086739..a94b8e1 100644 --- a/.env.sample +++ b/.env.sample @@ -1,5 +1,5 @@ #wiremock port for mocking identity server (sydent) -IDENTITY_MOCK_PORT=8083 +IDENTITY_MOCK_PORT=8090 # postgres port POSTGRES_PORT=5432 From 9ec5bb2e7e8b427f0773037d790c7e3ac8646094 Mon Sep 17 00:00:00 2001 From: olivier Date: Mon, 2 Jun 2025 12:17:34 +0200 Subject: [PATCH 14/71] Add base template and translations fix mailcatcher conf notify the server on templates update work on template rename unused file add log add doc on template add translation revert remove unused config add tchap config add doc --- .env.sample | 5 +- .gitignore | 3 +- conf/config.local.dev.yaml | 240 -------- conf/config.template.yaml | 11 +- docker-compose.yml | 7 +- resources/README.md | 34 +- resources/templates/base.html | 40 ++ resources/templates/pages/login.html | 60 +- resources/translations/en.json | 785 +++++++++++++++++++++++++++ resources/translations/fr.json | 270 +++++++++ start-local-mas-hot-reload.sh | 83 +++ tools/README_TCHAP.md | 20 + tools/build_conf.sh | 12 + 13 files changed, 1299 insertions(+), 271 deletions(-) delete mode 100644 conf/config.local.dev.yaml create mode 100644 resources/templates/base.html create mode 100644 resources/translations/en.json create mode 100644 resources/translations/fr.json create mode 100755 start-local-mas-hot-reload.sh diff --git a/.env.sample b/.env.sample index a94b8e1..8e7f76f 100644 --- a/.env.sample +++ b/.env.sample @@ -6,4 +6,7 @@ POSTGRES_PORT=5432 # keycloak config for mocking proconnect KEYCLOAK_PORT=8082 -KEYCLOAK_HOSTNAME=https://sso.tchapgouv.com \ No newline at end of file +KEYCLOAK_HOSTNAME=https://sso.tchapgouv.com + +# listen to template updates +TEMPLATE_SOURCE=./resources/templates \ No newline at end of file diff --git a/.gitignore b/.gitignore index 38f8aa3..bb2737d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ playwright/playwright-report/ playwright/playwright-results/ playwright/test-results/ playwright/.env -.env \ No newline at end of file +.env +.history/ \ No newline at end of file diff --git a/conf/config.local.dev.yaml b/conf/config.local.dev.yaml deleted file mode 100644 index 2e0f6be..0000000 --- a/conf/config.local.dev.yaml +++ /dev/null @@ -1,240 +0,0 @@ -http: - listeners: - - name: web - resources: - - name: discovery - - name: human - - name: oauth - - name: compat - - name: graphql - - name: assets - # for api admin calls - - name: adminapi - binds: - - address: '[::]:8080' - proxy_protocol: false - - name: internal - resources: - - name: health - # for api admin calls - - name: adminapi - binds: - - host: localhost - port: 8081 - proxy_protocol: false - trusted_proxies: - - 192.168.0.0/16 - - 172.16.0.0/12 - - 10.0.0.0/10 - - 127.0.0.1/8 - - fd00::/8 - - ::1/128 - # public_base: http://[::]:8080/ - # issuer: http://[::]:8080/ - public_base: https://auth.tchapgouv.com/ - issuer: https://auth.tchapgouv.com/ -database: - uri: postgresql://postgres:postgres@localhost/postgres - max_connections: 10 - min_connections: 0 - connect_timeout: 30 - idle_timeout: 600 - max_lifetime: 1800 -email: - from: '"Authentication Service" ' - reply_to: '"Authentication Service" ' - transport: smtp - mode: plain - hostname: 127.0.0.1 - port: 1025 - -secrets: - encryption: eb38b8b9087842b3345269f3c6ca92b2a8d6aa63e3a773d23ed1c9cb45c5ef83 - keys: - - kid: dyrZtIyXSA - key: | - -----BEGIN RSA PRIVATE KEY----- - MIIEogIBAAKCAQEAukLyWSv9KOeBsmIG4ntL/BP+wj5L4GbOyAOEzRBBO0ZbBYVn - 1L/aYQ65cQpQKK0MzH5cn7TvIY0JBh/ZsSm3e7DdhGJzoqPrW6E0/6QqXEl4gN1q - DUCMj2CwAcT9OX1Wt6cNq70+gbqp6+yT+Nn++KPylHa/V9wRxkaV3fyM+XYVeddB - amDR+fBjHgVXQ3xk2ezUBS3AmyKBgETnHufKCkxJ5mXdU9HT0ewg8J2PrgiRBwDj - XP7C5Zif/+NfYPO2mM/b0Y91pl9aXuUHX+/zlqtpcwX/WprEsCaRUa9qWHuiftMf - uelsflCgZ0KumZjzr3wPDEfu8n7WbVEObNxF+QIDAQABAoIBAEl82mNGUMbPuEMq - G+9FmDAnr27x5zvtNA6EHORPUn1Rf94IyXOOEloS1iV8XS3/QLp57I9ycpq5K2NI - M7qLbAIYQP3XXipAJD7ttpxaKABrWGj3cr0xx4NWMXsxPnttMUaaWXF14/CJNjuI - BsW7NLbi8HWU+F9wy26AMOb5mqFdQfk15H3LaM3L3hMuV5DOcA467luwHmuGGTji - VjZg3yZZF2ROtTwwVSB7UCokmW3FZys/U4SyzXSrboUxF9T3PW3Hxm9e1JfFQuLh - rn85Q4IDpRtK2O5ECmKjY0cyvQOVItQytTeVXtSEzgMfK5VpnYMefdCFvhExcsuV - JHT1c/UCgYEAziAPCJYNTXsvo3yEW1iWnAjfi6R7g9aluNDjf4xmva5DTOdZSH1d - AJkzXZtPYXXlR42RT4FzE/ICdjo81aDMrMHwoIiH9p1n7IdDTt3GONYi3VPKGvZd - Ghgdq5jgdedDhF9MximZcWdZOZLCymKME61RuCg18p/MMIJ/FRNBE38CgYEA51R6 - CjNc93qO7hL7AGu+evs3/PVrHsg5iBf8GcGeyNNGkA5TJcYTO075VBDQuWsMZbvx - d0jBjROs8U2AJzgbh6+N/rZACflk8W4LyD3Pw+1coIcckH4hmgN37vkh63qiGX8K - YVe8CrbGliBB2OccXsdJVDe0f45kte0eJh6cAocCgYAH9d7+wuTCoEZHtxBZgsNW - RVV0zCZlAg4mZBLVIzP4kVlSCAE/tm+4DTKZo9zd87KmH8aD3oj2NTt5G2isC2i8 - J0VGvd8aXBveW57y1cfI/CQejhTZE7imwFWtAdtxUjweSZvqb0LYyVf9zDgvnryw - KdplFVB4DUnSece0pai2uwKBgDzV335dQZ6nsXz0quPScfZ/qJqyo+gledPLkvXn - EG35+f2ads1hSN95BmLQRUPt3gXHJlpbXONQAFQ5MHGf9MV7KpmIrlCxMJW5fgm8 - D66T9p8UyTNKqGWLcff7tqrpxkV0PnOZEg+zP4htlUOIi9J1EFjAiYxeEygw4pPd - yuNzAoGAI0lLE32iIm+j5byFCKRuS6cQmDBzUJxZiCuLsuZEINYdz2nxKZqd9VtA - rOXzo8vJuGLF1hjf1/C66F3EnPxcclPL10vLCE8RbfQYVwBxw7MG9BLJXIlMjb2V - 7CjVDE4Gaa96tChg1pepuJcOEuWez3o3Ard9oZ4Z9sm7VJzmuoY= - -----END RSA PRIVATE KEY----- - - kid: O1hkajPW2v - key: | - -----BEGIN EC PRIVATE KEY----- - MHcCAQEEINsgMBFDIrIzqOIWuR94TCi5MTH1FS8wfgatu9BsO2jVoAoGCCqGSM49 - AwEHoUQDQgAEldEtvZaXtblpUdHpKKQiH7z9ADC55H0yrCYyQsLXbt14lI2NuseX - MWsvSLBzkbEetDxkmKh0bhOfrdwv9x5SwA== - -----END EC PRIVATE KEY----- - - kid: 2nhe3z2925 - key: | - -----BEGIN EC PRIVATE KEY----- - MIGkAgEBBDDTVngHypOwUnPOGXeskQJhdSLLPBCM+mkSvzr2SZ7Kjm3hftvs2s7J - gZBOZwXyoaKgBwYFK4EEACKhZANiAAQ3WGOQs3EqO2x4X7PBWs6Lw3qdmRLHqblc - Zplh3wYPDOoUMvD99Snxz43t5sK6kphLBL262/srx/UPT1McLUxBMlBvBUbBEKHX - a8icrL13yIwflquj0EHrE7czFJw1txs= - -----END EC PRIVATE KEY----- - - kid: 50aRR3QVqx - key: | - -----BEGIN EC PRIVATE KEY----- - MHQCAQEEIN8bErXY1sWEJ1y9KoYcpcUImIjpS/ay3pEugYPfr3Y/oAcGBSuBBAAK - oUQDQgAEMHcshHVFbMSEyyt3ptIdAhnrg+XlQskZ33hZvdtzm6I0wW8H8zslMp+I - t0KYCeIQ7HTPtgJAOsKxEPBfmVXZmA== - -----END EC PRIVATE KEY----- -passwords: - enabled: true - schemes: - - version: 1 - algorithm: bcrypt - secret: "secret01" - - version: 2 - algorithm: argon2id - minimum_complexity: 3 -matrix: - homeserver: tchapgouv.com - secret: '/DjWc4D3yyqgjYN8tum65g' - endpoint: https://matrix.tchapgouv.com/ - -clients: - - client_id: 0000000000000000000SYNAPSE - client_auth_method: client_secret_basic - client_secret: '/DjWc4D3yyqgjYN8tum65g' - - # for api admin calls - - client_id: 01J44RKQYM4G3TNVANTMTDYTX6 - client_auth_method: client_secret_basic - client_secret: phoo8ahneir3ohY2eigh4xuu6Oodaewi - - -policy: - data: - admin_clients: - # for api admin calls - - 01J44RKQYM4G3TNVANTMTDYTX6 - -account: - # Whether users are allowed to change their email addresses. - # - # Defaults to `true`. - email_change_allowed: false - - # Whether users are allowed to change their display names - # - # Defaults to `true`. - # This should be in sync with the policy in the homeserver configuration. - displayname_change_allowed: false - - # Whether to enable self-service password registration - # - # Defaults to `false`. - # This has no effect if password login is disabled. - password_registration_enabled: true - - # Whether users are allowed to change their passwords - # - # Defaults to `true`. - # This has no effect if password login is disabled. - password_change_allowed: true - - # Whether email-based password recovery is enabled - # - # Defaults to `false`. - # This has no effect if password login is disabled. - password_recovery_enabled: true - - # Whether users can log in with their email address. - # - # Defaults to `false`. - # This has no effect if password login is disabled. - login_with_email_allowed: true - -templates: - # From where to load the templates - # This is relative to the current working directory, *not* the config file - path: "WILL BE REPLACED BY build_conf.sh" - - # Path to the frontend assets manifest file - # assets_manifest: "/to/manifest.json" - - # # From where to load the translation files - # # Default in Docker distribution: `/usr/local/share/mas-cli/translations/` - # # Default in pre-built binaries: `./share/translations/` - # # Default in locally-built binaries: `./translations/` - # translations_path: /to/translations - -upstream_oauth2: - providers: - - id: "01JK5MR1SD21MAQY4PWMFG283W" - human_name: Proconnect (mock) - issuer: "https://sso.tchapgouv.com/realms/proconnect-mock" - token_endpoint_auth_method: client_secret_basic - client_id: "matrix-authentication-service" - client_secret: "HrJ1NZ0AbkHuWWjyRHh7X2lzn3S8eagt" - scope: "openid profile email" - claims_imports: - localpart: - action: require - template: "{{ user.email | email_to_mxid_localpart }}" - displayname: - action: require - template: "{{ user.email | email_to_display_name }}" - email: - action: require - template: "{{ user.email }}" - set_email_verification: always - allow_existing_users: true - -telemetry: - tracing: - # # List of propagators to use for extracting and injecting trace contexts - # propagators: - # # Propagate according to the W3C Trace Context specification - # - tracecontext - # # Propagate according to the W3C Baggage specification - # - baggage - # # Propagate trace context with Jaeger compatible headers - # - jaeger - - # # The default: don't export traces - exporter: none - - # Export traces to an OTLP-compatible endpoint - #exporter: otlp - #endpoint: https://localhost:4318 - metrics: - # The default: don't export metrics - exporter: none - - # Export metrics to an OTLP-compatible endpoint - #exporter: otlp - #endpoint: https://localhost:4317 - - # Export metrics by exposing a Prometheus endpoint - # This requires mounting the `prometheus` resource to an HTTP listener - #exporter: prometheus - - # sentry: - # # DSN to use for sending errors and crashes to Sentry - # dsn: https://public@host:port/1 - diff --git a/conf/config.template.yaml b/conf/config.template.yaml index 2e0f6be..216b75a 100644 --- a/conf/config.template.yaml +++ b/conf/config.template.yaml @@ -181,7 +181,7 @@ templates: # # Default in Docker distribution: `/usr/local/share/mas-cli/translations/` # # Default in pre-built binaries: `./share/translations/` # # Default in locally-built binaries: `./translations/` - # translations_path: /to/translations + translations_path: "WILL BE REPLACED BY build_conf.sh" upstream_oauth2: providers: @@ -194,11 +194,14 @@ upstream_oauth2: scope: "openid profile email" claims_imports: localpart: - action: require - template: "{{ user.email | email_to_mxid_localpart }}" + action: force + template: "{{ user.preferred_username }}" + #action: require + #template: "{{ user.email | email_to_mxid_localpart }}" displayname: action: require - template: "{{ user.email | email_to_display_name }}" + template: "{{ user.name }}" + #template: "{{ user.email | email_to_display_name }}" email: action: require template: "{{ user.email }}" diff --git a/docker-compose.yml b/docker-compose.yml index 78517b3..798a6c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,12 @@ services: mailcatcher: image: sj26/mailcatcher - network_mode: host + #network_mode: host + ports: + - "1025:1025" + - "1080:1080" + networks: + - tchap-network identity-mock: image: wiremock/wiremock:2.35.0 diff --git a/resources/README.md b/resources/README.md index d1055c8..29e966d 100644 --- a/resources/README.md +++ b/resources/README.md @@ -1,4 +1,32 @@ -custom templates for tchap -#### +# Custom templates for tchap -manifest.json +## Templates + +MAS templates are overriden with the script `tools/build_conf.sh` + +MAS templates needs the static resources from the frontend app. + +To reload automatically the server when a template is modified, use the script `start-local-mas-hot-reload.sh` + +## Static resources + +The manifest.json is extended in the vite config : `$MAS_HOME/frontend/tchap/vite.tchap.config.ts` + +Serve directory is defined in the config.yaml, by default it is `path: "./frontend/dist/"` + +``` +http: + listeners: + - name: web + resources: + - name: assets + path: "/resources/manifest.json" + +``` + +See in the logs + +``` +2025-06-03T12:28:33.967776Z INFO mas_cli::commands::server:297 Listening on http://[::]:8080 with resources [Discovery, Human, OAuth, Compat, GraphQL { playground: false, undocumented_oauth2_access: false }, Assets { path: "./frontend/dist/" }, AdminApi] + +``` diff --git a/resources/templates/base.html b/resources/templates/base.html new file mode 100644 index 0000000..73259f8 --- /dev/null +++ b/resources/templates/base.html @@ -0,0 +1,40 @@ +{# +Copyright 2024 New Vector Ltd. +Copyright 2021-2024 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% set _ = translator(lang) %} + +{% import "components/button.html" as button %} +{% import "components/field.html" as field %} +{% import "components/back_to_client.html" as back_to_client %} +{% import "components/logout.html" as logout %} +{% import "components/errors.html" as errors %} +{% import "components/icon.html" as icon %} +{% import "components/scope.html" as scope %} +{% import "components/captcha.html" as captcha %} + + + + + + + {% block title %}{{ _("app.name") }}{% endblock title %} + + {{ include_asset('src/shared.css') | indent(4) | safe }} + {{ include_asset('src/templates.css') | indent(4) | safe }} + {#:tchap: #} + {{ include_asset('tchap/src/tchap.css') | indent(4) | safe }} + {#:tchap:end #} + {{ captcha.head() }} + + + + + diff --git a/resources/templates/pages/login.html b/resources/templates/pages/login.html index aa9ff53..a0a6a82 100644 --- a/resources/templates/pages/login.html +++ b/resources/templates/pages/login.html @@ -13,10 +13,11 @@ {% block content %}
+ {% if next and next.kind == "link_upstream" %}

{{ _("mas.login.link.headline") }}

@@ -26,11 +27,44 @@

{{ _("mas.login.link.headline") }}

{% else %}

{{ _("mas.login.headline") }}

-

{{ _("mas.login.description") }}

+
{% endif %}
+ {% if providers %} + {% set params = next["params"] | default({}) | to_params(prefix="?") %} + {% for provider in providers %} + {% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %} +
+ +

+ + Qu’est-ce que ProConnect ? + +

+
+ + + {% endfor %} + {% endif %} + + + {{ field.separator() }} + +
{% if form.errors is not empty %} {% for error in form.errors %} @@ -68,26 +102,9 @@

{{ _("mas.login.headline") }}

{{ button.button(text=_("action.continue")) }} {% endif %} - {% if features.password_login and providers %} - {{ field.separator() }} - {% endif %} - {% if providers %} - {% set params = next["params"] | default({}) | to_params(prefix="?") %} - {% for provider in providers %} - {% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %} - - - {{ logo(provider.brand_name) }} - {{ _("mas.login.continue_with_provider", provider=name) }} - - {% endfor %} - {% endif %} + +
{% if (not next or next.kind != "link_upstream") and features.password_registration %} @@ -96,6 +113,7 @@

{{ _("mas.login.headline") }}

{{ _("mas.login.call_to_register") }}

+ {% set params = next["params"] | default({}) | to_params(prefix="?") %} {{ button.link_text(text=_("action.create_account"), href="/register" ~ params) }} diff --git a/resources/translations/en.json b/resources/translations/en.json new file mode 100644 index 0000000..7edb50a --- /dev/null +++ b/resources/translations/en.json @@ -0,0 +1,785 @@ +{ + "action": { + "back": "Back", + "@back": { + "context": "pages/recovery/disabled.html:22:32-48" + }, + "cancel": "Cancel", + "@cancel": { + "context": "pages/consent.html:69:11-29, pages/device_consent.html:127:13-31, pages/policy_violation.html:44:13-31" + }, + "continue": "Continue", + "@continue": { + "context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, pages/login.html:102:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48" + }, + "create_account": "Create Account", + "@create_account": { + "context": "pages/login.html:118:33-59, pages/upstream_oauth2/do_register.html:192:26-52" + }, + "sign_in": "Sign in", + "@sign_in": { + "context": "pages/account/deactivated.html:23:28-47, pages/account/locked.html:23:28-47, pages/index.html:30:26-45" + }, + "sign_out": "Sign out", + "@sign_out": { + "context": "pages/account/logged_out.html:22:28-48, pages/consent.html:65:28-48, pages/device_consent.html:136:30-50, pages/index.html:28:28-48, pages/policy_violation.html:38:28-48, pages/sso.html:45:28-48, pages/upstream_oauth2/link_mismatch.html:24:24-44, pages/upstream_oauth2/suggest_link.html:32:26-46" + }, + "skip": "Skip", + "@skip": { + "context": "pages/register/steps/display_name.html:49:28-44" + }, + "start_over": "Start over", + "@start_over": { + "context": "pages/recovery/consumed.html:22:32-54, pages/recovery/expired.html:30:32-54, pages/register/steps/email_in_use.html:28:32-54" + } + }, + "app": { + "human_name": "Matrix Authentication Service", + "@human_name": { + "context": "pages/index.html:15:29-48", + "description": "Human readable name of the application" + }, + "name": "matrix-authentication-service", + "@name": { + "context": "app.html:17:14-27, base.html:25:31-44", + "description": "Name of the application" + }, + "technical_description": "OpenID Connect discovery document: %(discovery_url)s", + "@technical_description": { + "context": "pages/index.html:17:13-72", + "description": "Introduction text displayed on the home page" + } + }, + "branding": { + "privacy_policy": { + "alt": "Link to the service privacy policy", + "@alt": { + "context": "components/footer.html:13:83-115" + }, + "link": "Privacy Policy", + "@link": { + "context": "components/footer.html:14:14-47" + } + }, + "terms_and_conditions": { + "alt": "Link to the service terms and conditions", + "@alt": { + "context": "components/footer.html:23:80-118" + }, + "link": "Terms & Conditions", + "@link": { + "context": "components/footer.html:24:14-53" + } + } + }, + "common": { + "display_name": "Display Name", + "@display_name": { + "context": "pages/register/steps/display_name.html:34:35-59, pages/upstream_oauth2/do_register.html:146:37-61" + }, + "email_address": "Email address", + "@email_address": { + "context": "pages/recovery/start.html:34:33-58, pages/register/password.html:38:33-58, pages/upstream_oauth2/do_register.html:114:37-62" + }, + "loading": "Loading…", + "@loading": { + "context": "form_post.html:14:27-46" + }, + "mxid": "Matrix ID", + "@mxid": { + "context": "pages/upstream_oauth2/do_register.html:93:35-51" + }, + "password": "Password", + "@password": { + "context": "pages/login.html:90:37-57, pages/reauth.html:28:35-55, pages/register/password.html:42:33-53" + }, + "password_confirm": "Confirm password", + "@password_confirm": { + "context": "pages/register/password.html:46:33-61" + }, + "username": "Username", + "@username": { + "context": "pages/login.html:84:37-57, pages/register/index.html:30:35-55, pages/register/password.html:34:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:106:39-59" + } + }, + "error": { + "unexpected": "Unexpected error", + "@unexpected": { + "context": "pages/error.html:22:29-50", + "description": "Error message displayed when an unexpected error occurs" + } + }, + "mas": { + "account": { + "deactivated": { + "description": "This account (%(mxid)s) has been deleted. If this is not expected, contact your server administrator.", + "@description": { + "context": "pages/account/deactivated.html:20:27-78" + }, + "heading": "Account deleted", + "@heading": { + "context": "pages/account/deactivated.html:18:29-65" + } + }, + "locked": { + "description": "This account (%(mxid)s) has been locked. If this is not expected, contact your server administrator.", + "@description": { + "context": "pages/account/locked.html:20:27-73" + }, + "heading": "Account locked", + "@heading": { + "context": "pages/account/locked.html:18:29-60" + } + }, + "logged_out": { + "description": "This session has been terminated. Sign out to be able to log back in", + "@description": { + "context": "pages/account/logged_out.html:19:27-66" + }, + "heading": "Session terminated", + "@heading": { + "context": "pages/account/logged_out.html:18:29-64" + } + } + }, + "back_to_homepage": "Go back to the homepage", + "@back_to_homepage": { + "context": "pages/404.html:16:29-54" + }, + "captcha": { + "noscript": "This form is protected by a CAPTCHA and requires JavaScript to be enabled to submit it. Please enable JavaScript in your browser and reload this page.", + "@noscript": { + "context": "components/captcha.html:13:11-36" + } + }, + "change_password": { + "change": "Change password", + "@change": { + "description": "Button to change the user's password" + }, + "confirm": "Confirm password", + "@confirm": { + "description": "Confirmation field for the new password" + }, + "current": "Current password", + "@current": { + "description": "Field for the user's current password" + }, + "description": "This will change the password on your account.", + "@description": {}, + "heading": "Change my password", + "@heading": { + "description": "Heading on the change password page" + }, + "new": "New password", + "@new": { + "description": "Field for the user's new password" + } + }, + "choose_display_name": { + "description": "This is the name other people will see. You can change this at any time.", + "@description": { + "context": "pages/register/steps/display_name.html:17:25-65", + "description": "During the registration flow, the user is asked to choose a display name. This is the description of that form." + }, + "headline": "Choose your display name", + "@headline": { + "context": "pages/register/steps/display_name.html:16:27-64", + "description": "During the registration flow, the user is asked to choose a display name. This is the headline of that form." + } + }, + "consent": { + "client_wants_access": "%(client_name)s at %(redirect_uri)s wants to access your account.", + "@client_wants_access": { + "context": "pages/consent.html:27:11-122" + }, + "heading": "Allow access to your account?", + "@heading": { + "context": "pages/consent.html:25:27-51, pages/device_consent.html:28:29-53" + }, + "make_sure_you_trust": "Make sure that you trust %(client_name)s.", + "@make_sure_you_trust": { + "context": "pages/consent.html:38:81-142, pages/device_consent.html:104:83-144" + }, + "this_will_allow": "This will allow %(client_name)s to:", + "@this_will_allow": { + "context": "pages/consent.html:28:11-68, pages/device_consent.html:94:13-70" + }, + "you_may_be_sharing": "You may be sharing sensitive information with this site or app.", + "@you_may_be_sharing": { + "context": "pages/consent.html:39:7-42, pages/device_consent.html:105:9-44" + } + }, + "device_card": { + "access_requested": "Access requested", + "@access_requested": { + "context": "pages/device_consent.html:82:34-71" + }, + "device_code": "Code", + "@device_code": { + "context": "pages/device_consent.html:86:34-66" + }, + "generic_device": "Device", + "@generic_device": { + "context": "pages/device_consent.html:70:22-57" + }, + "ip_address": "IP address", + "@ip_address": { + "context": "pages/device_consent.html:77:36-67" + } + }, + "device_code_link": { + "description": "Link a device", + "@description": { + "context": "pages/device_link.html:19:25-62" + }, + "headline": "Enter the code displayed on your device", + "@headline": { + "context": "pages/device_link.html:18:27-61" + } + }, + "device_consent": { + "another_device_access": "Another device wants to access your account.", + "@another_device_access": { + "context": "pages/device_consent.html:93:13-58" + }, + "denied": { + "description": "You denied access to %(client_name)s. You can close this window.", + "@description": { + "context": "pages/device_consent.html:147:27-94" + }, + "heading": "Access denied", + "@heading": { + "context": "pages/device_consent.html:146:29-67" + } + }, + "granted": { + "description": "You granted access to %(client_name)s. You can close this window.", + "@description": { + "context": "pages/device_consent.html:158:27-95" + }, + "heading": "Access granted", + "@heading": { + "context": "pages/device_consent.html:157:29-68" + } + } + }, + "device_display_name": { + "client_on_device": "%(client_name)s on %(device_name)s", + "@client_on_device": { + "context": "device_name.txt:28:4-99", + "description": "The automatic device name generated for a client, e.g. 'Element on iPhone'" + }, + "name_for_platform": "%(name)s for %(platform)s", + "@name_for_platform": { + "context": "device_name.txt:19:10-102", + "description": "Part of the automatic device name for the platfom, e.g. 'Safari for macOS'" + }, + "unknown_device": "Unknown device", + "@unknown_device": { + "context": "device_name.txt:24:8-51" + } + }, + "email_in_use": { + "description": "If you have forgotten your account credentials, you can recover your account. You can also start over and use a different email address.", + "@description": { + "context": "pages/register/steps/email_in_use.html:22:13-46" + }, + "title": "The email address %(email)s is already in use", + "@title": { + "context": "pages/register/steps/email_in_use.html:19:13-53" + } + }, + "emails": { + "greeting": "Hello %(username)s,", + "@greeting": { + "context": "emails/verification.html:17:3-64, emails/verification.txt:17:3-64", + "description": "Greeting at the top of emails sent to the user" + }, + "recovery": { + "click_button": "Click on the button below to create a new password:", + "@click_button": { + "context": "emails/recovery.html:28:7-44" + }, + "copy_link": "Copy the following link and paste it into a browser to create a new password:", + "@copy_link": { + "context": "emails/recovery.html:45:49-83, emails/recovery.txt:12:3-37" + }, + "create_new_password": "Create new password", + "@create_new_password": { + "context": "emails/recovery.html:43:9-53" + }, + "fallback": "The button doesn't work for you?", + "@fallback": { + "context": "emails/recovery.html:45:9-42" + }, + "headline": "You requested a password reset for your %(server_name)s account.", + "@headline": { + "context": "emails/recovery.html:26:7-74, emails/recovery.txt:10:3-70" + }, + "subject": "Reset your account password (%(mxid)s)", + "@subject": { + "context": "emails/recovery.subject:14:3-46" + }, + "you_can_ignore": "If you didn't ask for a new password, you can ignore this email. Your current password will continue to work.", + "@you_can_ignore": { + "context": "emails/recovery.html:50:7-46, emails/recovery.txt:16:3-42" + } + }, + "verify": { + "body_html": "Your verification code to confirm this email address is: %(code)s", + "@body_html": { + "context": "emails/verification.html:19:3-66", + "description": "The body of the email sent to verify an email address (HTML)" + }, + "body_text": "Your verification code to confirm this email address is: %(code)s", + "@body_text": { + "context": "emails/verification.txt:19:3-66", + "description": "The body of the email sent to verify an email address (text)" + }, + "subject": "Your email verification code is: %(code)s", + "@subject": { + "context": "emails/verification.subject:11:3-64", + "description": "The subject line of the email sent to verify an email address" + } + } + }, + "errors": { + "captcha": "CAPTCHA verification failed, please try again", + "@captcha": { + "context": "components/errors.html:19:7-30" + }, + "denied_policy": "Denied by policy: %(policy)s", + "@denied_policy": { + "context": "components/errors.html:17:7-58, components/field.html:85:19-70" + }, + "email_banned": "Email is banned by the server policy", + "@email_banned": { + "context": "components/field.html:83:19-47" + }, + "email_domain_banned": "Email domain is banned by the server policy", + "@email_domain_banned": { + "context": "components/field.html:79:19-54" + }, + "email_domain_not_allowed": "Email domain is not allowed by the server policy", + "@email_domain_not_allowed": { + "context": "components/field.html:77:19-59" + }, + "email_not_allowed": "Email is not allowed by the server policy", + "@email_not_allowed": { + "context": "components/field.html:81:19-52" + }, + "field_required": "This field is required", + "@field_required": { + "context": "components/field.html:60:17-47" + }, + "invalid_credentials": "Invalid credentials", + "@invalid_credentials": { + "context": "components/errors.html:11:7-42" + }, + "password_mismatch": "Password fields don't match", + "@password_mismatch": { + "context": "components/errors.html:13:7-40, components/field.html:88:17-50" + }, + "rate_limit_exceeded": "You've made too many requests in a short period. Please wait a few minutes and try again.", + "@rate_limit_exceeded": { + "context": "components/errors.html:15:7-42, pages/recovery/progress.html:26:11-46" + }, + "username_all_numeric": "Username cannot consist solely of numbers", + "@username_all_numeric": { + "context": "components/field.html:71:19-55" + }, + "username_banned": "Username is banned by the server policy", + "@username_banned": { + "context": "components/field.html:73:19-50", + "description": "Error message shown on registration, when the username matches a pattern that is banned by the server policy." + }, + "username_invalid_chars": "Username contains invalid characters. Use lowercase letters, numbers, dashes and underscores only.", + "@username_invalid_chars": { + "context": "components/field.html:69:19-57" + }, + "username_not_allowed": "Username is not allowed by the server policy", + "@username_not_allowed": { + "context": "components/field.html:75:19-55", + "description": "Error message shown on registration, when the username *does not match* any of the patterns that are allowed by the server policy." + }, + "username_taken": "This username is already taken", + "@username_taken": { + "context": "components/field.html:62:17-47" + }, + "username_too_long": "Username is too long", + "@username_too_long": { + "context": "components/field.html:67:19-52" + }, + "username_too_short": "Username is too short", + "@username_too_short": { + "context": "components/field.html:65:19-53" + } + }, + "login": { + "call_to_register": "Don't have an account yet?", + "@call_to_register": { + "context": "pages/login.html:113:13-44" + }, + "continue_with_provider": "Continue with %(provider)s", + "@continue_with_provider": { + "context": "pages/login.html:58:11-63, pages/register/index.html:53:15-67", + "description": "Button to log in with an upstream provider" + }, + "description": "Please sign in to continue:", + "@description": { + "context": "pages/login.html:30:34-60" + }, + "forgot_password": "Forgot password?", + "@forgot_password": { + "context": "pages/login.html:95:35-65", + "description": "On the login page, link to the account recovery process" + }, + "headline": "Sign in", + "@headline": { + "context": "pages/login.html:29:31-54" + }, + "link": { + "description": "Linking your %(provider)s account", + "@description": { + "context": "pages/login.html:25:29-75" + }, + "headline": "Sign in to link", + "@headline": { + "context": "pages/login.html:23:31-59" + } + }, + "no_login_methods": "No login methods available.", + "@no_login_methods": { + "context": "pages/login.html:124:11-42" + }, + "username_or_email": "Username or Email", + "@username_or_email": { + "context": "pages/login.html:80:37-69" + } + }, + "navbar": { + "my_account": "My account", + "@my_account": { + "context": "pages/index.html:27:26-52" + }, + "register": "Create an account", + "@register": { + "context": "pages/index.html:33:36-60" + }, + "signed_in_as": "Signed in as %(username)s.", + "@signed_in_as": { + "context": "pages/index.html:24:11-79", + "description": "Displayed in the navbar when the user is signed in" + } + }, + "not_found": { + "description": "The page you were looking for doesn't exist or has been moved", + "@description": { + "context": "pages/404.html:14:8-38" + }, + "heading": "Page not found", + "@heading": { + "context": "pages/404.html:13:39-65" + } + }, + "not_you": "Not %(username)s?", + "@not_you": { + "context": "pages/consent.html:62:11-67, pages/device_consent.html:133:13-69, pages/sso.html:42:11-67", + "description": "Suggestions for the user to log in as a different user" + }, + "or_separator": "Or", + "@or_separator": { + "context": "components/field.html:107:10-31", + "description": "Separator between the login methods" + }, + "policy_violation": { + "description": "This might be because of the client which authored the request, the currently logged in user, or the request itself.", + "@description": { + "context": "pages/policy_violation.html:19:25-62", + "description": "Displayed when an authorization request is denied by the policy" + }, + "heading": "The authorization request was denied the policy enforced by this service", + "@heading": { + "context": "pages/policy_violation.html:18:27-60", + "description": "Displayed when an authorization request is denied by the policy" + }, + "logged_as": "Logged as %(username)s", + "@logged_as": { + "context": "pages/policy_violation.html:35:11-86" + } + }, + "recovery": { + "consumed": { + "description": "To create a new password, start over and select “Forgot password”.", + "@description": { + "context": "pages/recovery/consumed.html:19:25-63", + "description": "Description on the error page shown when a user tries to use a recovery link that has already been used" + }, + "heading": "The link to reset your password has already been used", + "@heading": { + "context": "pages/recovery/consumed.html:18:27-61", + "description": "Title on the error page shown when a user tries to use a recovery link that has already been used" + } + }, + "disabled": { + "description": "If you have lost your credentials, please contact the administrator to recover your account.", + "@description": { + "context": "pages/recovery/disabled.html:19:25-63" + }, + "heading": "Account recovery is disabled", + "@heading": { + "context": "pages/recovery/disabled.html:18:27-61" + } + }, + "expired": { + "description": "Request a new email that will be sent to: %(email)s.", + "@description": { + "context": "pages/recovery/expired.html:19:46-104", + "description": "Description on the page shown when a user tries to use an expired recovery link" + }, + "heading": "The link to reset your password has expired", + "@heading": { + "context": "pages/recovery/expired.html:18:27-60", + "description": "Title on the page shown when a user tries to use an expired recovery link" + }, + "resend_email": "Resend email", + "@resend_email": { + "context": "pages/recovery/expired.html:27:28-66" + } + }, + "finish": { + "confirm": "Enter new password again", + "@confirm": { + "context": "pages/recovery/finish.html:41:33-65", + "description": "Label for the password confirmation field" + }, + "description": "Choose a new password for your account.", + "@description": { + "context": "pages/recovery/finish.html:19:25-61", + "description": "Description for the final password recovery page" + }, + "heading": "Reset your password", + "@heading": { + "context": "pages/recovery/finish.html:18:27-59", + "description": "Heading for the final password recovery page" + }, + "new": "New password", + "@new": { + "context": "pages/recovery/finish.html:37:33-61", + "description": "Label for the new password field" + }, + "save_and_continue": "Save and continue", + "@save_and_continue": { + "context": "pages/recovery/finish.html:45:26-68", + "description": "Button to save the new password and continue" + } + }, + "progress": { + "change_email": "Try a different email", + "@change_email": { + "context": "pages/recovery/progress.html:35:33-72", + "description": "Button to change the email address for the password recovery link" + }, + "description": "We sent an email with a link to reset your password if there's an account using %(email)s.", + "@description": { + "context": "pages/recovery/progress.html:19:46-105", + "description": "The description of the password recovery page, informing the user that an email has been sent to reset their password" + }, + "heading": "Check your email", + "@heading": { + "context": "pages/recovery/progress.html:18:27-61", + "description": "The title of the password recovery page, informing the user that an email has been sent to reset their password" + }, + "resend_email": "Resend email", + "@resend_email": { + "context": "pages/recovery/progress.html:32:36-75", + "description": "Button to resend the email with the password recovery link" + } + }, + "start": { + "description": "An email will be sent with a link to reset your password.", + "@description": { + "context": "pages/recovery/start.html:19:25-60", + "description": "The description of the page to initiate an account recovery" + }, + "heading": "Enter your email to continue", + "@heading": { + "context": "pages/recovery/start.html:18:27-58", + "description": "The title of the page to initiate an account recovery" + } + } + }, + "register": { + "call_to_login": "Already have an account?", + "@call_to_login": { + "context": "pages/register/index.html:59:35-66, pages/register/password.html:77:33-64", + "description": "Displayed on the registration page to suggest to log in instead" + }, + "continue_with_email": "Continue with email address", + "@continue_with_email": { + "context": "pages/register/index.html:44:30-67" + }, + "create_account": { + "description": "Choose a username to continue.", + "@description": { + "context": "pages/register/index.html:24:29-73" + }, + "heading": "Create an account", + "@heading": { + "context": "pages/register/index.html:21:29-69, pages/register/password.html:18:27-67" + } + }, + "terms_of_service": "I agree to the Terms and Conditions", + "@terms_of_service": { + "context": "pages/register/password.html:51:35-95, pages/upstream_oauth2/do_register.html:179:35-95" + } + }, + "scope": { + "edit_profile": "Edit your profile and contact details", + "@edit_profile": { + "context": "components/scope.html:15:35-62", + "description": "Displayed when the 'urn:mas:graphql:*' scope is requested" + }, + "manage_sessions": "Manage your devices and sessions", + "@manage_sessions": { + "context": "components/scope.html:16:39-69", + "description": "Displayed when the 'urn:mas:graphql:*' scope is requested" + }, + "mas_admin": "Administer any user on the matrix-authentication-service", + "@mas_admin": { + "context": "components/scope.html:23:42-66", + "description": "Displayed when the 'urn:mas:admin' scope is requested" + }, + "send_messages": "Send new messages on your behalf", + "@send_messages": { + "context": "components/scope.html:19:35-63" + }, + "synapse_admin": "Administer the Synapse homeserver", + "@synapse_admin": { + "context": "components/scope.html:21:42-70", + "description": "Displayed when the 'urn:synapse:admin:*' scope is requested" + }, + "view_messages": "View your existing messages and data", + "@view_messages": { + "context": "components/scope.html:18:35-63", + "description": "Displayed when the 'urn:matrix:client:api:*' scope is requested" + }, + "view_profile": "See your profile info and contact details", + "@view_profile": { + "context": "components/scope.html:13:43-70", + "description": "Displayed when the 'openid' scope is requested" + } + }, + "upstream_oauth2": { + "link_mismatch": { + "heading": "This upstream account is already linked to another account.", + "@heading": { + "context": "pages/upstream_oauth2/link_mismatch.html:19:11-57", + "description": "Page shown when the user tries to link an upstream account that is already linked to another account" + } + }, + "register": { + "choose_username": { + "description": "This cannot be changed later.", + "@description": { + "context": "pages/upstream_oauth2/do_register.html:52:13-74" + }, + "heading": "Choose your username", + "@heading": { + "context": "pages/upstream_oauth2/do_register.html:49:13-70", + "description": "Displayed when creating a new account from an SSO login, and the username is not forced" + } + }, + "create_account": "Create a new account", + "@create_account": { + "description": "Displayed when creating a new account from an SSO login, and the username is pre-filled and forced" + }, + "enforced_by_policy": "Enforced by server policy", + "@enforced_by_policy": { + "context": "pages/upstream_oauth2/do_register.html:97:14-66" + }, + "forced_display_name": "Will use the following display name", + "@forced_display_name": { + "description": "Tells the user what display name will be imported" + }, + "forced_email": "Will use the following email address", + "@forced_email": { + "description": "Tells the user which email address will be imported" + }, + "forced_localpart": "Will use the following username", + "@forced_localpart": { + "description": "Tells the user which username will be used" + }, + "import_data": { + "description": "Confirm the information that will be linked to your new %(server_name)s account.", + "@description": { + "context": "pages/upstream_oauth2/do_register.html:25:13-104" + }, + "heading": "Import your data", + "@heading": { + "context": "pages/upstream_oauth2/do_register.html:22:13-66" + } + }, + "imported_from_upstream": "Imported from your upstream account", + "@imported_from_upstream": { + "context": "pages/upstream_oauth2/do_register.html:121:18-74, pages/upstream_oauth2/do_register.html:153:18-74" + }, + "imported_from_upstream_with_name": "Imported from your %(human_name)s account", + "@imported_from_upstream_with_name": { + "context": "pages/upstream_oauth2/do_register.html:119:18-131, pages/upstream_oauth2/do_register.html:151:18-131" + }, + "link_existing": "Link to an existing account", + "@link_existing": { + "description": "Button to link an existing account after an SSO login" + }, + "provider_name": "%(human_name)s account", + "@provider_name": { + "context": "pages/upstream_oauth2/do_register.html:68:14-108" + }, + "signup_with_upstream": { + "heading": "Continue signing up with your %(human_name)s account", + "@heading": { + "context": "pages/upstream_oauth2/do_register.html:37:13-122" + } + }, + "suggested_display_name": "Import display name", + "@suggested_display_name": { + "description": "Option to let the user import their display name after an SSO login" + }, + "suggested_email": "Import email address", + "@suggested_email": { + "description": "Option to let the user import their email address after an SSO login" + }, + "use": "Use", + "@use": { + "context": "pages/upstream_oauth2/do_register.html:137:18-55, pages/upstream_oauth2/do_register.html:170:20-57" + } + }, + "suggest_link": { + "action": "Link", + "@action": { + "context": "pages/upstream_oauth2/suggest_link.html:27:28-72" + }, + "heading": "Link to your existing account", + "@heading": { + "context": "pages/upstream_oauth2/suggest_link.html:18:27-72" + } + } + }, + "verify_email": { + "6_digit_code": "6-digit code", + "@6_digit_code": { + "context": "pages/register/steps/verify_email.html:33:33-67" + }, + "description": "Enter the 6-digit code sent to: %(email)s", + "@description": { + "context": "pages/register/steps/verify_email.html:18:25-86" + }, + "headline": "Verify your email", + "@headline": { + "context": "pages/register/steps/verify_email.html:17:27-57" + } + } + } +} \ No newline at end of file diff --git a/resources/translations/fr.json b/resources/translations/fr.json new file mode 100644 index 0000000..3c73a85 --- /dev/null +++ b/resources/translations/fr.json @@ -0,0 +1,270 @@ +{ + "action": { + "back": "Retour", + "cancel": "Annuler", + "continue": "Continuer", + "create_account": "Créer un compte", + "sign_in": "Se connecter", + "sign_out": "Se déconnecter", + "skip": "Passer", + "start_over": "Recommencer", + "submit": "Soumettre" + }, + "app": { + "human_name": "Matrix Authentication Service", + "name": "matrix-authentication-service", + "technical_description": "Document de découverte OpenID Connect : %(discovery_url)s" + }, + "branding": { + "privacy_policy": { + "alt": "Lien vers la politique de confidentialité", + "link": "Politique de confidentialité" + }, + "terms_and_conditions": { + "alt": "Lien vers les conditions d'utilisation", + "link": "Conditions d'utilisation" + } + }, + "common": { + "display_name": "Pseudonyme", + "email_address": "Adresse mail", + "loading": "Chargement…", + "mxid": "Matrix ID", + "password": "Mot de passe", + "password_confirm": "Confirmer le mot de passe", + "username": "Nom d’utilisateur" + }, + "error": { + "unexpected": "Erreur inattendue" + }, + "mas": { + "account": { + "deactivated": { + "description": "Ce compte (%(mxid)s) a été supprimé. Si vous ne vous attendez pas à cela, contactez l'administrateur de votre serveur.", + "heading": "Compte supprimé" + }, + "locked": { + "description": "Ce compte (%(mxid)s) a été verrouillé. Si vous ne vous attendez pas à cela, contactez l'administrateur de votre serveur.", + "heading": "Compte bloqué" + }, + "logged_out": { + "description": "Cette session est terminée. Déconnectez-vous pour pouvoir vous reconnecter", + "heading": "Session terminée" + } + }, + "add_email": { + "description": "Entrez une adresse mail qui sera utilisée pour récupérer votre compte au cas où vous perdriez l'accès à celui-ci.", + "heading": "Ajouter une adresse mail" + }, + "back_to_homepage": "Retourner sur la page d'accueil", + "captcha": { + "noscript": "Ce formulaire est protégé par un CAPTCHA et nécessite l'activation de JavaScript pour le soumettre. Veuillez activer JavaScript dans votre navigateur et recharger cette page." + }, + "change_password": { + "change": "Changer de mot de passe", + "confirm": "Confirmer le mot de passe", + "current": "Mot de passe actuel", + "description": "Cela modifiera le mot de passe de votre compte.", + "heading": "Modifier mon mot de passe", + "new": "Nouveau mot de passe" + }, + "choose_display_name": { + "description": "C'est le nom que les autres personnes verront. Vous pouvez le modifier à tout moment.", + "headline": "Choisissez votre pseudonyme" + }, + "consent": { + "client_wants_access": "%(client_name)s à l'adresse %(redirect_uri)s souhaite accéder à votre compte.", + "heading": "Autoriser l'accès à votre compte ?", + "make_sure_you_trust": "Assurez-vous de faire confiance %(client_name)s.", + "this_will_allow": "Cela va permettre à %(client_name)s de :", + "you_may_be_sharing": "Vous partagez peut-être des informations sensibles avec ce site ou cette application." + }, + "device_card": { + "access_requested": "Accès demandé", + "device_code": "Code", + "generic_device": "Appareil", + "ip_address": "Adresse IP" + }, + "device_code_link": { + "description": "Associer un appareil", + "headline": "Entrez le code affiché sur votre appareil" + }, + "device_consent": { + "another_device_access": "Un autre appareil souhaite accéder à votre compte.", + "denied": { + "description": "Vous avez refusé l'accès à %(client_name)s. Vous pouvez fermer cette fenêtre.", + "heading": "Accès refusé" + }, + "granted": { + "description": "Vous avez accordé l'accès à %(client_name)s. Vous pouvez fermer cette fenêtre.", + "heading": "Accès accordé" + } + }, + "device_display_name": { + "client_on_device": "%(client_name)s sur %(device_name)s", + "name_for_platform": "%(name)s pour %(platform)s", + "unknown_device": "Appareil inconnu" + }, + "email_in_use": { + "description": "Si vous avez oublié vos identifiants de compte, vous pouvez récupérer votre compte. Vous pouvez également recommencer l'enregistrement et utiliser une adresse mail différente.", + "title": "L’adresse mail %(email)s est déjà utilisée" + }, + "emails": { + "greeting": "Bonjour %(username)s,", + "recovery": { + "click_button": "Cliquez sur le bouton ci-dessous pour créer un nouveau mot de passe :", + "copy_link": "Copiez le lien suivant et collez-le dans un navigateur pour créer un nouveau mot de passe :", + "create_new_password": "Créer un nouveau mot de passe", + "fallback": "Le bouton ne fonctionne pas pour vous ?", + "headline": "Vous avez demandé la réinitialisation du mot de passe de votre compte %(server_name)s.", + "subject": "Réinitialisez le mot de passe de votre compte (%(mxid)s)", + "you_can_ignore": "Si vous n’avez pas demandé à réinitialisation votre mot de passe, vous pouvez ignorer cet e-mail. Votre mot de passe actuel continuera de fonctionner." + }, + "verify": { + "body_html": "Votre code de vérification pour confirmer cette adresse mail est : %(code)s", + "body_text": "Votre code de vérification pour confirmer cette adresse mail est : %(code)s", + "subject": "Votre code de vérification est : %(code)s" + } + }, + "errors": { + "captcha": "La vérification du CAPTCHA a échoué, veuillez réessayer", + "denied_policy": "Refusé par la politique du serveur : %(policy)s", + "email_banned": "Cette adresse mail est interdite par la politique du serveur", + "email_domain_banned": "Le domaine de l'adresse mail est interdit par la politique du serveur.", + "email_domain_not_allowed": "Le domaine de l'adresse mail n'est pas autorisée par la politique du serveur.", + "email_not_allowed": "Cette adresse mail n'est pas autorisée par la politique du serveur", + "field_required": "Ce champ est requis", + "invalid_credentials": "Identifiants invalides", + "password_mismatch": "Les champs du mot de passe ne correspondent pas.", + "rate_limit_exceeded": "Vous avez effectué trop de requêtes sur une courte période. Veuillez patienter quelques minutes et réessayer.", + "username_all_numeric": "Le nom d'utilisateur ne peut pas être composé uniquement de chiffres", + "username_banned": "Ce nom d'utilisateur est interdit par la politique du serveur", + "username_invalid_chars": "Le nom d'utilisateur contient des caractères non valides. Utilisez uniquement des lettres minuscules, des chiffres, des tirets et des traits de soulignement.", + "username_not_allowed": "Ce nom d'utilisateur n'est pas autorisé par la politique du serveur", + "username_taken": "Ce nom d'utilisateur est déjà utilisé", + "username_too_long": "Le nom d'utilisateur est trop long", + "username_too_short": "Le nom d'utilisateur est trop court" + }, + "login": { + "call_to_register": "Vous n’avez pas encore de compte ?", + "continue_with_provider": "Poursuivre avec %(provider)s", + "description": "Veuillez vous connecter pour continuer :", + "forgot_password": "Mot de passe oublié ?", + "headline": "Se connecter", + "link": { + "description": "Associer votre compte %(provider)s", + "headline": "Se connecter pour associer" + }, + "no_login_methods": "Aucune méthode de connexion n'est disponible.", + "separator": "Ou", + "username_or_email": "Adresse mail professionnelle" + }, + "navbar": { + "my_account": "Mon compte", + "register": "Créer un compte", + "signed_in_as": "Connecté en tant que %(username)s" + }, + "not_found": { + "description": "La page que vous recherchez n’existe pas ou a été déplacée", + "heading": "Page introuvable" + }, + "not_you": "Vous n'êtes pas %(username)s ?", + "or_separator": "Ou", + "policy_violation": { + "description": "Cela peut être dû à l'application auteur de la demande, à l'utilisateur actuellement connecté ou à la demande elle-même.", + "heading": "La demande d'autorisation a été refusée, conformément à la politique appliquée par ce service.", + "logged_as": "Connecté en tant que %(username)s" + }, + "recovery": { + "consumed": { + "description": "Pour créer un nouveau mot de passe, recommencez et sélectionnez « Mot de passe oublié ».", + "heading": "Le lien pour réinitialiser votre mot de passe a déjà été utilisé" + }, + "disabled": { + "description": "Si vous avez perdu vos identifiants, veuillez contacter l'administrateur pour récupérer votre compte.", + "heading": "La récupération de compte est désactivée" + }, + "expired": { + "description": "Demander un nouvel e-mail qui sera envoyé à : %(email)s.", + "heading": "Le lien pour réinitialiser votre mot de passe a expiré", + "resend_email": "Renvoyer l’e-mail" + }, + "finish": { + "confirm": "Entrez de nouveau votre nouveau mot de passe", + "description": "Choisissez un nouveau mot de passe pour votre compte.", + "heading": "Réinitialiser votre mot de passe", + "new": "Nouveau mot de passe", + "save_and_continue": "Sauvegarder et continuer" + }, + "progress": { + "change_email": "Essayez une autre adresse mail", + "description": "Nous avons envoyé un e-mail contenant un lien pour réinitialiser votre mot de passe si un compte utilise %(email)s.", + "heading": "Vérifiez vos e-mails", + "resend_email": "Renvoyer l’e-mail" + }, + "start": { + "description": "", + "heading": "Entrez votre adresse mail pour réinitialiser votre mot de passe" + } + }, + "register": { + "call_to_login": "Vous avez déjà un compte ?", + "continue_with_email": "Continuer avec une adresse mail", + "create_account": { + "description": "Choisissez un nom d'utilisateur pour continuer.", + "heading": "Créer un compte" + }, + "sign_in_instead": "Se connecter", + "terms_of_service": "J'accepte les Conditions d'utilisation" + }, + "scope": { + "edit_profile": "Modifier votre profil et vos coordonnées", + "manage_sessions": "Gérer vos appareils et vos sessions", + "mas_admin": "Administrer n'importe quel utilisateur dans matrix-authentication-service", + "send_messages": "Envoyez de nouveaux messages en votre nom", + "synapse_admin": "Administrer le serveur d’accueil Synapse", + "view_messages": "Afficher vos messages et données existants", + "view_profile": "Voir les informations de votre profil et vos coordonnées" + }, + "upstream_oauth2": { + "link_mismatch": { + "heading": "Ce compte est déjà associé à un autre compte." + }, + "register": { + "choose_username": { + "description": "Cela ne peut pas être modifié ultérieurement.", + "heading": "Choisissez votre nom d'utilisateur" + }, + "create_account": "Créer un nouveau compte", + "enforced_by_policy": "Importé selon la politique du serveur", + "forced_display_name": "Utilisera le pseudonyme suivant", + "forced_email": "Utilisera l’adresse mail suivante", + "forced_localpart": "Utilisera le nom d'utilisateur suivant", + "import_data": { + "description": "Confirmez les informations qui seront associées à votre nouveau compte %(server_name)s.", + "heading": "Importez vos données" + }, + "imported_from_upstream": "Importé de votre compte en amont", + "imported_from_upstream_with_name": "Importé depuis votre compte %(human_name)s", + "link_existing": "Associer un compte existant", + "provider_name": "Compte %(human_name)s", + "signup_with_upstream": { + "heading": "Continuez à vous inscrire avec votre compte %(human_name)s" + }, + "suggested_display_name": "Importer le pseudonyme", + "suggested_email": "Importer l’adresse mail", + "use": "Importer" + }, + "suggest_link": { + "action": "Associer", + "heading": "Associer votre compte existant" + } + }, + "verify_email": { + "6_digit_code": "Code à 6 chiffres", + "code": "Code", + "description": "Veuillez saisir le code à 6 chiffres envoyé à : %(email)s", + "headline": "Vérifiez votre adresse mail" + } + } +} \ No newline at end of file diff --git a/start-local-mas-hot-reload.sh b/start-local-mas-hot-reload.sh new file mode 100755 index 0000000..efa58e2 --- /dev/null +++ b/start-local-mas-hot-reload.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +set -e + +# Source the .env file to load environment variables +if [ -f .env ]; then + source .env +else + echo "Error: .env file not found. Please create a .env file with the required environment variables." + exit 1 +fi + +# Check if MAS_HOME is defined +if [ -z "$MAS_HOME" ]; then + echo "Error: MAS_HOME environment variable is not defined" + echo "Please set MAS_HOME to the path of your matrix-authentication-service directory" + exit 1 +fi + +# Check if TEMPLATE_SOURCE is defined +if [ -z "$TEMPLATE_SOURCE" ]; then + echo "Error: TEMPLATE_SOURCE environment variable is not defined" + echo "Please set TEMPLATE_SOURCE to the path of your templates directory" + exit 1 +fi + +# Function to send SIGHUP to the server process +send_sighup() { + # Find the server process + SERVER_PID=$(pgrep -f "mas-cli server -c") + if [ -n "$SERVER_PID" ]; then + echo "Sending SIGHUP to server process $SERVER_PID" + kill -HUP $SERVER_PID + else + echo "Server process not found" + fi +} + +# Function to watch for template changes +watch_templates() { + echo "Watching for changes in $TEMPLATE_SOURCE..." + + # Use fswatch to monitor the templates directory + fswatch -o "$TEMPLATE_SOURCE" | while read; do + echo "Template change detected..." + $MAS_TCHAP_HOME/tools/build_conf.sh + send_sighup + done +} + +# Check if fswatch is installed +if ! command -v fswatch &> /dev/null; then + echo "fswatch is not installed. Please install it first:" + echo "brew install fswatch" + exit 1 +fi + +export MAS_HOME=$MAS_HOME +export MAS_TCHAP_HOME=$PWD +cd $MAS_HOME + +# Build conf from conf.template.yaml +$MAS_TCHAP_HOME/tools/build_conf.sh + +# Start watching for changes in the background +watch_templates & + +# Store the watcher's PID +WATCHER_PID=$! + +# Function to clean up on exit +cleanup() { + echo "Stopping template watcher..." + kill $WATCHER_PID 2>/dev/null + exit 0 +} + +# Set up trap for cleanup +trap cleanup EXIT INT TERM + +# Start the server +echo "Starting server..." +cargo run -- server -c $MAS_TCHAP_HOME/tmp/config.local.dev.yaml \ No newline at end of file diff --git a/tools/README_TCHAP.md b/tools/README_TCHAP.md index 935683d..e7cf1c1 100644 --- a/tools/README_TCHAP.md +++ b/tools/README_TCHAP.md @@ -35,4 +35,24 @@ cd .. #createdb -U postgres -O postgres keycloak #docker run -d -p 5432:5432 -e 'POSTGRES_USER=keycloak' -e 'POSTGRES_PASSWORD=keycloak' -e 'POSTGRES_DATABASE=keycloak' postgres-keycloak # cargo test --package mas-handlers upstream_oauth2::link::tests +``` + +## database + +create the db +``` +cd crates/storage-pg +cargo sqlx database setup +``` + +drop the db +``` +cd crates/storage-pg +cargo sqlx database drop +``` + +generate an offline db +``` +cd crates/storage-pg +cargo sqlx database prepare ``` \ No newline at end of file diff --git a/tools/build_conf.sh b/tools/build_conf.sh index 0fb7c87..ffc5670 100755 --- a/tools/build_conf.sh +++ b/tools/build_conf.sh @@ -1,6 +1,9 @@ #!/bin/sh set -e + +echo "Building templates..." + # New template directory MAS_TCHAP_DATA="$MAS_TCHAP_HOME/tmp" # Create data directory @@ -14,6 +17,8 @@ cp -r "$MAS_HOME/templates" "$MAS_TCHAP_DATA" # Override MAS template with custom tchap template cp -r "$MAS_TCHAP_HOME/resources/templates" "$MAS_TCHAP_DATA" +echo "Building MAS config..." + # Create MAS conf file template_yaml_file="$MAS_TCHAP_HOME/conf/config.template.yaml" yaml_file="$MAS_TCHAP_DATA/config.local.dev.yaml" @@ -21,3 +26,10 @@ cp $template_yaml_file $yaml_file MAS_TCHAP_TEMPLATES="$MAS_TCHAP_DATA/templates" sed -i '' -E "/^templates:/,/^[^[:space:]]/ s|^[[:space:]]*path:.*| path: \"$MAS_TCHAP_TEMPLATES\"|" "$yaml_file" + +echo "Updating translations..." +MAS_TCHAP_TRANSLATIONS="$MAS_TCHAP_HOME/resources/translations" + +cargo run -p mas-i18n-scan -- --update "${MAS_TCHAP_TEMPLATES}" "${MAS_TCHAP_TRANSLATIONS}/en.json" + +sed -i '' -E "/^templates:/,/^[^[:space:]]/ s|^[[:space:]]*translations_path:.*| translations_path: \"$MAS_TCHAP_TRANSLATIONS\"|" "$yaml_file" From 14c186524a3dd58a8345134f2f13dffff243dd55 Mon Sep 17 00:00:00 2001 From: olivier Date: Fri, 6 Jun 2025 10:50:36 +0200 Subject: [PATCH 15/71] fix proconnect button test --- playwright/tests/utils/auth-helpers.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/playwright/tests/utils/auth-helpers.ts b/playwright/tests/utils/auth-helpers.ts index 6c45102..e1b5908 100644 --- a/playwright/tests/utils/auth-helpers.ts +++ b/playwright/tests/utils/auth-helpers.ts @@ -48,7 +48,9 @@ export async function performOidcLogin(page: Page, user: TestUser, screenshot_pa // Find and click the OIDC provider button (adjust the selector as needed) // This is based on the login.html template which shows provider buttons - const oidcButton = page.locator('a.cpd-button[href*="/upstream/authorize/"]'); + //const oidcButton = page.locator('a.cpd-button[href*="/upstream/authorize/"]'); + //catch proconnect button with class proconnect-button + const oidcButton = page.locator('button.proconnect-button'); await oidcButton.click(); // Wait for navigation to Keycloak From 7db40501e7d6ee865ac1a5cff6bc62566653bef1 Mon Sep 17 00:00:00 2001 From: mcalinghee Date: Thu, 19 Jun 2025 10:50:35 +0200 Subject: [PATCH 16/71] add more tool migration and debug --- .gitignore | 3 +- start-local-mas.sh | 3 +- tools/debug_test.sh | 68 +++++++++++++++++++++++++++++++++++++++++++++ tools/migration.sh | 13 +++++++++ 4 files changed, 85 insertions(+), 2 deletions(-) create mode 100755 tools/debug_test.sh create mode 100755 tools/migration.sh diff --git a/.gitignore b/.gitignore index bb2737d..4ce3b6b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ playwright/playwright-results/ playwright/test-results/ playwright/.env .env -.history/ \ No newline at end of file +.history/ +.idea \ No newline at end of file diff --git a/start-local-mas.sh b/start-local-mas.sh index bffabd6..3f80408 100755 --- a/start-local-mas.sh +++ b/start-local-mas.sh @@ -22,4 +22,5 @@ cd $MAS_HOME # Build conf from conf.template.yaml $MAS_TCHAP_HOME/tools/build_conf.sh -cargo run -- server -c $MAS_TCHAP_HOME/tmp/config.local.dev.yaml +export RUST_LOG=info +cargo run -- server -c $MAS_TCHAP_HOME/tmp/config.local.dev.yaml diff --git a/tools/debug_test.sh b/tools/debug_test.sh new file mode 100755 index 0000000..fb2d133 --- /dev/null +++ b/tools/debug_test.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# Usage: ./debug_test.sh crates/handlers upstream_oauth2::link::tests::test_link_existing_account +set -e + +cd $MAS_TCHAP_HOME + +if [[ $# -lt 2 ]]; then + echo "Usage: $0 " + echo "Example: $0 crates/mas-handlers test_valid_token_flow" + exit 1 +fi + +CRATE_DIR="$1" +TEST_NAME="$2" + +# Compile the tests for the crate +echo "🔧 Building tests for crate: $CRATE_DIR" +cd "$CRATE_DIR" +cargo test --no-run + +# Get crate name from Cargo.toml +CRATE_NAME=$(grep '^name' Cargo.toml | head -n1 | cut -d '"' -f2) +# Derive expected test binary name (Cargo replaces '-' with '_') +BINARY_NAME="${CRATE_NAME//-/_}" + +# Get the correct test binary path +BIN_PATH=$(cargo test --no-run --message-format=json \ + | jq -r --arg name "$BINARY_NAME" ' + select(.profile.test == true and .target.name == $name) + | .executable + ') + +if [[ ! -x "$BIN_PATH" ]]; then + echo "❌ Could not find test binary." + exit 2 +fi + +echo "✅ Test binary: $BIN_PATH" + +# Go back to workspace root +cd - > /dev/null + +# Ensure .vscode exists +mkdir -p .vscode + +# Write launch.json +cat > .vscode/launch.json < Date: Thu, 19 Jun 2025 11:52:19 +0200 Subject: [PATCH 17/71] remove css from base --- resources/templates/base.html | 3 --- 1 file changed, 3 deletions(-) diff --git a/resources/templates/base.html b/resources/templates/base.html index 73259f8..bf0b6b1 100644 --- a/resources/templates/base.html +++ b/resources/templates/base.html @@ -26,9 +26,6 @@ {{ include_asset('src/shared.css') | indent(4) | safe }} {{ include_asset('src/templates.css') | indent(4) | safe }} - {#:tchap: #} - {{ include_asset('tchap/src/tchap.css') | indent(4) | safe }} - {#:tchap:end #} {{ captcha.head() }} From 39c0909fc3395f1997f7dea4aed2f7ac507854ab Mon Sep 17 00:00:00 2001 From: Olivier D Date: Thu, 19 Jun 2025 16:34:17 +0200 Subject: [PATCH 18/71] Adapt configuration and tests for release 2025 - 06 (#6) * add new labels * add config * add check template * add translations * add element flow for login oidc * remove allow_existing_users * export variable * set tchap template * remove css * update tests * update text * add MAS env variable * reuse existing sh script * rename tests --- .env.sample | 10 ++++++ conf/config.template.yaml | 10 +++--- playwright/fixtures/auth-fixture.ts | 4 +-- playwright/tests/element-oidc-auth.spec.ts | 32 +++++++++---------- playwright/tests/login-oidc.spec.ts | 35 ++++++--------------- playwright/tests/login-password.spec.ts | 6 ++-- playwright/tests/register-oidc.spec.ts | 28 ++++++++--------- playwright/tests/utils/auth-helpers.ts | 8 +++-- playwright/tests/utils/mas-admin.ts | 9 +++++- resources/translations/en.json | 36 +++++++++++++++++++--- resources/translations/fr.json | 5 +++ start-local-mas-hot-reload.sh | 12 +++++--- start-local-mas.sh | 20 ++++++++++++ 13 files changed, 137 insertions(+), 78 deletions(-) diff --git a/.env.sample b/.env.sample index 8e7f76f..b8ddbc2 100644 --- a/.env.sample +++ b/.env.sample @@ -1,3 +1,13 @@ +### MAS CONFIG ### + +TCHAP_IDENTITY_SERVER_URL=http://localhost:8083 + + +### ENV DEV CONFIG ### + +# Path to the MAS project to run +MAS_HOME=path/to/matrix-authentication-service + #wiremock port for mocking identity server (sydent) IDENTITY_MOCK_PORT=8090 diff --git a/conf/config.template.yaml b/conf/config.template.yaml index 216b75a..92789d7 100644 --- a/conf/config.template.yaml +++ b/conf/config.template.yaml @@ -195,18 +195,18 @@ upstream_oauth2: claims_imports: localpart: action: force - template: "{{ user.preferred_username }}" + #template: "{{ user.preferred_username }}" + on_conflict: add #action: require - #template: "{{ user.email | email_to_mxid_localpart }}" + template: "{{ user.email | email_to_mxid_localpart }}" displayname: action: require - template: "{{ user.name }}" - #template: "{{ user.email | email_to_display_name }}" + #template: "{{ user.name }}" + template: "{{ user.email | email_to_display_name }}" email: action: require template: "{{ user.email }}" set_email_verification: always - allow_existing_users: true telemetry: tracing: diff --git a/playwright/fixtures/auth-fixture.ts b/playwright/fixtures/auth-fixture.ts index 6ede975..d7ecd8d 100644 --- a/playwright/fixtures/auth-fixture.ts +++ b/playwright/fixtures/auth-fixture.ts @@ -78,7 +78,7 @@ function createLegacyUserFixture(domain: string) { */ export const test = base.extend<{ testUser: TestUser; - testExternalUser: TestUser; + testExternalUserWithInvit: TestUser; testExternalUserWitoutInvit: TestUser; testUserOnWrongServer: TestUser; userLegacy:TestUser; @@ -88,7 +88,7 @@ export const test = base.extend<{ * Create a test user in Keycloak before the test and clean it up after */ testUser: createTestUserFixture(STANDARD_EMAIL_DOMAIN), - testExternalUser: createTestUserFixture(INVITED_EMAIL_DOMAIN), + testExternalUserWithInvit: createTestUserFixture(INVITED_EMAIL_DOMAIN), testExternalUserWitoutInvit: createTestUserFixture(NOT_INVITED_EMAIL_DOMAIN), testUserOnWrongServer: createTestUserFixture(WRONG_SERVER_EMAIL_DOMAIN), userLegacy:createLegacyUserFixture(STANDARD_EMAIL_DOMAIN), diff --git a/playwright/tests/element-oidc-auth.spec.ts b/playwright/tests/element-oidc-auth.spec.ts index 58e9a0d..8d9cf23 100644 --- a/playwright/tests/element-oidc-auth.spec.ts +++ b/playwright/tests/element-oidc-auth.spec.ts @@ -1,26 +1,25 @@ import { test, expect } from '../fixtures/auth-fixture'; import { - performOidcLogin, verifyUserInMas, - createMasTestUser, - cleanupMasTestUser, - performPasswordLogin, - TestUser, performOidcLoginFromElement } from './utils/auth-helpers'; -import { checkMasUserExistsByEmail } from './utils/mas-admin'; +import { checkMasUserExistsByEmail, createMasUserWithPassword } from './utils/mas-admin'; import { SCREENSHOTS_DIR, TCHAP_LEGACY } from './utils/config'; -test.describe('Element OIDC register flows', () => { - test('element : register via oidc and create user in MAS', async ({ page, testUser }) => { - const screenshot_path = 'element_register_oidc'; + +//flaky on await expect(page.locator('text=Configuration')).toBeVisible({timeout: 20000}); +test.describe('Element : Login via OIDC', () => { + test('element match account by username', async ({ page, userLegacy: userLegacy }) => { + const screenshot_path = test.info().title.replace(" ", "_"); + + userLegacy.masId = await createMasUserWithPassword(userLegacy.kc_username, userLegacy.kc_email, userLegacy.kc_password); // Verify the test user doesn't exist in MAS yet - const existsBeforeLogin = await checkMasUserExistsByEmail(testUser.kc_email); - expect(existsBeforeLogin).toBe(false); + const existsBeforeLogin = await checkMasUserExistsByEmail(userLegacy.kc_email); + expect(existsBeforeLogin).toBe(true); // Perform the OIDC login flow - await performOidcLoginFromElement(page, testUser,screenshot_path, TCHAP_LEGACY); + await performOidcLoginFromElement(page, userLegacy,screenshot_path, TCHAP_LEGACY); // Click the create account button await page.locator('button[type="submit"]').click(); @@ -33,18 +32,19 @@ test.describe('Element OIDC register flows', () => { await page.locator('button[type="submit"]').filter({hasText:'Continuer'}).click(); - await expect(page.locator('text=Configuration')).toBeVisible(); + //flaky condition + await expect(page.locator('text=Configuration')).toBeVisible({timeout: 20000}); // Take a screenshot of the authenticated state await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/06-auth-success.png` }); // Verify the user was created in MAS - await verifyUserInMas(testUser); + await verifyUserInMas(userLegacy); // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(testUser.kc_email); + const existsAfterLogin = await checkMasUserExistsByEmail(userLegacy.kc_email); expect(existsAfterLogin).toBe(true); - console.log(`Successfully authenticated and verified user ${testUser.kc_username} (${testUser.kc_email})`); + console.log(`Successfully authenticated and verified user ${userLegacy.kc_username} (${userLegacy.kc_email})`); }); }); diff --git a/playwright/tests/login-oidc.spec.ts b/playwright/tests/login-oidc.spec.ts index b336896..aee7e7c 100644 --- a/playwright/tests/login-oidc.spec.ts +++ b/playwright/tests/login-oidc.spec.ts @@ -1,28 +1,21 @@ import { test, expect } from '../fixtures/auth-fixture'; import { - performOidcLogin, - verifyUserInMas, - createMasTestUser, - cleanupMasTestUser, - performPasswordLogin, - TestUser + performOidcLogin, } from './utils/auth-helpers'; import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser,oauthLinkExistsByUserId, oauthLinkExistsBySubject } from './utils/mas-admin'; import { SCREENSHOTS_DIR } from './utils/config'; -test.describe('Oidc login flows', () => { +test.describe('Login via OIDC', () => { - test('should link existing MAS account when logging in via OIDC by username', async ({ page, userLegacy: userLegacy }) => { - const screenshot_path = 'link_oidc_account_by_username'; - + test('match account by username', async ({ page, userLegacy: userLegacy }) => { + const screenshot_path = test.info().title.replace(" ", "_"); + // Create a user in MAS with the same email as the Keycloak user console.log(`Creating MAS user with same username as Keycloak user: ${userLegacy.kc_username}`); userLegacy.masId = await createMasUserWithPassword(userLegacy.kc_username, userLegacy.kc_email, userLegacy.kc_password); try { - - // Perform the OIDC login flow await performOidcLogin(page, userLegacy, screenshot_path); @@ -50,8 +43,8 @@ test.describe('Oidc login flows', () => { }); - test('should link existing MAS account when logging in via OIDC with same email but different username', async ({ page, userLegacy: userLegacy }) => { - const screenshot_path = 'link_oidc_account_by_email'; + test('match account by email', async ({ page, userLegacy: userLegacy }) => { + const screenshot_path = test.info().title.replace(" ", "_"); // Create a user in MAS with the same email as the Keycloak user console.log(`Creating MAS user with same email as Keycloak user: ${userLegacy.kc_email}`); @@ -59,11 +52,7 @@ test.describe('Oidc login flows', () => { userLegacy.masId = await createMasUserWithPassword(userLegacy.kc_username+"different_from_email", userLegacy.kc_email, userLegacy.kc_password); try { - // Verify the user exists in MAS - const existsBeforeLogin = await checkMasUserExistsByEmail(userLegacy.kc_email); - expect(existsBeforeLogin).toBe(true); - console.log(`Confirmed MAS user exists with email: ${userLegacy.kc_email}`); - + // Perform the OIDC login flow await performOidcLogin(page, userLegacy, screenshot_path); @@ -92,8 +81,8 @@ test.describe('Oidc login flows', () => { }); - test('should link existing MAS account when logging in via OIDC by email using fallback rules', async ({ page, userLegacyWithFallbackRules: userLegacy }) => { - const screenshot_path = 'link_oidc_account_by_email_with_fallback_rules'; + test('match account by email with fallback rules', async ({ page, userLegacyWithFallbackRules: userLegacy }) => { + const screenshot_path = test.info().title.replace(" ", "_"); const old_email_domain = "@beta.gouv.fr"; const old_email = userLegacy.kc_email.replace(/@.*/, old_email_domain); @@ -105,10 +94,6 @@ test.describe('Oidc login flows', () => { userLegacy.masId = await createMasUserWithPassword(userLegacy.kc_username+"different_from_email", old_email, userLegacy.kc_password); try { - // Verify the user exists in MAS - const existsBeforeLogin = await checkMasUserExistsByEmail(old_email); - expect(existsBeforeLogin).toBe(true); - console.log(`Confirmed MAS user exists with email: ${old_email}`); // Perform the OIDC login flow await performOidcLogin(page, userLegacy, screenshot_path); diff --git a/playwright/tests/login-password.spec.ts b/playwright/tests/login-password.spec.ts index 83ab4a6..a247e71 100644 --- a/playwright/tests/login-password.spec.ts +++ b/playwright/tests/login-password.spec.ts @@ -10,10 +10,10 @@ import { import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser } from './utils/mas-admin'; import { SCREENSHOTS_DIR } from './utils/config'; -test.describe('password login flows', () => { +test.describe('Login with password', () => { - test('should authenticate with username and password', async ({ page }) => { - const screenshot_path = 'login_pwd'; + test('login with allowed account', async ({ page }) => { + const screenshot_path = test.info().title.replace(" ", "_"); // Create a test user with a password in MAS const user = await createMasTestUser("exemple.com"); diff --git a/playwright/tests/register-oidc.spec.ts b/playwright/tests/register-oidc.spec.ts index 3e89366..47dab3d 100644 --- a/playwright/tests/register-oidc.spec.ts +++ b/playwright/tests/register-oidc.spec.ts @@ -10,10 +10,10 @@ import { import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser } from './utils/mas-admin'; import { SCREENSHOTS_DIR } from './utils/config'; -test.describe('OIDC register Flows', () => { +test.describe('Register', () => { - test('register via oidc and create user in MAS', async ({ page, testUser }) => { - const screenshot_path = 'register_oidc'; + test('register oidc with allowed account', async ({ page, testUser }) => { + const screenshot_path = test.info().title.replace(" ", "_"); // Verify the test user doesn't exist in MAS yet const existsBeforeLogin = await checkMasUserExistsByEmail(testUser.kc_email); @@ -42,8 +42,8 @@ test.describe('OIDC register Flows', () => { console.log(`Successfully authenticated and verified user ${testUser.kc_username} (${testUser.kc_email})`); }); - test('register via oidc and get error because extern has no invit', async ({ page, testExternalUserWitoutInvit }) => { - const screenshot_path = 'register_oidc_no_invit'; + test('register oidc with extern without invit', async ({ page, testExternalUserWitoutInvit }) => { + const screenshot_path = test.info().title.replace(" ", "_"); // Verify the test user doesn't exist in MAS yet const existsBeforeLogin = await checkMasUserExistsByEmail(testExternalUserWitoutInvit.kc_email); @@ -66,15 +66,15 @@ test.describe('OIDC register Flows', () => { console.log(`Successfully authenticated and verified user ${testExternalUserWitoutInvit.kc_username} (${testExternalUserWitoutInvit.kc_email})`); }); - test('register via oidc and create user external with invitation in MAS', async ({ page, testExternalUser }) => { - const screenshot_path = 'register_oidc_invit'; + test('register oidc with extern with invit', async ({ page, testExternalUserWithInvit: testExternalUserWithInvit }) => { + const screenshot_path = test.info().title.replace(" ", "_"); // Verify the test user doesn't exist in MAS yet - const existsBeforeLogin = await checkMasUserExistsByEmail(testExternalUser.kc_email); + const existsBeforeLogin = await checkMasUserExistsByEmail(testExternalUserWithInvit.kc_email); expect(existsBeforeLogin).toBe(false); // Perform the OIDC login flow - await performOidcLogin(page, testExternalUser, screenshot_path); + await performOidcLogin(page, testExternalUserWithInvit, screenshot_path); // Click the create account button await page.locator('button[type="submit"]').click(); @@ -86,17 +86,17 @@ test.describe('OIDC register Flows', () => { await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-authenticated-external.png` }); // Verify the user was created in MAS - await verifyUserInMas(testExternalUser); + await verifyUserInMas(testExternalUserWithInvit); // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(testExternalUser.kc_email); + const existsAfterLogin = await checkMasUserExistsByEmail(testExternalUserWithInvit.kc_email); expect(existsAfterLogin).toBe(true); - console.log(`Successfully authenticated and verified external user ${testExternalUser.kc_username} (${testExternalUser.kc_email})`); + console.log(`Successfully authenticated and verified external user ${testExternalUserWithInvit.kc_username} (${testExternalUserWithInvit.kc_email})`); }); - test('should authenticate user via oidc and get error because wrong server', async ({ page, testUserOnWrongServer }) => { - const screenshot_path = 'register_oidc_wrong_server'; + test('register oidc on wrong homeserver', async ({ page, testUserOnWrongServer }) => { + const screenshot_path = test.info().title.replace(" ", "_"); // Verify the test user doesn't exist in MAS yet const existsBeforeLogin = await checkMasUserExistsByEmail(testUserOnWrongServer.kc_email); diff --git a/playwright/tests/utils/auth-helpers.ts b/playwright/tests/utils/auth-helpers.ts index e1b5908..65a2b47 100644 --- a/playwright/tests/utils/auth-helpers.ts +++ b/playwright/tests/utils/auth-helpers.ts @@ -83,8 +83,8 @@ export async function performOidcLogin(page: Page, user: TestUser, screenshot_pa */ export async function performOidcLoginFromElement(page: Page, user: TestUser, screenshot_path: string, tchap_legacy:boolean=false): Promise { // Navigate to Element login page - await page.goto(`${ELEMENT_URL}/#/login`); - + await page.goto(`${ELEMENT_URL}/#/login`, { waitUntil: 'networkidle' }); + // Take a screenshot of the Element login page await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-element-login-page.png` }); @@ -103,7 +103,9 @@ export async function performOidcLoginFromElement(page: Page, user: TestUser, sc await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-mas-login-page.png` }); // Find and click the OIDC provider button - const oidcButton = page.locator('a.cpd-button[href*="/upstream/authorize/"]'); + //const oidcButton = page.locator('a.cpd-button[href*="/upstream/authorize/"]'); + const oidcButton = page.locator('button.proconnect-button'); + await oidcButton.click(); // Wait for navigation to Keycloak diff --git a/playwright/tests/utils/mas-admin.ts b/playwright/tests/utils/mas-admin.ts index 7f26750..8514961 100644 --- a/playwright/tests/utils/mas-admin.ts +++ b/playwright/tests/utils/mas-admin.ts @@ -76,7 +76,14 @@ export async function getMasUserByEmail(email: string): Promise { const emailResult = await emailResponse.json(); if (emailResult.data.length === 0) { console.log(`[MAS API] No user found with email: ${email}`); - return null; + throw new Error(`[MAS API] No user found with email: ${email}`); + + } + + if (emailResult.data.length > 1) { + console.log(`[MAS API] Multiple users found with email: ${email}`); + throw new Error(`[MAS API] Multiple users found with email: ${email}`); + } // Extract user_id from the attributes diff --git a/resources/translations/en.json b/resources/translations/en.json index 7edb50a..7bafe80 100644 --- a/resources/translations/en.json +++ b/resources/translations/en.json @@ -10,11 +10,11 @@ }, "continue": "Continue", "@continue": { - "context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, pages/login.html:102:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48" + "context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, pages/login.html:102:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48" }, "create_account": "Create Account", "@create_account": { - "context": "pages/login.html:118:33-59, pages/upstream_oauth2/do_register.html:192:26-52" + "context": "pages/login.html:118:33-59, pages/upstream_oauth2/do_register.html:191:26-52" }, "sign_in": "Sign in", "@sign_in": { @@ -635,6 +635,20 @@ "context": "pages/register/password.html:51:35-95, pages/upstream_oauth2/do_register.html:179:35-95" } }, + "registration_token": { + "description": "", + "@description": { + "context": "pages/register/steps/registration_token.html:17:25-64" + }, + "field": "", + "@field": { + "context": "pages/register/steps/registration_token.html:33:35-68" + }, + "headline": "", + "@headline": { + "context": "pages/register/steps/registration_token.html:16:27-63" + } + }, "scope": { "edit_profile": "Edit your profile and contact details", "@edit_profile": { @@ -679,6 +693,20 @@ "description": "Page shown when the user tries to link an upstream account that is already linked to another account" } }, + "login_link": { + "action": "Link", + "@action": { + "context": "pages/upstream_oauth2/login_link.html:27:28-70" + }, + "description": "An account exists for the username : %(username)s", + "@description": { + "context": "pages/upstream_oauth2/login_link.html:21:7-85" + }, + "heading": "Link to your existing account", + "@heading": { + "context": "pages/upstream_oauth2/login_link.html:17:27-70" + } + }, "register": { "choose_username": { "description": "This cannot be changed later.", @@ -759,11 +787,11 @@ "suggest_link": { "action": "Link", "@action": { - "context": "pages/upstream_oauth2/suggest_link.html:27:28-72" + "context": "pages/upstream_oauth2/do_login.html:28:28-72, pages/upstream_oauth2/suggest_link.html:27:28-72" }, "heading": "Link to your existing account", "@heading": { - "context": "pages/upstream_oauth2/suggest_link.html:18:27-72" + "context": "pages/upstream_oauth2/do_login.html:18:27-72, pages/upstream_oauth2/suggest_link.html:18:27-72" } } }, diff --git a/resources/translations/fr.json b/resources/translations/fr.json index 3c73a85..7f02a2e 100644 --- a/resources/translations/fr.json +++ b/resources/translations/fr.json @@ -258,6 +258,11 @@ "suggest_link": { "action": "Associer", "heading": "Associer votre compte existant" + }, + "login_link": { + "action": "Continuer", + "heading": "Associer votre compte existant", + "description": "Un compte existe pour ce nom d'utilisateur: %(username)s, il sera associé à votre compte Proconnect" } }, "verify_email": { diff --git a/start-local-mas-hot-reload.sh b/start-local-mas-hot-reload.sh index efa58e2..d367d9c 100755 --- a/start-local-mas-hot-reload.sh +++ b/start-local-mas-hot-reload.sh @@ -1,4 +1,5 @@ #!/bin/bash +# runs the MAS with a hot reload when a template is modified set -e @@ -43,6 +44,7 @@ watch_templates() { # Use fswatch to monitor the templates directory fswatch -o "$TEMPLATE_SOURCE" | while read; do echo "Template change detected..." + cd $MAS_HOME $MAS_TCHAP_HOME/tools/build_conf.sh send_sighup done @@ -57,10 +59,10 @@ fi export MAS_HOME=$MAS_HOME export MAS_TCHAP_HOME=$PWD -cd $MAS_HOME + # Build conf from conf.template.yaml -$MAS_TCHAP_HOME/tools/build_conf.sh +# $MAS_TCHAP_HOME/tools/build_conf.sh # Start watching for changes in the background watch_templates & @@ -78,6 +80,6 @@ cleanup() { # Set up trap for cleanup trap cleanup EXIT INT TERM -# Start the server -echo "Starting server..." -cargo run -- server -c $MAS_TCHAP_HOME/tmp/config.local.dev.yaml \ No newline at end of file + + +./start-local-mas.sh \ No newline at end of file diff --git a/start-local-mas.sh b/start-local-mas.sh index 3f80408..7169484 100755 --- a/start-local-mas.sh +++ b/start-local-mas.sh @@ -1,4 +1,9 @@ #!/bin/bash +# runs the MAS at the path : $MAS_HOME +# before running the server : +# - build the config +# - build the template +# - runs sanity check on the templates set -e @@ -17,10 +22,25 @@ if [ -z "$MAS_HOME" ]; then exit 1 fi +export MAS_HOME=$MAS_HOME export MAS_TCHAP_HOME=$PWD cd $MAS_HOME + # Build conf from conf.template.yaml $MAS_TCHAP_HOME/tools/build_conf.sh export RUST_LOG=info +# Check if TCHAP_IDENTITY_SERVER_URL is defined +if [ -z "$TCHAP_IDENTITY_SERVER_URL" ]; then + echo "Error: TCHAP_IDENTITY_SERVER_URL environment variable is not defined" + exit 1 +fi + +export TCHAP_IDENTITY_SERVER_URL=$TCHAP_IDENTITY_SERVER_URL + +# Start the server +echo "Checking templates..." +cargo run -- templates check -c $MAS_TCHAP_HOME/tmp/config.local.dev.yaml + cargo run -- server -c $MAS_TCHAP_HOME/tmp/config.local.dev.yaml + From 33e806ed13f74b1fa5b3fcd6c434e4a601844246 Mon Sep 17 00:00:00 2001 From: olivier Date: Thu, 31 Jul 2025 16:22:52 +0200 Subject: [PATCH 19/71] make identity mock compatible with local tchap web --- tools/test-identity-mock.sh | 4 ++- .../mappings/invited-mapping-internal.json | 28 +++++++++++++++++++ wiremock/mappings/invited-mapping.json | 9 +++--- .../not-invited-mapping-internal.json | 28 +++++++++++++++++++ wiremock/mappings/not-invited-mapping.json | 9 +++--- wiremock/mappings/user-mapping-internal.json | 28 +++++++++++++++++++ wiremock/mappings/user-mapping.json | 9 +++--- .../mappings/wrong-hs-mapping-internal.json | 28 +++++++++++++++++++ wiremock/mappings/wrong-hs-mapping.json | 9 +++--- 9 files changed, 131 insertions(+), 21 deletions(-) create mode 100644 wiremock/mappings/invited-mapping-internal.json create mode 100644 wiremock/mappings/not-invited-mapping-internal.json create mode 100644 wiremock/mappings/user-mapping-internal.json create mode 100644 wiremock/mappings/wrong-hs-mapping-internal.json diff --git a/tools/test-identity-mock.sh b/tools/test-identity-mock.sh index 2463f0a..bdf43c4 100755 --- a/tools/test-identity-mock.sh +++ b/tools/test-identity-mock.sh @@ -5,7 +5,9 @@ # and displays the responses # Set the base URL for the mock server -MOCK_URL="http://localhost:8083" +#MOCK_URL="http://localhost:8083" +MOCK_URL="https://matrix.tchapgouv.com" + # Colors for output GREEN='\033[0;32m' diff --git a/wiremock/mappings/invited-mapping-internal.json b/wiremock/mappings/invited-mapping-internal.json new file mode 100644 index 0000000..b62c405 --- /dev/null +++ b/wiremock/mappings/invited-mapping-internal.json @@ -0,0 +1,28 @@ +{ + "name": "Invited User Mapping", + "request": { + "method": "GET", + "urlPathPattern": "/_matrix/identity/api/v1/internal-info", + "queryParameters": { + "medium": { + "equalTo": "email" + }, + "address": { + "contains": "@invited.externe.com" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "hs": "tchapgouv.com", + "requires_invite": true, + "invited": true + }, + "headers": { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*" + } + }, + "priority": 100 +} diff --git a/wiremock/mappings/invited-mapping.json b/wiremock/mappings/invited-mapping.json index 5ae926b..9906d58 100644 --- a/wiremock/mappings/invited-mapping.json +++ b/wiremock/mappings/invited-mapping.json @@ -2,7 +2,7 @@ "name": "Invited User Mapping", "request": { "method": "GET", - "urlPathPattern": "/_matrix/identity/api/v1/internal-info", + "urlPathPattern": "/_matrix/identity/api/v1/info", "queryParameters": { "medium": { "equalTo": "email" @@ -15,12 +15,11 @@ "response": { "status": 200, "jsonBody": { - "hs": "tchapgouv.com", - "requires_invite": true, - "invited": true + "hs": "tchapgouv.com" }, "headers": { - "Content-Type": "application/json" + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*" } }, "priority": 100 diff --git a/wiremock/mappings/not-invited-mapping-internal.json b/wiremock/mappings/not-invited-mapping-internal.json new file mode 100644 index 0000000..3ee1ad9 --- /dev/null +++ b/wiremock/mappings/not-invited-mapping-internal.json @@ -0,0 +1,28 @@ +{ + "name": "Not Invited User Mapping", + "request": { + "method": "GET", + "urlPathPattern": "/_matrix/identity/api/v1/internal-info", + "queryParameters": { + "medium": { + "equalTo": "email" + }, + "address": { + "contains": "@not.invited.externe.com" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "hs": "tchapgouv.com", + "requires_invite": true, + "invited": false + }, + "headers": { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*" + } + }, + "priority": 100 +} diff --git a/wiremock/mappings/not-invited-mapping.json b/wiremock/mappings/not-invited-mapping.json index cce3b52..7788c80 100644 --- a/wiremock/mappings/not-invited-mapping.json +++ b/wiremock/mappings/not-invited-mapping.json @@ -2,7 +2,7 @@ "name": "Not Invited User Mapping", "request": { "method": "GET", - "urlPathPattern": "/_matrix/identity/api/v1/internal-info", + "urlPathPattern": "/_matrix/identity/api/v1/info", "queryParameters": { "medium": { "equalTo": "email" @@ -15,12 +15,11 @@ "response": { "status": 200, "jsonBody": { - "hs": "tchapgouv.com", - "requires_invite": true, - "invited": false + "hs": "tchapgouv.com" }, "headers": { - "Content-Type": "application/json" + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*" } }, "priority": 100 diff --git a/wiremock/mappings/user-mapping-internal.json b/wiremock/mappings/user-mapping-internal.json new file mode 100644 index 0000000..1b74de5 --- /dev/null +++ b/wiremock/mappings/user-mapping-internal.json @@ -0,0 +1,28 @@ +{ + "name": "User Standard Mapping", + "request": { + "method": "GET", + "urlPathPattern": "/_matrix/identity/api/v1/internal-info", + "queryParameters": { + "medium": { + "equalTo": "email" + }, + "address": { + "contains": "tchapgouv.com" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "hs": "tchapgouv.com", + "requires_invite": false, + "invited": false + }, + "headers": { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*" + } + }, + "priority": 100 +} diff --git a/wiremock/mappings/user-mapping.json b/wiremock/mappings/user-mapping.json index 48a856c..38f9edf 100644 --- a/wiremock/mappings/user-mapping.json +++ b/wiremock/mappings/user-mapping.json @@ -2,7 +2,7 @@ "name": "User Standard Mapping", "request": { "method": "GET", - "urlPathPattern": "/_matrix/identity/api/v1/internal-info", + "urlPathPattern": "/_matrix/identity/api/v1/info", "queryParameters": { "medium": { "equalTo": "email" @@ -15,12 +15,11 @@ "response": { "status": 200, "jsonBody": { - "hs": "tchapgouv.com", - "requires_invite": false, - "invited": false + "hs": "tchapgouv.com" }, "headers": { - "Content-Type": "application/json" + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*" } }, "priority": 100 diff --git a/wiremock/mappings/wrong-hs-mapping-internal.json b/wiremock/mappings/wrong-hs-mapping-internal.json new file mode 100644 index 0000000..5f8713f --- /dev/null +++ b/wiremock/mappings/wrong-hs-mapping-internal.json @@ -0,0 +1,28 @@ +{ + "name": "User Standard Mapping", + "request": { + "method": "GET", + "urlPathPattern": "/_matrix/identity/api/v1/internal-info", + "queryParameters": { + "medium": { + "equalTo": "email" + }, + "address": { + "contains": "wrong.server.com" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "hs": "wrong.server.com", + "requires_invite": false, + "invited": false + }, + "headers": { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*" + } + }, + "priority": 100 +} diff --git a/wiremock/mappings/wrong-hs-mapping.json b/wiremock/mappings/wrong-hs-mapping.json index 1138b66..add20a3 100644 --- a/wiremock/mappings/wrong-hs-mapping.json +++ b/wiremock/mappings/wrong-hs-mapping.json @@ -2,7 +2,7 @@ "name": "User Standard Mapping", "request": { "method": "GET", - "urlPathPattern": "/_matrix/identity/api/v1/internal-info", + "urlPathPattern": "/_matrix/identity/api/v1/info", "queryParameters": { "medium": { "equalTo": "email" @@ -15,12 +15,11 @@ "response": { "status": 200, "jsonBody": { - "hs": "wrong.server.com", - "requires_invite": false, - "invited": false + "hs": "wrong.server.com" }, "headers": { - "Content-Type": "application/json" + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*" } }, "priority": 100 From 9dab741c74d9bc92c9ce4c16b311b04f946bb3b7 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Mon, 25 Aug 2025 10:12:24 +0200 Subject: [PATCH 20/71] Add header and footer to templates login page (#7) * fix mailcatcher conf * notify the server on templates update * work on template * rename unused file * add log * add doc on template * add translation * revert * remove unused config * add tchap config * add doc * add header and footer of lasuite --- .gitignore | 2 +- resources/css/tchap.css | 27 ------- resources/templates/base.html | 15 +++- resources/templates/pages/login.html | 24 ++++-- resources/templates/tchap/footer.html | 111 ++++++++++++++++++++++++++ resources/templates/tchap/header.html | 52 ++++++++++++ resources/translations/en.json | 18 ++--- start-local-mas-hot-reload.sh | 6 +- tools/build_conf.sh | 10 +++ 9 files changed, 216 insertions(+), 49 deletions(-) delete mode 100644 resources/css/tchap.css create mode 100644 resources/templates/tchap/footer.html create mode 100644 resources/templates/tchap/header.html diff --git a/.gitignore b/.gitignore index 4ce3b6b..37dd651 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ playwright/test-results/ playwright/.env .env .history/ -.idea \ No newline at end of file +.idea diff --git a/resources/css/tchap.css b/resources/css/tchap.css deleted file mode 100644 index 7103409..0000000 --- a/resources/css/tchap.css +++ /dev/null @@ -1,27 +0,0 @@ -/* NOT USED AT THE MOMENT */ - -.proconnect-sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border-width: 0; -} - -.proconnect-button { - background-color: transparent !important; - background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScyMTEnIGhlaWdodD0nNTgnIGZpbGw9J25vbmUnPjxwYXRoIGZpbGw9JyMwMDAwOTEnIGQ9J00wIDBoMjExdjU4SDB6Jy8+PHBhdGggZmlsbD0nI2ZmZicgZD0nbTY5Ljk4NiAyNi4zNjggMS4xNTYtMS4wNzFjLjgzMyAxLjA1NCAxLjgxOSAxLjU5OCAyLjk0MSAxLjU5OCAxLjI5MiAwIDIuMDQtLjgxNiAyLjA0LTEuOTA0IDAtMi41NS01LjYyNy0yLjI0NC01LjYyNy02LjAzNSAwLTEuNzM0IDEuNDI4LTMuMTk2IDMuNDUxLTMuMTk2IDEuNjgzIDAgMi45MDcuNzY1IDMuNzkxIDEuOTM4bC0xLjE5IDEuMDM3Yy0uNjk3LTEuMDAzLTEuNTQ3LTEuNTQ3LTIuNTg0LTEuNTQ3LTEuMTA1IDAtMS44MzYuNzQ4LTEuODM2IDEuNzM0IDAgMi41NjcgNS42MjcgMi4yNDQgNS42MjcgNi4wNTIgMCAyLjAyMy0xLjU4MSAzLjM0OS0zLjY1NSAzLjM0OS0xLjc2OCAwLTMuMDc3LS42NjMtNC4xMTQtMS45NTVabTEwLjgxNy01LjcxMkg3OS40NmwxLjQ0NS00LjU1NmgxLjY0OWwtMS43NTEgNC41NTZabTQuODE4LTMuNDUxYy0uNTYgMC0xLjAyLS40NTktMS4wMi0xLjAyYTEuMDIgMS4wMiAwIDAgMSAxLjAyLTEuMDAzYy41NjEgMCAxLjAwMy40NTkgMS4wMDMgMS4wMDMgMCAuNTYxLS40NDIgMS4wMi0xLjAwMyAxLjAyWk04NC44OTEgMjh2LTguNTY4aDEuNDQ0VjI4SDg0Ljg5Wm0zLjc2Ny00LjI4NGMwLTIuNDk5IDEuNzE3LTQuNjI0IDQuNDAzLTQuNjI0IDEuMjQxIDAgMi4yNjEuNDU5IDMuMDQzIDEuMjkyVjE1LjI1aDEuNDQ1VjI4aC0xLjQ0NXYtLjk1MmMtLjc4Mi44MzMtMS44MDIgMS4yOTItMy4wNDMgMS4yOTItMi42ODYgMC00LjQwMy0yLjEyNS00LjQwMy00LjYyNFptMS41MyAwYzAgMS44MTkgMS4yMjQgMy4yNjQgMy4wNDMgMy4yNjQgMS4xOSAwIDIuMjEtLjU3OCAyLjg3My0xLjU5OFYyMi4wNWMtLjY4LTEuMDM3LTEuNy0xLjU5OC0yLjg3My0xLjU5OC0xLjgxOSAwLTMuMDQzIDEuNDQ1LTMuMDQzIDMuMjY0Wm0xOC4wMjMgMi44NzNjLS43OTkgMS4wNzEtMi4wNzQgMS43NTEtMy42NzIgMS43NTEtMi44OSAwLTQuNjc1LTIuMTI1LTQuNjc1LTQuNjI0IDAtMi42MDEgMS42NjYtNC42MjQgNC4zMTgtNC42MjQgMi4zMjkgMCAzLjg0MiAxLjU4MSAzLjg0MiAzLjcyMyAwIC4zNC0uMDUxLjY4LS4xMDIuOTE4aC02LjU2MnYuMDM0YzAgMS44ODcgMS4yOTIgMy4yNjQgMy4yMTMgMy4yNjQgMS4wODggMCAyLjAwNi0uNTEgMi41NjctMS4yNzVsMS4wNzEuODMzWm0tNC4wMTItNi4yNTZjLTEuMzk0IDAtMi4zOC43ODItMi43MiAyLjI2MWg1LjA4M2MtLjA1MS0xLjI0MS0uOTUyLTIuMjYxLTIuMzYzLTIuMjYxWk0xMTAuNDczIDI4di04LjU2OGgxLjQ0NXYuOTY5Yy42OTctLjc2NSAxLjU4MS0xLjMwOSAyLjg1Ni0xLjMwOSAxLjkyMSAwIDMuMzQ5IDEuMjkyIDMuMzQ5IDMuNzIzVjI4aC0xLjQ2MnYtNS4xMzRjMC0xLjUzLS44NS0yLjQxNC0yLjE3Ni0yLjQxNC0xLjI0MSAwLTIuMDIzLjcxNC0yLjU2NyAxLjYxNVYyOGgtMS40NDVabTExLjA1Mi0yLjg3M3YtNC4zNjloLTEuNjE1di0xLjMyNmgxLjYxNVYxNy4yOWgxLjQ2MnYyLjE0MmgyLjk3NXYxLjMyNmgtMi45NzV2NC4zNjljMCAxLjM0My42OCAxLjcxNyAxLjcxNyAxLjcxNy41NjEgMCAuOTUyLS4wNjggMS4yNzUtLjIwNHYxLjI5MmMtLjQwOC4xNy0uODY3LjIzOC0xLjQ3OS4yMzgtMS45MDQgMC0yLjk3NS0uOTUyLTIuOTc1LTMuMDQzWm03LjM3Ny03LjkyMmMtLjU2MSAwLTEuMDItLjQ1OS0xLjAyLTEuMDJhMS4wMiAxLjAyIDAgMCAxIDEuMDItMS4wMDNjLjU2MSAwIDEuMDAzLjQ1OSAxLjAwMyAxLjAwMyAwIC41NjEtLjQ0MiAxLjAyLTEuMDAzIDEuMDJaTTEyOC4xNzEgMjh2LTguNTY4aDEuNDQ1VjI4aC0xLjQ0NVptMy4zNzctOC41NjhoMS42MTV2LTEuMDU0YzAtMS44MzYgMS4yMDctMy4xMjggMy4wNDMtMy4xMjguOTUyIDAgMS43LjM0IDIuMjEuODMzbC0uOTAxIDEuMDU0YTEuNjMzIDEuNjMzIDAgMCAwLTEuMjkyLS41NzhjLS45MzUgMC0xLjU5OC42OC0xLjU5OCAxLjc4NXYxLjA4OGgyLjk3NXYxLjMyNmgtMi45NzVWMjhoLTEuNDYydi03LjI0MmgtMS42MTV2LTEuMzI2Wm04LjU0My0yLjIyN2MtLjU2MSAwLTEuMDItLjQ1OS0xLjAyLTEuMDJhMS4wMiAxLjAyIDAgMCAxIDEuMDItMS4wMDNjLjU2MSAwIDEuMDAzLjQ1OSAxLjAwMyAxLjAwMyAwIC41NjEtLjQ0MiAxLjAyLTEuMDAzIDEuMDJaTTEzOS4zNiAyOHYtOC41NjhoMS40NDVWMjhoLTEuNDQ1Wm0xMi4xMTUtMS40MTFjLS43OTkgMS4wNzEtMi4wNzQgMS43NTEtMy42NzIgMS43NTEtMi44OSAwLTQuNjc1LTIuMTI1LTQuNjc1LTQuNjI0IDAtMi42MDEgMS42NjYtNC42MjQgNC4zMTgtNC42MjQgMi4zMjkgMCAzLjg0MiAxLjU4MSAzLjg0MiAzLjcyMyAwIC4zNC0uMDUxLjY4LS4xMDIuOTE4aC02LjU2MnYuMDM0YzAgMS44ODcgMS4yOTIgMy4yNjQgMy4yMTMgMy4yNjQgMS4wODggMCAyLjAwNi0uNTEgMi41NjctMS4yNzVsMS4wNzEuODMzWm0tNC4wMTItNi4yNTZjLTEuMzk0IDAtMi4zOC43ODItMi43MiAyLjI2MWg1LjA4M2MtLjA1MS0xLjI0MS0uOTUyLTIuMjYxLTIuMzYzLTIuMjYxWk0xNTMuNzM3IDI4di04LjU2OGgxLjQ0NXYxLjA3MWMuNjI5LS43NDggMS40MTEtMS4yNDEgMi40OTktMS4yNDEuMjcyIDAgLjUyNy4wMzQuNzMxLjEwMnYxLjQ5NmEzLjEwNSAzLjEwNSAwIDAgMC0uODUtLjExOWMtMS4xMjIgMC0xLjg1My41NzgtMi4zOCAxLjQ0NVYyOGgtMS40NDVabTEzLjY4NS4zNGMtMS42ODMgMC0yLjgyMi0uOTUyLTIuODIyLTIuNDQ4IDAtMS4zMjYuOTg2LTIuMjc4IDIuODIyLTIuNTY3bDIuODczLS40NzZ2LS41OTVjMC0xLjE5LS44NS0xLjg3LTIuMDU3LTEuODctMS4wMDMgMC0xLjgzNi40NDItMi4zMjkgMS4xOWwtMS4wODgtLjgzM2MuNzQ4LTEuMDIgMS45NTUtMS42NDkgMy40NTEtMS42NDkgMi4xNzYgMCAzLjQ2OCAxLjI3NSAzLjQ2OCAzLjE2MlYyOGgtMS40NDV2LTEuMDg4Yy0uNjQ2LjkwMS0xLjcxNyAxLjQyOC0yLjg3MyAxLjQyOFptLTEuMzc3LTIuNDk5YzAgLjczMS42MjkgMS4yOTIgMS42MTUgMS4yOTIgMS4xMzkgMCAyLjA0LS41OTUgMi42MzUtMS41ODFWMjMuOTJsLTIuNTMzLjQ0MmMtMS4xOS4xODctMS43MTcuNzMxLTEuNzE3IDEuNDc5Wm03LjI1Mi02LjQwOWgxLjU2NGwyLjczNyA3LjA1NSAyLjczNy03LjA1NWgxLjU2NEwxNzguNTUgMjhoLTEuOTA0bC0zLjM0OS04LjU2OFptMTcuODU2IDcuMTU3Yy0uNzk5IDEuMDcxLTIuMDc0IDEuNzUxLTMuNjcyIDEuNzUxLTIuODkgMC00LjY3NS0yLjEyNS00LjY3NS00LjYyNCAwLTIuNjAxIDEuNjY2LTQuNjI0IDQuMzE4LTQuNjI0IDIuMzI5IDAgMy44NDIgMS41ODEgMy44NDIgMy43MjMgMCAuMzQtLjA1MS42OC0uMTAyLjkxOGgtNi41NjJ2LjAzNGMwIDEuODg3IDEuMjkyIDMuMjY0IDMuMjEzIDMuMjY0IDEuMDg4IDAgMi4wMDYtLjUxIDIuNTY3LTEuMjc1bDEuMDcxLjgzM1ptLTQuMDEyLTYuMjU2Yy0xLjM5NCAwLTIuMzguNzgyLTIuNzIgMi4yNjFoNS4wODNjLS4wNTEtMS4yNDEtLjk1Mi0yLjI2MS0yLjM2My0yLjI2MVptMTAuMTg1IDYuNjQ3YzEuMDU0IDAgMS45MDQtLjUxIDIuNDMxLTEuMjc1bDEuMTU2Ljg4NGMtLjc5OSAxLjA3MS0yLjA0IDEuNzUxLTMuNjA0IDEuNzUxLTIuODM5IDAtNC42NTgtMi4xMjUtNC42NTgtNC42MjQgMC0yLjQ5OSAxLjgxOS00LjYyNCA0LjY1OC00LjYyNCAxLjU0NyAwIDIuODA1LjY5NyAzLjYwNCAxLjc1MWwtMS4xNTYuODg0YTIuOTI1IDIuOTI1IDAgMCAwLTIuNDQ4LTEuMjc1Yy0xLjgzNiAwLTMuMTQ1IDEuNDQ1LTMuMTQ1IDMuMjY0IDAgMS44MzYgMS4zMDkgMy4yNjQgMy4xNjIgMy4yNjRaTTcwLjg1NCA0NVYzMi40aDQuMTU4YzIuNzcyIDAgNC40NjQgMS40MjIgNC40NjQgMy43NjIgMCAyLjMyMi0xLjY5MiAzLjc0NC00LjQ2NCAzLjc0NEg3My40MVY0NWgtMi41NTZabTQuMjY2LTEwLjQyMmgtMS43MXYzLjE1aDEuNzFjMS4wOCAwIDEuNzI4LS41NzYgMS43MjgtMS42MDIgMC0uOTU0LS42NDgtMS41NDgtMS43MjgtMS41NDhaTTgxLjI0OSA0NXYtOS4wNzJoMi4yODZ2LjljLjU5NC0uNjEyIDEuMzY4LTEuMDggMi4zOTQtMS4wOC4zMDYgMCAuNTc2LjA1NC43OTIuMTI2djIuMzk0YTMuOTM4IDMuOTM4IDAgMCAwLTEuMDA4LS4xMjZjLTEuMTE2IDAtMS44MzYuNjEyLTIuMTc4IDEuMTdWNDVoLTIuMjg2Wm0xMS4zODYtOS40MzJjMi45NTIgMCA0Ljk2OCAyLjE3OCA0Ljk2OCA0Ljg5NnMtMi4wMTYgNC44OTYtNC45NjggNC44OTYtNC45NjgtMi4xNzgtNC45NjgtNC44OTYgMi4wMTYtNC44OTYgNC45NjgtNC44OTZabS4wMzYgNy42MzJjMS40NTggMCAyLjU1Ni0xLjE3IDIuNTU2LTIuNzM2IDAtMS41ODQtMS4wOTgtMi43MzYtMi41NTYtMi43MzYtMS41MTIgMC0yLjYyOCAxLjE1Mi0yLjYyOCAyLjczNiAwIDEuNTg0IDEuMTE2IDIuNzM2IDIuNjI4IDIuNzM2Wm0xMy4xNzItLjIzNGMxLjQ0IDAgMi41NzQtLjcwMiAzLjI5NC0xLjcyOGwyLjAxNiAxLjU0OGMtMS4xNTIgMS41NjYtMy4wMjQgMi41NzQtNS4zMSAyLjU3NC0zLjk3OCAwLTYuNjk2LTMuMDYtNi42OTYtNi42NnMyLjcxOC02LjY2IDYuNjk2LTYuNjZjMi4yODYgMCA0LjE1OCAxLjAyNiA1LjMxIDIuNTU2bC0yLjAxNiAxLjU2NmMtLjcyLTEuMDI2LTEuODU0LTEuNzI4LTMuMjk0LTEuNzI4LTIuMzc2IDAtNC4wNjggMS44NTQtNC4wNjggNC4yNjZzMS42OTIgNC4yNjYgNC4wNjggNC4yNjZabTExLjM2Ni03LjM5OGMyLjk1MiAwIDQuOTY4IDIuMTc4IDQuOTY4IDQuODk2cy0yLjAxNiA0Ljg5Ni00Ljk2OCA0Ljg5Ni00Ljk2OC0yLjE3OC00Ljk2OC00Ljg5NiAyLjAxNi00Ljg5NiA0Ljk2OC00Ljg5NlptLjAzNiA3LjYzMmMxLjQ1OCAwIDIuNTU2LTEuMTcgMi41NTYtMi43MzYgMC0xLjU4NC0xLjA5OC0yLjczNi0yLjU1Ni0yLjczNi0xLjUxMiAwLTIuNjI4IDEuMTUyLTIuNjI4IDIuNzM2IDAgMS41ODQgMS4xMTYgMi43MzYgMi42MjggMi43MzZabTcuMDE4IDEuOHYtOS4wNzJoMi4yODZ2LjcyYy42My0uNjEyIDEuNDc2LTEuMDggMi42ODItMS4wOCAxLjk2MiAwIDMuNTI4IDEuMzUgMy41MjggNC4wMzJWNDVoLTIuMzIydi01LjMxYzAtMS4yMDYtLjY2Ni0xLjk2Mi0xLjc4Mi0xLjk2Mi0xLjE1MiAwLTEuNzY0Ljc3NC0yLjEwNiAxLjM1VjQ1aC0yLjI4NlptMTEuMDkxIDB2LTkuMDcyaDIuMjg2di43MmMuNjMtLjYxMiAxLjQ3Ni0xLjA4IDIuNjgyLTEuMDggMS45NjIgMCAzLjUyOCAxLjM1IDMuNTI4IDQuMDMyVjQ1aC0yLjMyMnYtNS4zMWMwLTEuMjA2LS42NjYtMS45NjItMS43ODItMS45NjItMS4xNTIgMC0xLjc2NC43NzQtMi4xMDYgMS4zNVY0NWgtMi4yODZabTE5LjQ0NC0xLjQ3NmMtLjg0NiAxLjEzNC0yLjI1IDEuODM2LTMuOTYgMS44MzYtMy4yMjIgMC01LjA0LTIuMjUtNS4wNC00Ljg5NiAwLTIuNjgyIDEuNjkyLTQuODk2IDQuNjYyLTQuODk2IDIuNTIgMCA0LjE3NiAxLjY5MiA0LjE3NiA0LjA2OCAwIC41MDQtLjA3Mi45OS0uMTQ0IDEuMjk2aC02LjM1NGMuMTQ0IDEuNDk0IDEuMTg4IDIuMzc2IDIuNzM2IDIuMzc2Ljk5IDAgMS44LS40MzIgMi4yODYtMS4wOGwxLjYzOCAxLjI5NlptLTQuMzM4LTYuMDQ4Yy0xLjExNiAwLTEuODcyLjU0LTIuMTc4IDEuNzI4aDQuMDg2Yy0uMDM2LS45LS43MDItMS43MjgtMS45MDgtMS43MjhabTEwLjY5NiA1LjcyNGMuODgyIDAgMS41ODQtLjQzMiAyLjAxNi0xLjA2MmwxLjgxOCAxLjM4NmMtLjg0NiAxLjExNi0yLjE3OCAxLjgzNi0zLjgzNCAxLjgzNi0zLjEzMiAwLTUuMDA0LTIuMjUtNS4wMDQtNC44OTZzMS44NzItNC44OTYgNS4wMDQtNC44OTZjMS42NTYgMCAyLjk4OC43MiAzLjgzNCAxLjgzNmwtMS44MTggMS4zODZjLS40MzItLjYzLTEuMTE2LTEuMDYyLTIuMDUyLTEuMDYyLTEuNDk0IDAtMi41OTIgMS4xNTItMi41OTIgMi43MzYgMCAxLjYwMiAxLjA5OCAyLjczNiAyLjYyOCAyLjczNlptNi4yMDQtMS41MTJ2LTMuNjcyaC0xLjY5MnYtMi4wODhoMS42OTJWMzMuNjZoMi4zMDR2Mi4yNjhoMi43NzJ2Mi4wODhoLTIuNzcydjMuNjcyYzAgMS4wMDguNTQgMS40MDQgMS40NCAxLjQwNC42MyAwIDEuMDQ0LS4wNzIgMS4zNS0uMTk4djEuOTk4Yy0uNDUuMTk4LS45OS4yODgtMS43NDYuMjg4LTIuMjY4IDAtMy4zNDgtMS4yNzgtMy4zNDgtMy40OTJaJy8+PHBhdGggZmlsbD0nIzAwMDA5MScgZD0nTTQ2Ljk5MiAxOS4wOTggMzEuOTk4IDEwLjQybC0xNC45OTQgOC43NmEuNjA2LjYwNiAwIDAgMC0uMzA2LjUyNXYxNi45NDhhLjY2Ni42NjYgMCAwIDAgLjMwNi41MjRsMTQuOTkyIDguNiAxNC45OTQtOC43MDZhLjY2Ni42NjYgMCAwIDAgLjMwNi0uNTI0VjE5LjYyNmEuNjA0LjYwNCAwIDAgMC0uMzA0LS41MjhaJy8+PHBhdGggZmlsbD0nI0ZDQzYzQScgZD0nbTI2LjY0MSAxOS41OTgtNS4wMjkgOC42MjgtNC41NTctOS4xNzUgNS4zOS0zLjExMyA0LjQ4OSAzLjE2LS4yOTMuNVptMjAuNjU2IDE2Ljk4VjE5LjYyYS42LjYgMCAwIDAtLjMwNi0uNTIzTDMxLjk5OCAxMC40MicvPjxwYXRoIGZpbGw9JyMwMDYzQ0InIGQ9J00xNi43IDM2LjU3OCAzMiAxMC40MnYzNS4zNjJsLTE0Ljk5Ni04LjYwNWEuNjY1LjY2NSAwIDAgMS0uMzA2LS41MjRWMTkuNzA2bC4wMDIgMTYuODcyWm0yNC42NjktMjAuNzM1IDUuNDU4IDMuMTU1LTQuNDg5IDkuMTUtNS4zODctOS4yMzYgNC40MTgtMy4wN1onLz48cGF0aCBmaWxsPScjZmZmJyBkPSdtNTEuNjA2IDE2LjMwMy0xOS4xOS0xMS4wMmEuOTMzLjkzMyAwIDAgMC0uODMyIDBsLTE5LjE5IDExLjAyYS44ODcuODg3IDAgMCAwLS4zOTQuNjk1djIyYS44ODUuODg1IDAgMCAwIC4zOTQuN2wxOS4xODkgMTEuMDJhLjkzMi45MzIgMCAwIDAgLjgzMiAwbDE5LjE5MS0xMS4wMmEuODg2Ljg4NiAwIDAgMCAuMzk0LS43di0yMmEuODg3Ljg4NyAwIDAgMC0uMzk0LS42OTVaTTIyLjc4OSAzNC4wNTloLjA3OWMtLjA0MiAwLS4wNzkuMDA3LS4wNzkuMDUgMCAuMS4xNTEgMCAuMi4xYS45MTIuOTEyIDAgMCAwLS42MjkuMjc2YzAgLjA1LjEuMDUuMTUxLjA1LS4wNzUuMS0uMjI2LjA1LS4yNzcuMTUyYS4xNzYuMTc2IDAgMCAwIC4xLjA1Yy0uMDUgMC0uMSAwLS4xLjA1di4xNTJjLS4xMjYgMC0uMTc2LjEtLjI3Ny4xNS4yLjE1Mi4zMjcgMCAuNTI4IDAtLjUyOC4yLS45NTYuNDc5LTEuNDg0LjYzLS4xIDAgMCAuMTUtLjEuMTUuMTUxLjEuMjI3LS4wNS4zNzctLjA1LS42NTQuMzc4LTEuMzMzLjctMi4wMzcgMS4xMzNhLjM1MS4zNTEgMCAwIDAtLjEuMmgtLjJjLS4xLjA1LS4wNS4xNzYtLjE1MS4yNzcuMjI2LjE1LjUtLjIuNjU0IDAgLjA1IDAtLjEuMDUtLjIuMDUtLjA1IDAtLjA1LjEtLjEuMWgtLjE1NGMtLjEuMDc1LS4yLjEyNi0uMi4yNzZhLjIyLjIyIDAgMCAwLS4yMjYuMSA5LjAzMSA5LjAzMSAwIDAgMCAzLjE0NC0uNTc4IDcuNjgzIDcuNjgzIDAgMCAwIDIuMDg4LTEuNTYuMTc2LjE3NiAwIDAgMSAuMDUuMWMtLjE0Ny40MzctLjQzLjgxNi0uODA2IDEuMDgtLjI3Ny4xNTItLjQ3OC4zNzgtLjcuNDc5YTQuMDU3IDQuMDU3IDAgMCAwLS40MjguMjc2Yy0uNjMyLjE5Ny0xLjI4MS4zMzUtMS45MzkuNDEybC0uMzA1LjA0NGMtLjIyNS4wMzMtLjQ0OS4wNjktLjY3MS4xMDhsLTEuOTkzLTEuMTM4YS42NDcuNjQ3IDAgMCAxLS4yODgtLjQxMS41Ny41NyAwIDAgMCAuMDk0LS4wNjMuMjY2LjI2NiAwIDAgMC0uMTEzLS4wNzF2LS42NWExMi43ODIgMTIuNzgyIDAgMCAwIDMuMDM4LS45NDIgOC43NDYgOC43NDYgMCAwIDAtMy4wMzctMS4zNDN2LTEuNTE1YTExLjY3IDExLjY3IDAgMCAxIDEuNjM5LjM5MiA2LjQyIDYuNDIgMCAwIDEgMS4xODIuNTc4Yy4xNDcuMTQuMzA3LjI2Ny40NzguMzc3YS45MS45MSAwIDAgMCAuOC4wNWguMzNhMy45NjEgMy45NjEgMCAwIDAgMS45MzctLjkwNWMwIC4wNS4wNS4wNS4xLjA1YTMuNjI5IDMuNjI5IDAgMCAxLS40MjggMS4xMzJjLjAwMy4wNS0uMDQ4LjE1Mi4wNTMuMjAyWm0yLjgxNyAzLjU3Yy4yNTEtLjEuNC0uMjc2LjYyOS0uMzc2LS4wNS4wNS0uMDUuMTUtLjEuMmEzLjY5OSAzLjY5OSAwIDAgMC0uNTI4LjQgMTUuOTY1IDE1Ljk2NSAwIDAgMC0xLjU4NSAxLjYxYy0uMjUyLjMtLjUyOC41NzgtLjguODU1LS4wOTYuMDktLjIuMTcyLS4zMS4yNDVsLTIuNTI3LTEuNDVjLjM2LjAzLjcyMS4wMTMgMS4wNzYtLjA1My4yOTQtLjA4My41OC0uMTkyLjg1NS0uMzI3di4xYy43LS4yNzcgMS4yMzItLjkwNiAxLjkzNy0xLjEzMi4wMjUgMCAuMTI2LjEuMjI2LjA1YTEuODgzIDEuODgzIDAgMCAxIDEuNTA5LS43YzAgLjA1IDAgLjEuMDUuMWguMDI1Yy0uMTUxLjEyNi0uMzI3LjI1LS41LjM3Ny0uMDU3LjA1Mi0uMDA3LjEwMi4wNDMuMTAyWm0tOC45MDgtNi4xNjN2LS4xODZhNS44MTcgNS44MTcgMCAwIDEgMS41ODgtLjE4OCAxLjUyIDEuNTIgMCAwIDEgLjQ3OCAwIDUuODYgNS44NiAwIDAgMC0yLjA2Ni4zNzRabTMwLjYgNS4wODhhLjY2NS42NjUgMCAwIDEtLjMwNi41MjRsLTEwLjA3OSA1Ljg1YTMyLjI5NiAzMi4yOTYgMCAwIDEtMy40MDgtMS4xODQgMi44MjYgMi44MjYgMCAwIDEtLjA1LTIuMjQ1Yy4wOC0uMzA4LjE5OC0uNjA1LjM1Mi0uODgzLjAyNS0uMDI1LjA1LS4wNS4wNS0uMDc2YS4wMjUuMDI1IDAgMCAwIC4wMjUtLjAyNSA0LjMyIDQuMzIgMCAwIDEgLjM3Ny0uNTU1bC4wMTUtLjAxNS4wMi0uMDIxLjAxNS0uMDE1YzAtLjAyNS4wMjUtLjA1LjA1LS4wNzYuMDI1LS4wNTEuMDc1LS4wNzYuMS0uMTI2LjE3Ni0uMTg2LjM3LS4zNTQuNTc5LS41LjIxMy0uMDc3LjQzMS0uMTM2LjY1NC0uMTc3LjgxMS4wNiAxLjYxNy4xNyAyLjQxNS4zMjhhLjc1Mi43NTIgMCAwIDEgLjI3Ny4xYy4zMDEuMDU5LjYxMi4wNDEuOTA1LS4wNWExLjEzNyAxLjEzNyAwIDAgMCAuODU1LS43MDYgMS4yMTIgMS4yMTIgMCAwIDAgLjA1LTEuMDZjLS4xNzgtLjI3NS0uMDEzLS40MzYuMTgxLS41OWwuMDY4LS4wNTRjLjA4Ni0uMDYxLjE2NC0uMTM0LjIzMS0uMjE2LjEyNi0uMjUyLS4xLS40LS4xNTEtLjYzLS4wNS0uMS0uMjI2LS4wNS0uMzI3LS4yLjM1Mi0uMTUxLjg1NS0uNDMuNjI5LS44NTctLjE1MS0uMjI3LS4zNzctLjYzLS4xLS44NTcuMzUyLS4yLjg1NS0uMTUxIDEuMDA2LS40OGExLjEzNyAxLjEzNyAwIDAgMC0uMjkyLTEuMDg0bC0uMDc1LS4xMDhhNC43NTQgNC43NTQgMCAwIDEtLjIxMS0uMzIgNi45MDUgNi45MDUgMCAwIDAtLjUyOC0uNzU3IDQuMjk3IDQuMjk3IDAgMCAxLS41MjgtMS4wMWMtLjE1MS0uMzc3LjA1LS43MDUuMDUtMS4wODNhNi4zNDcgNi4zNDcgMCAwIDAtLjMyNy0yLjE0NGMtLjEyNi0uMzUzLS4xNzYtLjczMS0uMzI3LTEuMDZhMS4xMiAxLjEyIDAgMCAwLS4yMjYtLjU4LjM3NC4zNzQgMCAwIDEgMC0uMzI3Yy4yMDUtLjE0NS4zOTktLjMwNS41NzktLjQ4YS41NjcuNTY3IDAgMCAwLS4yLS43MDVjLS4zMjctLjE1MS0uMy4zMjgtLjUyOC40MjloLS4xNTFjLS4wNS0uMTI2LjA1LS4xNzcuMTUxLS4yNzcgMC0uMDUgMC0uMTUxLS4wNS0uMTUxLS4yIDAtLjM3Ny0uMDUxLS40MjgtLjE1MWEzLjk1NyAzLjk1NyAwIDAgMC0xLjg2MS0xLjI4NmMuMTg4LjA1OC4zODIuMDkxLjU3OS4xLjMzOC4wNzEuNjkuMDM2IDEuMDA2LS4xLjIyNy0uMDc2LjI3Ny0uNDguMzc3LS43MDZhLjguOCAwIDAgMC0uMTUxLS42MzEgMi4xOSAyLjE5IDAgMCAwLS45MDYtLjc1NiA5LjEzIDkuMTMgMCAwIDEtLjY3OS0uMzUzLjk1Ni45NTYgMCAwIDAtLjI1MS0uMTI2Yy0yLjk2NS0xLjQ4NS05LjA2OS0uMi05LjUzNCAwaC0uMDA5YTguMjU0IDguMjU0IDAgMCAwLTEuMjQ5LjQ3NSAzLjkyMiAzLjkyMiAwIDAgMC0yLjM2NSAyLjQ2NSAzLjgzIDMuODMgMCAwIDAtMS4zMzMgMS41MDljLS40MjguOC0xLjA1NiAxLjUwOS0uOTU2IDIuNDE0LjEuNzguMjc3IDEuNDg0LjQyOCAyLjI4OS4wNDMuMjcyLjExLjU0LjIuOC4xLjI3NiAwIC42MjkuMTUxLjg1NS4wNzUuMTUuMDI1LjMyNy4yMjcuNDI4di4yYy4wNS4wNS4wNS4xLjE1MS4xdi4yYy40MzUuNDIzLjgwNy45MDYgMS4xMDcgMS40MzQuMS4yNzYtLjQ3OC4xNS0uNy4wNWE1Ljk3NyA1Ljk3NyAwIDAgMS0xLjEzMi0uOTU2LjE3Ni4xNzYgMCAwIDAtLjA1MS4xYy4yLjM1Mi45MDYuNzguNTI4IDEuMDA2LS4yLjEtLjQyOC0uMTUxLS42MjkuMDUtLjA1LjA3NiAwIC4xNzcgMCAuMjc3LS4yNzctLjItLjU3OC0uMS0uODU1LS4yLS4yLS4wNS0uMjUyLS40MjctLjQ3OC0uNDI3YTE1LjE5MSAxNS4xOTEgMCAwIDAtMS44MTEtLjMyNyAxNS4xNDQgMTUuMTQ0IDAgMCAwLTEuNzM5LS4xNlYxOS43MDdhLjYwNi42MDYgMCAwIDEgLjMwNi0uNTI0bDE0Ljk4Ny04Ljc2MSAxNC45OTQgOC42NzdhLjYwNS42MDUgMCAwIDEgLjMwNi41MjR2MTYuOTMyWm0tNy45NTQtOC4yNjFhLjMyNS4zMjUgMCAwIDEtLjI4Mi4xNDkgMi44NCAyLjg0IDAgMCAwLS4yODIuMjczYy4xIDAgMCAuMTQ5LjEuMTQ5LS4yMDUuMjIzLjA3Ny42OTQtLjIwNS43OTMtLjM3LjA5OS0uNzU4LjA5OS0xLjEyNyAwYS43MjcuNzI3IDAgMCAxIC4xNjctLjAxNmguMDg1YS4zODIuMzgyIDAgMCAwIC4zMzctLjEzMnYtLjJjMC0uMDUtLjA1MS0uMDUtLjEtLjA1YS4xNi4xNiAwIDAgMS0uMS4wNS4yMjMuMjIzIDAgMCAwLS4xNTQtLjIuODA2LjgwNiAwIDAgMS0uNzE4LS4yNzMuNjcuNjcgMCAwIDEgLjQzNi0uMDVjLjEyOCAwIC4wNzctLjIyMy4yMzEtLjMyMmguMTU0Yy4zMDctLjM3Mi44NzEtLjQ3MS45NzQtLjg0MyAwLS4xLS4yODItLjEtLjQ4Ny0uMTVhMi4yNiAyLjI2IDAgMCAwLS44Mi4wNWMtLjM2LjA1LS43MTIuMTQyLTEuMDUxLjI3NC4yOC0uMjA2LjU5Mi0uMzY1LjkyMy0uNDcxLjIzMi0uMDkuNDczLS4xNTcuNzE4LS4ybC4xMzItLjAyNi4xMzMtLjAyN2EuOTcuOTcgMCAwIDEgLjU1NiAwYy4yMzEuMS42MTUuMS42NjYuMjQ4LjEuMjczLS4xNTQuNTQ1LS40MzUuNzQ0LS4wNTcuMDguMTQ5LjEzNS4xNDkuMjNaJy8+PHJlY3Qgd2lkdGg9JzI5LjU2JyBoZWlnaHQ9JzEzLjMwMicgeD0nMzcnIHk9JzUnIGZpbGw9JyNGQ0M2M0EnIHJ4PScyJy8+PHBhdGggZmlsbD0nIzE2MTYxNicgZD0nTTM5LjU2MiAxNi4xNjhWNy4zMTZoMi45MjFjLjk3IDAgMS43MzIuMjM2IDIuMjg5LjcwOC41NjUuNDcyLjg0NyAxLjExNy44NDcgMS45MzUgMCAuODEtLjI4MiAxLjQ1LS44NDcgMS45MjItLjU1Ny40NzItMS4zMi43MDgtMi4yODkuNzA4aC0xLjEyNXYzLjU3OWgtMS43OTZabTIuOTk3LTcuMzIyaC0xLjIwMXYyLjIxM2gxLjJjLjM4IDAgLjY3NS0uMDk3Ljg4Ni0uMjkuMjItLjE5NS4zMjktLjQ3My4zMjktLjgzNiAwLS4zMzctLjExLS42MDItLjMyOS0uNzk2LS4yMS0uMTk0LS41MDYtLjI5MS0uODg1LS4yOTFaTTQ3LjIzIDE2LjE2OFY3LjMxNmgyLjcwN2MuOTcgMCAxLjczNi4yMzYgMi4zMDEuNzA4LjU2NS40NzIuODQ3IDEuMTE3Ljg0NyAxLjkzNSAwIC41My0uMTI2Ljk5NS0uMzc5IDEuMzktLjI0NC4zODktLjU5LjY4OC0xLjAzNy44OTlsMi43ODIgMy45MmgtMi4xNWwtMi4zNTItMy41NzloLS45MjN2My41NzloLTEuNzk1Wm0yLjgwOC03LjMyMmgtMS4wMTJ2Mi4yMTNoMS4wMTJjLjM4IDAgLjY3NC0uMDk3Ljg4NS0uMjkuMjEtLjE5NS4zMTYtLjQ3My4zMTYtLjgzNiAwLS4zMzctLjEwNS0uNjAyLS4zMTYtLjc5Ni0uMjEtLjE5NC0uNTA2LS4yOTEtLjg4NS0uMjkxWk01OS41NDkgNy4wNjNjLjY5IDAgMS4zMjMuMTI2IDEuODk2LjM4LjU4Mi4yNTIgMS4wOC41OSAxLjQ5MiAxLjAxMS40MTQuNDIxLjczNC45MTkuOTYyIDEuNDkyLjIyNy41NjUuMzQxIDEuMTY0LjM0MSAxLjc5NiAwIC42MzItLjExNCAxLjIzNS0uMzQxIDEuODA4YTQuNDg1IDQuNDg1IDAgMCAxLS45NjIgMS40OGMtLjQxMy40MjEtLjkxLjc1OC0xLjQ5MiAxLjAxMWE0LjY0OCA0LjY0OCAwIDAgMS0xLjg5Ni4zOCA0LjczOCA0LjczOCAwIDAgMS0zLjQwMi0xLjM5MSA0LjQ4NCA0LjQ4NCAwIDAgMS0uOTYxLTEuNDggNC44NTUgNC44NTUgMCAwIDEtLjM0Mi0xLjgwOGMwLS42MzMuMTE0LTEuMjMxLjM0Mi0xLjc5Ni4yMjctLjU3My41NDgtMS4wNy45NjEtMS40OTIuNDEzLS40MjIuOTEtLjc1OSAxLjQ5Mi0xLjAxMmE0LjczNyA0LjczNyAwIDAgMSAxLjkxLS4zNzlabTAgNy42NzZhMi44IDIuOCAwIDAgMCAxLjEzOC0uMjI4Yy4zNTQtLjE2LjY1My0uMzcuODk4LS42MzIuMjUyLS4yNy40NS0uNTg2LjU5NC0uOTQ5YTMuMjcgMy4yNyAwIDAgMCAuMjE1LTEuMTg4IDMuMTcgMy4xNyAwIDAgMC0uMjE1LTEuMTc2IDIuNzkxIDIuNzkxIDAgMCAwLS41OTUtLjk0OSAyLjU0OCAyLjU0OCAwIDAgMC0uODk3LS42MzIgMi42NzMgMi42NzMgMCAwIDAtMS4xMzgtLjI0Yy0uNDEzIDAtLjc5Ny4wOC0xLjE1MS4yNGEyLjY3OCAyLjY3OCAwIDAgMC0uOTEuNjMyIDIuODk5IDIuODk5IDAgMCAwLS41ODIuOTQ5IDMuMTcgMy4xNyAwIDAgMC0uMjE1IDEuMTc2YzAgLjQyMS4wNzEuODE3LjIxNSAxLjE4OC4xNDMuMzYzLjMzNy42NzkuNTgxLjk0OS4yNTMuMjYxLjU1Ny40NzIuOTEuNjMyLjM1NS4xNTIuNzM5LjIyOCAxLjE1Mi4yMjhaJy8+PC9zdmc+"); - background-position: 50% 50%; - background-repeat: no-repeat; - width: 214px; - height: 56px; - border: none; -} - -.proconnect-button:hover { - background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScyMTEnIGhlaWdodD0nNTgnIGZpbGw9J25vbmUnPjxnIGNsaXAtcGF0aD0ndXJsKCNhKSc+PHBhdGggZmlsbD0nIzEyMTJGRicgZD0nTTIxMSAwSDB2NThoMjExVjBaJy8+PHBhdGggZmlsbD0nI2ZmZicgZD0nbTY5Ljk4NiAyNi4zNjggMS4xNTYtMS4wNzFjLjgzMyAxLjA1NCAxLjgxOSAxLjU5OCAyLjk0MSAxLjU5OCAxLjI5MiAwIDIuMDQtLjgxNiAyLjA0LTEuOTA0IDAtMi41NS01LjYyNy0yLjI0NC01LjYyNy02LjAzNSAwLTEuNzM0IDEuNDI4LTMuMTk2IDMuNDUxLTMuMTk2IDEuNjgzIDAgMi45MDcuNzY1IDMuNzkxIDEuOTM4bC0xLjE5IDEuMDM3Yy0uNjk3LTEuMDAzLTEuNTQ3LTEuNTQ3LTIuNTg0LTEuNTQ3LTEuMTA1IDAtMS44MzYuNzQ4LTEuODM2IDEuNzM0IDAgMi41NjcgNS42MjcgMi4yNDQgNS42MjcgNi4wNTIgMCAyLjAyMy0xLjU4MSAzLjM0OS0zLjY1NSAzLjM0OS0xLjc2OCAwLTMuMDc3LS42NjMtNC4xMTQtMS45NTVabTEwLjgxNy01LjcxMkg3OS40NmwxLjQ0NS00LjU1NmgxLjY0OWwtMS43NTEgNC41NTZabTQuODE4LTMuNDUxYy0uNTYgMC0xLjAyLS40NTktMS4wMi0xLjAyYTEuMDIgMS4wMiAwIDAgMSAxLjAyLTEuMDAzYy41NjEgMCAxLjAwMy40NTkgMS4wMDMgMS4wMDMgMCAuNTYxLS40NDIgMS4wMi0xLjAwMyAxLjAyWk04NC44OTEgMjh2LTguNTY4aDEuNDQ0VjI4SDg0Ljg5Wm0zLjc2Ny00LjI4NGMwLTIuNDk5IDEuNzE3LTQuNjI0IDQuNDAzLTQuNjI0IDEuMjQxIDAgMi4yNjEuNDU5IDMuMDQzIDEuMjkyVjE1LjI1aDEuNDQ1VjI4aC0xLjQ0NXYtLjk1MmMtLjc4Mi44MzMtMS44MDIgMS4yOTItMy4wNDMgMS4yOTItMi42ODYgMC00LjQwMy0yLjEyNS00LjQwMy00LjYyNFptMS41MyAwYzAgMS44MTkgMS4yMjQgMy4yNjQgMy4wNDMgMy4yNjQgMS4xOSAwIDIuMjEtLjU3OCAyLjg3My0xLjU5OFYyMi4wNWMtLjY4LTEuMDM3LTEuNy0xLjU5OC0yLjg3My0xLjU5OC0xLjgxOSAwLTMuMDQzIDEuNDQ1LTMuMDQzIDMuMjY0Wm0xOC4wMjMgMi44NzNjLS43OTkgMS4wNzEtMi4wNzQgMS43NTEtMy42NzIgMS43NTEtMi44OSAwLTQuNjc1LTIuMTI1LTQuNjc1LTQuNjI0IDAtMi42MDEgMS42NjYtNC42MjQgNC4zMTgtNC42MjQgMi4zMjkgMCAzLjg0MiAxLjU4MSAzLjg0MiAzLjcyMyAwIC4zNC0uMDUxLjY4LS4xMDIuOTE4aC02LjU2MnYuMDM0YzAgMS44ODcgMS4yOTIgMy4yNjQgMy4yMTMgMy4yNjQgMS4wODggMCAyLjAwNi0uNTEgMi41NjctMS4yNzVsMS4wNzEuODMzWm0tNC4wMTItNi4yNTZjLTEuMzk0IDAtMi4zOC43ODItMi43MiAyLjI2MWg1LjA4M2MtLjA1MS0xLjI0MS0uOTUyLTIuMjYxLTIuMzYzLTIuMjYxWk0xMTAuNDczIDI4di04LjU2OGgxLjQ0NXYuOTY5Yy42OTctLjc2NSAxLjU4MS0xLjMwOSAyLjg1Ni0xLjMwOSAxLjkyMSAwIDMuMzQ5IDEuMjkyIDMuMzQ5IDMuNzIzVjI4aC0xLjQ2MnYtNS4xMzRjMC0xLjUzLS44NS0yLjQxNC0yLjE3Ni0yLjQxNC0xLjI0MSAwLTIuMDIzLjcxNC0yLjU2NyAxLjYxNVYyOGgtMS40NDVabTExLjA1Mi0yLjg3M3YtNC4zNjloLTEuNjE1di0xLjMyNmgxLjYxNVYxNy4yOWgxLjQ2MnYyLjE0MmgyLjk3NXYxLjMyNmgtMi45NzV2NC4zNjljMCAxLjM0My42OCAxLjcxNyAxLjcxNyAxLjcxNy41NjEgMCAuOTUyLS4wNjggMS4yNzUtLjIwNHYxLjI5MmMtLjQwOC4xNy0uODY3LjIzOC0xLjQ3OS4yMzgtMS45MDQgMC0yLjk3NS0uOTUyLTIuOTc1LTMuMDQzWm03LjM3Ny03LjkyMmMtLjU2MSAwLTEuMDItLjQ1OS0xLjAyLTEuMDJhMS4wMiAxLjAyIDAgMCAxIDEuMDItMS4wMDNjLjU2MSAwIDEuMDAzLjQ1OSAxLjAwMyAxLjAwMyAwIC41NjEtLjQ0MiAxLjAyLTEuMDAzIDEuMDJaTTEyOC4xNzEgMjh2LTguNTY4aDEuNDQ1VjI4aC0xLjQ0NVptMy4zNzctOC41NjhoMS42MTV2LTEuMDU0YzAtMS44MzYgMS4yMDctMy4xMjggMy4wNDMtMy4xMjguOTUyIDAgMS43LjM0IDIuMjEuODMzbC0uOTAxIDEuMDU0YTEuNjMzIDEuNjMzIDAgMCAwLTEuMjkyLS41NzhjLS45MzUgMC0xLjU5OC42OC0xLjU5OCAxLjc4NXYxLjA4OGgyLjk3NXYxLjMyNmgtMi45NzVWMjhoLTEuNDYydi03LjI0MmgtMS42MTV2LTEuMzI2Wm04LjU0My0yLjIyN2MtLjU2MSAwLTEuMDItLjQ1OS0xLjAyLTEuMDJhMS4wMiAxLjAyIDAgMCAxIDEuMDItMS4wMDNjLjU2MSAwIDEuMDAzLjQ1OSAxLjAwMyAxLjAwMyAwIC41NjEtLjQ0MiAxLjAyLTEuMDAzIDEuMDJaTTEzOS4zNiAyOHYtOC41NjhoMS40NDVWMjhoLTEuNDQ1Wm0xMi4xMTUtMS40MTFjLS43OTkgMS4wNzEtMi4wNzQgMS43NTEtMy42NzIgMS43NTEtMi44OSAwLTQuNjc1LTIuMTI1LTQuNjc1LTQuNjI0IDAtMi42MDEgMS42NjYtNC42MjQgNC4zMTgtNC42MjQgMi4zMjkgMCAzLjg0MiAxLjU4MSAzLjg0MiAzLjcyMyAwIC4zNC0uMDUxLjY4LS4xMDIuOTE4aC02LjU2MnYuMDM0YzAgMS44ODcgMS4yOTIgMy4yNjQgMy4yMTMgMy4yNjQgMS4wODggMCAyLjAwNi0uNTEgMi41NjctMS4yNzVsMS4wNzEuODMzWm0tNC4wMTItNi4yNTZjLTEuMzk0IDAtMi4zOC43ODItMi43MiAyLjI2MWg1LjA4M2MtLjA1MS0xLjI0MS0uOTUyLTIuMjYxLTIuMzYzLTIuMjYxWk0xNTMuNzM3IDI4di04LjU2OGgxLjQ0NXYxLjA3MWMuNjI5LS43NDggMS40MTEtMS4yNDEgMi40OTktMS4yNDEuMjcyIDAgLjUyNy4wMzQuNzMxLjEwMnYxLjQ5NmEzLjEwNSAzLjEwNSAwIDAgMC0uODUtLjExOWMtMS4xMjIgMC0xLjg1My41NzgtMi4zOCAxLjQ0NVYyOGgtMS40NDVabTEzLjY4NS4zNGMtMS42ODMgMC0yLjgyMi0uOTUyLTIuODIyLTIuNDQ4IDAtMS4zMjYuOTg2LTIuMjc4IDIuODIyLTIuNTY3bDIuODczLS40NzZ2LS41OTVjMC0xLjE5LS44NS0xLjg3LTIuMDU3LTEuODctMS4wMDMgMC0xLjgzNi40NDItMi4zMjkgMS4xOWwtMS4wODgtLjgzM2MuNzQ4LTEuMDIgMS45NTUtMS42NDkgMy40NTEtMS42NDkgMi4xNzYgMCAzLjQ2OCAxLjI3NSAzLjQ2OCAzLjE2MlYyOGgtMS40NDV2LTEuMDg4Yy0uNjQ2LjkwMS0xLjcxNyAxLjQyOC0yLjg3MyAxLjQyOFptLTEuMzc3LTIuNDk5YzAgLjczMS42MjkgMS4yOTIgMS42MTUgMS4yOTIgMS4xMzkgMCAyLjA0LS41OTUgMi42MzUtMS41ODFWMjMuOTJsLTIuNTMzLjQ0MmMtMS4xOS4xODctMS43MTcuNzMxLTEuNzE3IDEuNDc5Wm03LjI1Mi02LjQwOWgxLjU2NGwyLjczNyA3LjA1NSAyLjczNy03LjA1NWgxLjU2NEwxNzguNTUgMjhoLTEuOTA0bC0zLjM0OS04LjU2OFptMTcuODU2IDcuMTU3Yy0uNzk5IDEuMDcxLTIuMDc0IDEuNzUxLTMuNjcyIDEuNzUxLTIuODkgMC00LjY3NS0yLjEyNS00LjY3NS00LjYyNCAwLTIuNjAxIDEuNjY2LTQuNjI0IDQuMzE4LTQuNjI0IDIuMzI5IDAgMy44NDIgMS41ODEgMy44NDIgMy43MjMgMCAuMzQtLjA1MS42OC0uMTAyLjkxOGgtNi41NjJ2LjAzNGMwIDEuODg3IDEuMjkyIDMuMjY0IDMuMjEzIDMuMjY0IDEuMDg4IDAgMi4wMDYtLjUxIDIuNTY3LTEuMjc1bDEuMDcxLjgzM1ptLTQuMDEyLTYuMjU2Yy0xLjM5NCAwLTIuMzguNzgyLTIuNzIgMi4yNjFoNS4wODNjLS4wNTEtMS4yNDEtLjk1Mi0yLjI2MS0yLjM2My0yLjI2MVptMTAuMTg1IDYuNjQ3YzEuMDU0IDAgMS45MDQtLjUxIDIuNDMxLTEuMjc1bDEuMTU2Ljg4NGMtLjc5OSAxLjA3MS0yLjA0IDEuNzUxLTMuNjA0IDEuNzUxLTIuODM5IDAtNC42NTgtMi4xMjUtNC42NTgtNC42MjQgMC0yLjQ5OSAxLjgxOS00LjYyNCA0LjY1OC00LjYyNCAxLjU0NyAwIDIuODA1LjY5NyAzLjYwNCAxLjc1MWwtMS4xNTYuODg0YTIuOTI1IDIuOTI1IDAgMCAwLTIuNDQ4LTEuMjc1Yy0xLjgzNiAwLTMuMTQ1IDEuNDQ1LTMuMTQ1IDMuMjY0IDAgMS44MzYgMS4zMDkgMy4yNjQgMy4xNjIgMy4yNjRaTTcwLjg1NCA0NVYzMi40aDQuMTU4YzIuNzcyIDAgNC40NjQgMS40MjIgNC40NjQgMy43NjIgMCAyLjMyMi0xLjY5MiAzLjc0NC00LjQ2NCAzLjc0NEg3My40MVY0NWgtMi41NTZabTQuMjY2LTEwLjQyMmgtMS43MXYzLjE1aDEuNzFjMS4wOCAwIDEuNzI4LS41NzYgMS43MjgtMS42MDIgMC0uOTU0LS42NDgtMS41NDgtMS43MjgtMS41NDhaTTgxLjI0OSA0NXYtOS4wNzJoMi4yODZ2LjljLjU5NC0uNjEyIDEuMzY4LTEuMDggMi4zOTQtMS4wOC4zMDYgMCAuNTc2LjA1NC43OTIuMTI2djIuMzk0YTMuOTM4IDMuOTM4IDAgMCAwLTEuMDA4LS4xMjZjLTEuMTE2IDAtMS44MzYuNjEyLTIuMTc4IDEuMTdWNDVoLTIuMjg2Wm0xMS4zODYtOS40MzJjMi45NTIgMCA0Ljk2OCAyLjE3OCA0Ljk2OCA0Ljg5NnMtMi4wMTYgNC44OTYtNC45NjggNC44OTYtNC45NjgtMi4xNzgtNC45NjgtNC44OTYgMi4wMTYtNC44OTYgNC45NjgtNC44OTZabS4wMzYgNy42MzJjMS40NTggMCAyLjU1Ni0xLjE3IDIuNTU2LTIuNzM2IDAtMS41ODQtMS4wOTgtMi43MzYtMi41NTYtMi43MzYtMS41MTIgMC0yLjYyOCAxLjE1Mi0yLjYyOCAyLjczNiAwIDEuNTg0IDEuMTE2IDIuNzM2IDIuNjI4IDIuNzM2Wm0xMy4xNzItLjIzNGMxLjQ0IDAgMi41NzQtLjcwMiAzLjI5NC0xLjcyOGwyLjAxNiAxLjU0OGMtMS4xNTIgMS41NjYtMy4wMjQgMi41NzQtNS4zMSAyLjU3NC0zLjk3OCAwLTYuNjk2LTMuMDYtNi42OTYtNi42NnMyLjcxOC02LjY2IDYuNjk2LTYuNjZjMi4yODYgMCA0LjE1OCAxLjAyNiA1LjMxIDIuNTU2bC0yLjAxNiAxLjU2NmMtLjcyLTEuMDI2LTEuODU0LTEuNzI4LTMuMjk0LTEuNzI4LTIuMzc2IDAtNC4wNjggMS44NTQtNC4wNjggNC4yNjZzMS42OTIgNC4yNjYgNC4wNjggNC4yNjZabTExLjM2Ni03LjM5OGMyLjk1MiAwIDQuOTY4IDIuMTc4IDQuOTY4IDQuODk2cy0yLjAxNiA0Ljg5Ni00Ljk2OCA0Ljg5Ni00Ljk2OC0yLjE3OC00Ljk2OC00Ljg5NiAyLjAxNi00Ljg5NiA0Ljk2OC00Ljg5NlptLjAzNiA3LjYzMmMxLjQ1OCAwIDIuNTU2LTEuMTcgMi41NTYtMi43MzYgMC0xLjU4NC0xLjA5OC0yLjczNi0yLjU1Ni0yLjczNi0xLjUxMiAwLTIuNjI4IDEuMTUyLTIuNjI4IDIuNzM2IDAgMS41ODQgMS4xMTYgMi43MzYgMi42MjggMi43MzZabTcuMDE4IDEuOHYtOS4wNzJoMi4yODZ2LjcyYy42My0uNjEyIDEuNDc2LTEuMDggMi42ODItMS4wOCAxLjk2MiAwIDMuNTI4IDEuMzUgMy41MjggNC4wMzJWNDVoLTIuMzIydi01LjMxYzAtMS4yMDYtLjY2Ni0xLjk2Mi0xLjc4Mi0xLjk2Mi0xLjE1MiAwLTEuNzY0Ljc3NC0yLjEwNiAxLjM1VjQ1aC0yLjI4NlptMTEuMDkxIDB2LTkuMDcyaDIuMjg2di43MmMuNjMtLjYxMiAxLjQ3Ni0xLjA4IDIuNjgyLTEuMDggMS45NjIgMCAzLjUyOCAxLjM1IDMuNTI4IDQuMDMyVjQ1aC0yLjMyMnYtNS4zMWMwLTEuMjA2LS42NjYtMS45NjItMS43ODItMS45NjItMS4xNTIgMC0xLjc2NC43NzQtMi4xMDYgMS4zNVY0NWgtMi4yODZabTE5LjQ0NC0xLjQ3NmMtLjg0NiAxLjEzNC0yLjI1IDEuODM2LTMuOTYgMS44MzYtMy4yMjIgMC01LjA0LTIuMjUtNS4wNC00Ljg5NiAwLTIuNjgyIDEuNjkyLTQuODk2IDQuNjYyLTQuODk2IDIuNTIgMCA0LjE3NiAxLjY5MiA0LjE3NiA0LjA2OCAwIC41MDQtLjA3Mi45OS0uMTQ0IDEuMjk2aC02LjM1NGMuMTQ0IDEuNDk0IDEuMTg4IDIuMzc2IDIuNzM2IDIuMzc2Ljk5IDAgMS44LS40MzIgMi4yODYtMS4wOGwxLjYzOCAxLjI5NlptLTQuMzM4LTYuMDQ4Yy0xLjExNiAwLTEuODcyLjU0LTIuMTc4IDEuNzI4aDQuMDg2Yy0uMDM2LS45LS43MDItMS43MjgtMS45MDgtMS43MjhabTEwLjY5NiA1LjcyNGMuODgyIDAgMS41ODQtLjQzMiAyLjAxNi0xLjA2MmwxLjgxOCAxLjM4NmMtLjg0NiAxLjExNi0yLjE3OCAxLjgzNi0zLjgzNCAxLjgzNi0zLjEzMiAwLTUuMDA0LTIuMjUtNS4wMDQtNC44OTZzMS44NzItNC44OTYgNS4wMDQtNC44OTZjMS42NTYgMCAyLjk4OC43MiAzLjgzNCAxLjgzNmwtMS44MTggMS4zODZjLS40MzItLjYzLTEuMTE2LTEuMDYyLTIuMDUyLTEuMDYyLTEuNDk0IDAtMi41OTIgMS4xNTItMi41OTIgMi43MzYgMCAxLjYwMiAxLjA5OCAyLjczNiAyLjYyOCAyLjczNlptNi4yMDQtMS41MTJ2LTMuNjcyaC0xLjY5MnYtMi4wODhoMS42OTJWMzMuNjZoMi4zMDR2Mi4yNjhoMi43NzJ2Mi4wODhoLTIuNzcydjMuNjcyYzAgMS4wMDguNTQgMS40MDQgMS40NCAxLjQwNC42MyAwIDEuMDQ0LS4wNzIgMS4zNS0uMTk4djEuOTk4Yy0uNDUuMTk4LS45OS4yODgtMS43NDYuMjg4LTIuMjY4IDAtMy4zNDgtMS4yNzgtMy4zNDgtMy40OTJaJy8+PHBhdGggZmlsbD0nIzAwMDA5MScgZD0nTTQ2Ljk5MiAxOS4wOTggMzEuOTk4IDEwLjQybC0xNC45OTQgOC43NmEuNjA2LjYwNiAwIDAgMC0uMzA2LjUyNXYxNi45NDhhLjY2Ni42NjYgMCAwIDAgLjMwNi41MjRsMTQuOTkyIDguNiAxNC45OTQtOC43MDZhLjY2Ni42NjYgMCAwIDAgLjMwNi0uNTI0VjE5LjYyNmEuNjA0LjYwNCAwIDAgMC0uMzA0LS41MjhaJy8+PHBhdGggZmlsbD0nI0ZDQzYzQScgZD0nbTI2LjY0MSAxOS41OTgtNS4wMjkgOC42MjgtNC41NTctOS4xNzUgNS4zOS0zLjExMyA0LjQ4OSAzLjE2LS4yOTMuNVptMjAuNjU2IDE2Ljk4VjE5LjYyYS42LjYgMCAwIDAtLjMwNi0uNTIzTDMxLjk5OCAxMC40MicvPjxwYXRoIGZpbGw9JyMwMDYzQ0InIGQ9J00xNi43IDM2LjU3OCAzMiAxMC40MnYzNS4zNjJsLTE0Ljk5Ni04LjYwNWEuNjY1LjY2NSAwIDAgMS0uMzA2LS41MjRWMTkuNzA2bC4wMDIgMTYuODcyWm0yNC42NjktMjAuNzM1IDUuNDU4IDMuMTU1LTQuNDg5IDkuMTUtNS4zODctOS4yMzYgNC40MTgtMy4wN1onLz48cGF0aCBmaWxsPScjZmZmJyBkPSdtNTEuNjA2IDE2LjMwMy0xOS4xOS0xMS4wMmEuOTMzLjkzMyAwIDAgMC0uODMyIDBsLTE5LjE5IDExLjAyYS44ODcuODg3IDAgMCAwLS4zOTQuNjk1djIyYS44ODUuODg1IDAgMCAwIC4zOTQuN2wxOS4xODkgMTEuMDJhLjkzMi45MzIgMCAwIDAgLjgzMiAwbDE5LjE5MS0xMS4wMmEuODg2Ljg4NiAwIDAgMCAuMzk0LS43di0yMmEuODg3Ljg4NyAwIDAgMC0uMzk0LS42OTVaTTIyLjc4OSAzNC4wNTloLjA3OWMtLjA0MiAwLS4wNzkuMDA3LS4wNzkuMDUgMCAuMS4xNTEgMCAuMi4xYS45MTIuOTEyIDAgMCAwLS42MjkuMjc2YzAgLjA1LjEuMDUuMTUxLjA1LS4wNzUuMS0uMjI2LjA1LS4yNzcuMTUyYS4xNzYuMTc2IDAgMCAwIC4xLjA1Yy0uMDUgMC0uMSAwLS4xLjA1di4xNTJjLS4xMjYgMC0uMTc2LjEtLjI3Ny4xNS4yLjE1Mi4zMjcgMCAuNTI4IDAtLjUyOC4yLS45NTYuNDc5LTEuNDg0LjYzLS4xIDAgMCAuMTUtLjEuMTUuMTUxLjEuMjI3LS4wNS4zNzctLjA1LS42NTQuMzc4LTEuMzMzLjctMi4wMzcgMS4xMzNhLjM1MS4zNTEgMCAwIDAtLjEuMmgtLjJjLS4xLjA1LS4wNS4xNzYtLjE1MS4yNzcuMjI2LjE1LjUtLjIuNjU0IDAgLjA1IDAtLjEuMDUtLjIuMDUtLjA1IDAtLjA1LjEtLjEuMWgtLjE1NGMtLjEuMDc1LS4yLjEyNi0uMi4yNzZhLjIyLjIyIDAgMCAwLS4yMjYuMSA5LjAzMSA5LjAzMSAwIDAgMCAzLjE0NC0uNTc4IDcuNjgzIDcuNjgzIDAgMCAwIDIuMDg4LTEuNTYuMTc2LjE3NiAwIDAgMSAuMDUuMWMtLjE0Ny40MzctLjQzLjgxNi0uODA2IDEuMDgtLjI3Ny4xNTItLjQ3OC4zNzgtLjcuNDc5YTQuMDU3IDQuMDU3IDAgMCAwLS40MjguMjc2Yy0uNjMyLjE5Ny0xLjI4MS4zMzUtMS45MzkuNDEybC0uMzA1LjA0NGMtLjIyNS4wMzMtLjQ0OS4wNjktLjY3MS4xMDhsLTEuOTkzLTEuMTM4YS42NDcuNjQ3IDAgMCAxLS4yODgtLjQxMS41Ny41NyAwIDAgMCAuMDk0LS4wNjMuMjY2LjI2NiAwIDAgMC0uMTEzLS4wNzF2LS42NWExMi43ODIgMTIuNzgyIDAgMCAwIDMuMDM4LS45NDIgOC43NDYgOC43NDYgMCAwIDAtMy4wMzctMS4zNDN2LTEuNTE1YTExLjY3IDExLjY3IDAgMCAxIDEuNjM5LjM5MiA2LjQyIDYuNDIgMCAwIDEgMS4xODIuNTc4Yy4xNDcuMTQuMzA3LjI2Ny40NzguMzc3YS45MS45MSAwIDAgMCAuOC4wNWguMzNhMy45NjEgMy45NjEgMCAwIDAgMS45MzctLjkwNWMwIC4wNS4wNS4wNS4xLjA1YTMuNjI5IDMuNjI5IDAgMCAxLS40MjggMS4xMzJjLjAwMy4wNS0uMDQ4LjE1Mi4wNTMuMjAyWm0yLjgxNyAzLjU3Yy4yNTEtLjEuNC0uMjc2LjYyOS0uMzc2LS4wNS4wNS0uMDUuMTUtLjEuMmEzLjY5OSAzLjY5OSAwIDAgMC0uNTI4LjQgMTUuOTY1IDE1Ljk2NSAwIDAgMC0xLjU4NSAxLjYxYy0uMjUyLjMtLjUyOC41NzgtLjguODU1LS4wOTYuMDktLjIuMTcyLS4zMS4yNDVsLTIuNTI3LTEuNDVjLjM2LjAzLjcyMS4wMTMgMS4wNzYtLjA1My4yOTQtLjA4My41OC0uMTkyLjg1NS0uMzI3di4xYy43LS4yNzcgMS4yMzItLjkwNiAxLjkzNy0xLjEzMi4wMjUgMCAuMTI2LjEuMjI2LjA1YTEuODgzIDEuODgzIDAgMCAxIDEuNTA5LS43YzAgLjA1IDAgLjEuMDUuMWguMDI1Yy0uMTUxLjEyNi0uMzI3LjI1LS41LjM3Ny0uMDU3LjA1Mi0uMDA3LjEwMi4wNDMuMTAyWm0tOC45MDgtNi4xNjN2LS4xODZhNS44MTcgNS44MTcgMCAwIDEgMS41ODgtLjE4OCAxLjUyIDEuNTIgMCAwIDEgLjQ3OCAwIDUuODYgNS44NiAwIDAgMC0yLjA2Ni4zNzRabTMwLjYgNS4wODhhLjY2NS42NjUgMCAwIDEtLjMwNi41MjRsLTEwLjA3OSA1Ljg1YTMyLjI5NiAzMi4yOTYgMCAwIDEtMy40MDgtMS4xODQgMi44MjYgMi44MjYgMCAwIDEtLjA1LTIuMjQ1Yy4wOC0uMzA4LjE5OC0uNjA1LjM1Mi0uODgzLjAyNS0uMDI1LjA1LS4wNS4wNS0uMDc2YS4wMjUuMDI1IDAgMCAwIC4wMjUtLjAyNSA0LjMyIDQuMzIgMCAwIDEgLjM3Ny0uNTU1bC4wMTUtLjAxNS4wMi0uMDIxLjAxNS0uMDE1YzAtLjAyNS4wMjUtLjA1LjA1LS4wNzYuMDI1LS4wNTEuMDc1LS4wNzYuMS0uMTI2LjE3Ni0uMTg2LjM3LS4zNTQuNTc5LS41LjIxMy0uMDc3LjQzMS0uMTM2LjY1NC0uMTc3LjgxMS4wNiAxLjYxNy4xNyAyLjQxNS4zMjhhLjc1Mi43NTIgMCAwIDEgLjI3Ny4xYy4zMDEuMDU5LjYxMi4wNDEuOTA1LS4wNWExLjEzNyAxLjEzNyAwIDAgMCAuODU1LS43MDYgMS4yMTIgMS4yMTIgMCAwIDAgLjA1LTEuMDZjLS4xNzgtLjI3NS0uMDEzLS40MzYuMTgxLS41OWwuMDY4LS4wNTRjLjA4Ni0uMDYxLjE2NC0uMTM0LjIzMS0uMjE2LjEyNi0uMjUyLS4xLS40LS4xNTEtLjYzLS4wNS0uMS0uMjI2LS4wNS0uMzI3LS4yLjM1Mi0uMTUxLjg1NS0uNDMuNjI5LS44NTctLjE1MS0uMjI3LS4zNzctLjYzLS4xLS44NTcuMzUyLS4yLjg1NS0uMTUxIDEuMDA2LS40OGExLjEzNyAxLjEzNyAwIDAgMC0uMjkyLTEuMDg0bC0uMDc1LS4xMDhhNC43NTQgNC43NTQgMCAwIDEtLjIxMS0uMzIgNi45MDUgNi45MDUgMCAwIDAtLjUyOC0uNzU3IDQuMjk3IDQuMjk3IDAgMCAxLS41MjgtMS4wMWMtLjE1MS0uMzc3LjA1LS43MDUuMDUtMS4wODNhNi4zNDcgNi4zNDcgMCAwIDAtLjMyNy0yLjE0NGMtLjEyNi0uMzUzLS4xNzYtLjczMS0uMzI3LTEuMDZhMS4xMiAxLjEyIDAgMCAwLS4yMjYtLjU4LjM3NC4zNzQgMCAwIDEgMC0uMzI3Yy4yMDUtLjE0NS4zOTktLjMwNS41NzktLjQ4YS41NjcuNTY3IDAgMCAwLS4yLS43MDVjLS4zMjctLjE1MS0uMy4zMjgtLjUyOC40MjloLS4xNTFjLS4wNS0uMTI2LjA1LS4xNzcuMTUxLS4yNzcgMC0uMDUgMC0uMTUxLS4wNS0uMTUxLS4yIDAtLjM3Ny0uMDUxLS40MjgtLjE1MWEzLjk1NyAzLjk1NyAwIDAgMC0xLjg2MS0xLjI4NmMuMTg4LjA1OC4zODIuMDkxLjU3OS4xLjMzOC4wNzEuNjkuMDM2IDEuMDA2LS4xLjIyNy0uMDc2LjI3Ny0uNDguMzc3LS43MDZhLjguOCAwIDAgMC0uMTUxLS42MzEgMi4xOSAyLjE5IDAgMCAwLS45MDYtLjc1NiA5LjEzIDkuMTMgMCAwIDEtLjY3OS0uMzUzLjk1Ni45NTYgMCAwIDAtLjI1MS0uMTI2Yy0yLjk2NS0xLjQ4NS05LjA2OS0uMi05LjUzNCAwaC0uMDA5YTguMjU0IDguMjU0IDAgMCAwLTEuMjQ5LjQ3NSAzLjkyMiAzLjkyMiAwIDAgMC0yLjM2NSAyLjQ2NSAzLjgzIDMuODMgMCAwIDAtMS4zMzMgMS41MDljLS40MjguOC0xLjA1NiAxLjUwOS0uOTU2IDIuNDE0LjEuNzguMjc3IDEuNDg0LjQyOCAyLjI4OS4wNDMuMjcyLjExLjU0LjIuOC4xLjI3NiAwIC42MjkuMTUxLjg1NS4wNzUuMTUuMDI1LjMyNy4yMjcuNDI4di4yYy4wNS4wNS4wNS4xLjE1MS4xdi4yYy40MzUuNDIzLjgwNy45MDYgMS4xMDcgMS40MzQuMS4yNzYtLjQ3OC4xNS0uNy4wNWE1Ljk3NyA1Ljk3NyAwIDAgMS0xLjEzMi0uOTU2LjE3Ni4xNzYgMCAwIDAtLjA1MS4xYy4yLjM1Mi45MDYuNzguNTI4IDEuMDA2LS4yLjEtLjQyOC0uMTUxLS42MjkuMDUtLjA1LjA3NiAwIC4xNzcgMCAuMjc3LS4yNzctLjItLjU3OC0uMS0uODU1LS4yLS4yLS4wNS0uMjUyLS40MjctLjQ3OC0uNDI3YTE1LjE5MSAxNS4xOTEgMCAwIDAtMS44MTEtLjMyNyAxNS4xNDQgMTUuMTQ0IDAgMCAwLTEuNzM5LS4xNlYxOS43MDdhLjYwNi42MDYgMCAwIDEgLjMwNi0uNTI0bDE0Ljk4Ny04Ljc2MSAxNC45OTQgOC42NzdhLjYwNS42MDUgMCAwIDEgLjMwNi41MjR2MTYuOTMyWm0tNy45NTQtOC4yNjFhLjMyNS4zMjUgMCAwIDEtLjI4Mi4xNDkgMi44NCAyLjg0IDAgMCAwLS4yODIuMjczYy4xIDAgMCAuMTQ5LjEuMTQ5LS4yMDUuMjIzLjA3Ny42OTQtLjIwNS43OTMtLjM3LjA5OS0uNzU4LjA5OS0xLjEyNyAwYS43MjcuNzI3IDAgMCAxIC4xNjctLjAxNmguMDg1YS4zODIuMzgyIDAgMCAwIC4zMzctLjEzMnYtLjJjMC0uMDUtLjA1MS0uMDUtLjEtLjA1YS4xNi4xNiAwIDAgMS0uMS4wNS4yMjMuMjIzIDAgMCAwLS4xNTQtLjIuODA2LjgwNiAwIDAgMS0uNzE4LS4yNzMuNjcuNjcgMCAwIDEgLjQzNi0uMDVjLjEyOCAwIC4wNzctLjIyMy4yMzEtLjMyMmguMTU0Yy4zMDctLjM3Mi44NzEtLjQ3MS45NzQtLjg0MyAwLS4xLS4yODItLjEtLjQ4Ny0uMTVhMi4yNiAyLjI2IDAgMCAwLS44Mi4wNWMtLjM2LjA1LS43MTIuMTQyLTEuMDUxLjI3NC4yOC0uMjA2LjU5Mi0uMzY1LjkyMy0uNDcxLjIzMi0uMDkuNDczLS4xNTcuNzE4LS4ybC4xMzItLjAyNi4xMzMtLjAyN2EuOTcuOTcgMCAwIDEgLjU1NiAwYy4yMzEuMS42MTUuMS42NjYuMjQ4LjEuMjczLS4xNTQuNTQ1LS40MzUuNzQ0LS4wNTcuMDguMTQ5LjEzNS4xNDkuMjNaJy8+PHBhdGggZmlsbD0nI0ZDQzYzQScgZD0nTTY0LjU2IDVIMzlhMiAyIDAgMCAwLTIgMnY5LjMwMmEyIDIgMCAwIDAgMiAyaDI1LjU2YTIgMiAwIDAgMCAyLTJWN2EyIDIgMCAwIDAtMi0yWicvPjxwYXRoIGZpbGw9JyMxNjE2MTYnIGQ9J00zOS41NjIgMTYuMTY4VjcuMzE2aDIuOTIxYy45NyAwIDEuNzMyLjIzNiAyLjI4OS43MDguNTY1LjQ3Mi44NDcgMS4xMTcuODQ3IDEuOTM1IDAgLjgxLS4yODIgMS40NS0uODQ3IDEuOTIyLS41NTcuNDcyLTEuMzIuNzA4LTIuMjg5LjcwOGgtMS4xMjV2My41NzloLTEuNzk2Wm0yLjk5Ny03LjMyMmgtMS4yMDF2Mi4yMTNoMS4yYy4zOCAwIC42NzUtLjA5Ny44ODYtLjI5LjIyLS4xOTUuMzI5LS40NzMuMzI5LS44MzYgMC0uMzM3LS4xMS0uNjAyLS4zMjktLjc5Ni0uMjEtLjE5NC0uNTA2LS4yOTEtLjg4NS0uMjkxWk00Ny4yMyAxNi4xNjhWNy4zMTZoMi43MDdjLjk3IDAgMS43MzYuMjM2IDIuMzAxLjcwOC41NjUuNDcyLjg0NyAxLjExNy44NDcgMS45MzUgMCAuNTMtLjEyNi45OTUtLjM3OSAxLjM5LS4yNDQuMzg5LS41OS42ODgtMS4wMzcuODk5bDIuNzgyIDMuOTJoLTIuMTVsLTIuMzUyLTMuNTc5aC0uOTIzdjMuNTc5aC0xLjc5NVptMi44MDgtNy4zMjJoLTEuMDEydjIuMjEzaDEuMDEyYy4zOCAwIC42NzQtLjA5Ny44ODUtLjI5LjIxLS4xOTUuMzE2LS40NzMuMzE2LS44MzYgMC0uMzM3LS4xMDUtLjYwMi0uMzE2LS43OTYtLjIxLS4xOTQtLjUwNi0uMjkxLS44ODUtLjI5MVpNNTkuNTQ5IDcuMDYzYy42OSAwIDEuMzIzLjEyNiAxLjg5Ni4zOC41ODIuMjUyIDEuMDguNTkgMS40OTIgMS4wMTEuNDE0LjQyMS43MzQuOTE5Ljk2MiAxLjQ5Mi4yMjcuNTY1LjM0MSAxLjE2NC4zNDEgMS43OTYgMCAuNjMyLS4xMTQgMS4yMzUtLjM0MSAxLjgwOGE0LjQ4NSA0LjQ4NSAwIDAgMS0uOTYyIDEuNDhjLS40MTMuNDIxLS45MS43NTgtMS40OTIgMS4wMTFhNC42NDggNC42NDggMCAwIDEtMS44OTYuMzggNC43MzggNC43MzggMCAwIDEtMy40MDItMS4zOTEgNC40ODQgNC40ODQgMCAwIDEtLjk2MS0xLjQ4IDQuODU1IDQuODU1IDAgMCAxLS4zNDItMS44MDhjMC0uNjMzLjExNC0xLjIzMS4zNDItMS43OTYuMjI3LS41NzMuNTQ4LTEuMDcuOTYxLTEuNDkyLjQxMy0uNDIyLjkxLS43NTkgMS40OTItMS4wMTJhNC43MzcgNC43MzcgMCAwIDEgMS45MS0uMzc5Wm0wIDcuNjc2YTIuOCAyLjggMCAwIDAgMS4xMzgtLjIyOGMuMzU0LS4xNi42NTMtLjM3Ljg5OC0uNjMyLjI1Mi0uMjcuNDUtLjU4Ni41OTQtLjk0OWEzLjI3IDMuMjcgMCAwIDAgLjIxNS0xLjE4OCAzLjE3IDMuMTcgMCAwIDAtLjIxNS0xLjE3NiAyLjc5MSAyLjc5MSAwIDAgMC0uNTk1LS45NDkgMi41NDggMi41NDggMCAwIDAtLjg5Ny0uNjMyIDIuNjczIDIuNjczIDAgMCAwLTEuMTM4LS4yNGMtLjQxMyAwLS43OTcuMDgtMS4xNTEuMjRhMi42NzggMi42NzggMCAwIDAtLjkxLjYzMiAyLjg5OSAyLjg5OSAwIDAgMC0uNTgyLjk0OSAzLjE3IDMuMTcgMCAwIDAtLjIxNSAxLjE3NmMwIC40MjEuMDcxLjgxNy4yMTUgMS4xODguMTQzLjM2My4zMzcuNjc5LjU4MS45NDkuMjUzLjI2MS41NTcuNDcyLjkxLjYzMi4zNTUuMTUyLjczOS4yMjggMS4xNTIuMjI4WicvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9J2EnPjxwYXRoIGZpbGw9JyNmZmYnIGQ9J00wIDBoMjExdjU4SDB6Jy8+PC9jbGlwUGF0aD48L2RlZnM+PC9zdmc+"); -} \ No newline at end of file diff --git a/resources/templates/base.html b/resources/templates/base.html index bf0b6b1..bbfc5bb 100644 --- a/resources/templates/base.html +++ b/resources/templates/base.html @@ -26,12 +26,25 @@ {{ include_asset('src/shared.css') | indent(4) | safe }} {{ include_asset('src/templates.css') | indent(4) | safe }} + + + {{ include_asset('tchap/css/tchap.css') | indent(4) | safe }} + {{ include_asset('node_modules/@gouvfr-lasuite/integration/dist/css/homepage-full.css') | indent(4) | safe }} + + {{ captcha.head() }} + + {% include "tchap/header.html" %} + + + + {% include "tchap/footer.html" %} diff --git a/resources/templates/pages/login.html b/resources/templates/pages/login.html index a0a6a82..cacc3c5 100644 --- a/resources/templates/pages/login.html +++ b/resources/templates/pages/login.html @@ -32,12 +32,14 @@

{{ _("mas.login.headline") }}

{% endif %} - {% if providers %} + {% set params = next["params"] | default({}) | to_params(prefix="?") %} {% for provider in providers %} - {% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %} -
-

@@ -51,16 +53,22 @@

{{ _("mas.login.headline") }}

+ {% endfor %} + + - {% endfor %} - {% endif %} - + {{ field.separator() }} diff --git a/resources/templates/tchap/footer.html b/resources/templates/tchap/footer.html new file mode 100644 index 0000000..c944802 --- /dev/null +++ b/resources/templates/tchap/footer.html @@ -0,0 +1,111 @@ + + diff --git a/resources/templates/tchap/header.html b/resources/templates/tchap/header.html new file mode 100644 index 0000000..bfb514a --- /dev/null +++ b/resources/templates/tchap/header.html @@ -0,0 +1,52 @@ + + diff --git a/resources/translations/en.json b/resources/translations/en.json index 7bafe80..d5b6c0a 100644 --- a/resources/translations/en.json +++ b/resources/translations/en.json @@ -10,11 +10,11 @@ }, "continue": "Continue", "@continue": { - "context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, pages/login.html:102:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48" + "context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, pages/login.html:110:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48" }, "create_account": "Create Account", "@create_account": { - "context": "pages/login.html:118:33-59, pages/upstream_oauth2/do_register.html:191:26-52" + "context": "pages/login.html:126:33-59, pages/upstream_oauth2/do_register.html:191:26-52" }, "sign_in": "Sign in", "@sign_in": { @@ -91,7 +91,7 @@ }, "password": "Password", "@password": { - "context": "pages/login.html:90:37-57, pages/reauth.html:28:35-55, pages/register/password.html:42:33-53" + "context": "pages/login.html:98:37-57, pages/reauth.html:28:35-55, pages/register/password.html:42:33-53" }, "password_confirm": "Confirm password", "@password_confirm": { @@ -99,7 +99,7 @@ }, "username": "Username", "@username": { - "context": "pages/login.html:84:37-57, pages/register/index.html:30:35-55, pages/register/password.html:34:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:106:39-59" + "context": "pages/login.html:92:37-57, pages/register/index.html:30:35-55, pages/register/password.html:34:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:106:39-59" } }, "error": { @@ -419,11 +419,11 @@ "login": { "call_to_register": "Don't have an account yet?", "@call_to_register": { - "context": "pages/login.html:113:13-44" + "context": "pages/login.html:121:13-44" }, "continue_with_provider": "Continue with %(provider)s", "@continue_with_provider": { - "context": "pages/login.html:58:11-63, pages/register/index.html:53:15-67", + "context": "pages/login.html:66:11-63, pages/register/index.html:53:15-67", "description": "Button to log in with an upstream provider" }, "description": "Please sign in to continue:", @@ -432,7 +432,7 @@ }, "forgot_password": "Forgot password?", "@forgot_password": { - "context": "pages/login.html:95:35-65", + "context": "pages/login.html:103:35-65", "description": "On the login page, link to the account recovery process" }, "headline": "Sign in", @@ -451,11 +451,11 @@ }, "no_login_methods": "No login methods available.", "@no_login_methods": { - "context": "pages/login.html:124:11-42" + "context": "pages/login.html:132:11-42" }, "username_or_email": "Username or Email", "@username_or_email": { - "context": "pages/login.html:80:37-69" + "context": "pages/login.html:88:37-69" } }, "navbar": { diff --git a/start-local-mas-hot-reload.sh b/start-local-mas-hot-reload.sh index d367d9c..6e62e8a 100755 --- a/start-local-mas-hot-reload.sh +++ b/start-local-mas-hot-reload.sh @@ -1,6 +1,8 @@ #!/bin/bash + # runs the MAS with a hot reload when a template is modified + set -e # Source the .env file to load environment variables @@ -44,7 +46,6 @@ watch_templates() { # Use fswatch to monitor the templates directory fswatch -o "$TEMPLATE_SOURCE" | while read; do echo "Template change detected..." - cd $MAS_HOME $MAS_TCHAP_HOME/tools/build_conf.sh send_sighup done @@ -60,7 +61,6 @@ fi export MAS_HOME=$MAS_HOME export MAS_TCHAP_HOME=$PWD - # Build conf from conf.template.yaml # $MAS_TCHAP_HOME/tools/build_conf.sh @@ -82,4 +82,4 @@ trap cleanup EXIT INT TERM -./start-local-mas.sh \ No newline at end of file +./start-local-mas.sh diff --git a/tools/build_conf.sh b/tools/build_conf.sh index ffc5670..a3370fe 100755 --- a/tools/build_conf.sh +++ b/tools/build_conf.sh @@ -2,6 +2,16 @@ set -e +echo "Install tchap @vector-im/compound-design-tokens ..." + +cd "$MAS_HOME/frontend" +yarn install + +# uncomment if needed : +# echo "Building frontend and static resources with yarn build-tchap ..." +# yarn build-tchap + + echo "Building templates..." # New template directory From 227c77ae46fda247927e84352d3bb1ed1d9e7d22 Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 26 Aug 2025 14:35:28 +0200 Subject: [PATCH 21/71] fix integration tests --- conf/config.template.yaml | 8 ++++++++ wiremock/mappings/user-mapping-internal.json | 2 +- wiremock/mappings/user-mapping.json | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/conf/config.template.yaml b/conf/config.template.yaml index 92789d7..d72208e 100644 --- a/conf/config.template.yaml +++ b/conf/config.template.yaml @@ -241,3 +241,11 @@ telemetry: # # DSN to use for sending errors and crashes to Sentry # dsn: https://public@host:port/1 +tchap: + identity_server_url: "http://localhost:8083" + email_lookup_fallback_rules: + # match : the new email pattern + # search : the old email pattern + # old email in mail.numerique.gouv.fr + - match_with : '@numerique.gouv.fr' + search: '@beta.gouv.fr' \ No newline at end of file diff --git a/wiremock/mappings/user-mapping-internal.json b/wiremock/mappings/user-mapping-internal.json index 1b74de5..df123dc 100644 --- a/wiremock/mappings/user-mapping-internal.json +++ b/wiremock/mappings/user-mapping-internal.json @@ -8,7 +8,7 @@ "equalTo": "email" }, "address": { - "contains": "tchapgouv.com" + "matches" : ".*@(numerique\\.gouv\\.fr|tchapgouv\\.com|beta\\.gouv\\.fr)$" } } }, diff --git a/wiremock/mappings/user-mapping.json b/wiremock/mappings/user-mapping.json index 38f9edf..f0a7bba 100644 --- a/wiremock/mappings/user-mapping.json +++ b/wiremock/mappings/user-mapping.json @@ -8,7 +8,7 @@ "equalTo": "email" }, "address": { - "contains": "tchapgouv.com" + "matches" : ".*@(numerique\\.gouv\\.fr|tchapgouv\\.com|beta\\.gouv\\.fr)$" } } }, From fe9b3cc71ff881c5548275663ad1e5f836caa453 Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 26 Aug 2025 16:14:35 +0200 Subject: [PATCH 22/71] update scripts --- .env.sample | 2 -- start-local-mas.sh | 7 ------- 2 files changed, 9 deletions(-) diff --git a/.env.sample b/.env.sample index b8ddbc2..e2b5e17 100644 --- a/.env.sample +++ b/.env.sample @@ -1,7 +1,5 @@ ### MAS CONFIG ### -TCHAP_IDENTITY_SERVER_URL=http://localhost:8083 - ### ENV DEV CONFIG ### diff --git a/start-local-mas.sh b/start-local-mas.sh index 7169484..9987c89 100755 --- a/start-local-mas.sh +++ b/start-local-mas.sh @@ -30,13 +30,6 @@ cd $MAS_HOME $MAS_TCHAP_HOME/tools/build_conf.sh export RUST_LOG=info -# Check if TCHAP_IDENTITY_SERVER_URL is defined -if [ -z "$TCHAP_IDENTITY_SERVER_URL" ]; then - echo "Error: TCHAP_IDENTITY_SERVER_URL environment variable is not defined" - exit 1 -fi - -export TCHAP_IDENTITY_SERVER_URL=$TCHAP_IDENTITY_SERVER_URL # Start the server echo "Checking templates..." From f14ff983661bae2500f2b00fa1fd36e8dcc5562c Mon Sep 17 00:00:00 2001 From: olivier Date: Thu, 28 Aug 2025 15:35:26 +0200 Subject: [PATCH 23/71] use npm instead of yarn --- tools/build_conf.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/build_conf.sh b/tools/build_conf.sh index a3370fe..c918f38 100755 --- a/tools/build_conf.sh +++ b/tools/build_conf.sh @@ -5,11 +5,11 @@ set -e echo "Install tchap @vector-im/compound-design-tokens ..." cd "$MAS_HOME/frontend" -yarn install +npm install # uncomment if needed : # echo "Building frontend and static resources with yarn build-tchap ..." -# yarn build-tchap +npm run build-tchap echo "Building templates..." From 53253482e4614cc504778d73cdbd9b458d585112 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Mon, 1 Sep 2025 15:59:00 +0200 Subject: [PATCH 24/71] move templates to fork repo (#8) --- .env.sample | 2 +- README.md | 4 +- resources/README.md | 32 - resources/templates/base.html | 50 -- resources/templates/pages/login.html | 136 ----- resources/templates/tchap/footer.html | 111 ---- resources/templates/tchap/header.html | 52 -- resources/translations/en.json | 813 -------------------------- resources/translations/fr.json | 275 --------- start-local-mas-hot-reload.sh | 14 +- tools/build_conf.sh | 4 +- 11 files changed, 10 insertions(+), 1483 deletions(-) delete mode 100644 resources/README.md delete mode 100644 resources/templates/base.html delete mode 100644 resources/templates/pages/login.html delete mode 100644 resources/templates/tchap/footer.html delete mode 100644 resources/templates/tchap/header.html delete mode 100644 resources/translations/en.json delete mode 100644 resources/translations/fr.json diff --git a/.env.sample b/.env.sample index e2b5e17..17e4a11 100644 --- a/.env.sample +++ b/.env.sample @@ -17,4 +17,4 @@ KEYCLOAK_PORT=8082 KEYCLOAK_HOSTNAME=https://sso.tchapgouv.com # listen to template updates -TEMPLATE_SOURCE=./resources/templates \ No newline at end of file +TEMPLATE_WATCH=./resources/templates \ No newline at end of file diff --git a/README.md b/README.md index f33c457..5875648 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,11 @@ - /.github custom CI (if needed) - /conf : configuration du MAS special tchap - /playwright : test E2E -- /ressources : custom html templates, css and manifest.json - /tools : for local dev -- /translations : tchap custom labels - /wiremock : mock http response for identity server sydent -export $MAS_HOME : path of /matrix-authentication-service, fork léger https://github.com/tchapgouv/matrix-authentication-service +export $MAS_HOME : path of /matrix-authentication-service, fork https://github.com/tchapgouv/matrix-authentication-service export $MAS_TCHAP_HOME : path of /matrix-authentication-service-tchap diff --git a/resources/README.md b/resources/README.md deleted file mode 100644 index 29e966d..0000000 --- a/resources/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# Custom templates for tchap - -## Templates - -MAS templates are overriden with the script `tools/build_conf.sh` - -MAS templates needs the static resources from the frontend app. - -To reload automatically the server when a template is modified, use the script `start-local-mas-hot-reload.sh` - -## Static resources - -The manifest.json is extended in the vite config : `$MAS_HOME/frontend/tchap/vite.tchap.config.ts` - -Serve directory is defined in the config.yaml, by default it is `path: "./frontend/dist/"` - -``` -http: - listeners: - - name: web - resources: - - name: assets - path: "/resources/manifest.json" - -``` - -See in the logs - -``` -2025-06-03T12:28:33.967776Z INFO mas_cli::commands::server:297 Listening on http://[::]:8080 with resources [Discovery, Human, OAuth, Compat, GraphQL { playground: false, undocumented_oauth2_access: false }, Assets { path: "./frontend/dist/" }, AdminApi] - -``` diff --git a/resources/templates/base.html b/resources/templates/base.html deleted file mode 100644 index bbfc5bb..0000000 --- a/resources/templates/base.html +++ /dev/null @@ -1,50 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2021-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% set _ = translator(lang) %} - -{% import "components/button.html" as button %} -{% import "components/field.html" as field %} -{% import "components/back_to_client.html" as back_to_client %} -{% import "components/logout.html" as logout %} -{% import "components/errors.html" as errors %} -{% import "components/icon.html" as icon %} -{% import "components/scope.html" as scope %} -{% import "components/captcha.html" as captcha %} - - - - - - - {% block title %}{{ _("app.name") }}{% endblock title %} - - {{ include_asset('src/shared.css') | indent(4) | safe }} - {{ include_asset('src/templates.css') | indent(4) | safe }} - - - {{ include_asset('tchap/css/tchap.css') | indent(4) | safe }} - {{ include_asset('node_modules/@gouvfr-lasuite/integration/dist/css/homepage-full.css') | indent(4) | safe }} - - - {{ captcha.head() }} - - - - {% include "tchap/header.html" %} - - - - - {% include "tchap/footer.html" %} - - diff --git a/resources/templates/pages/login.html b/resources/templates/pages/login.html deleted file mode 100644 index cacc3c5..0000000 --- a/resources/templates/pages/login.html +++ /dev/null @@ -1,136 +0,0 @@ -{# -Copyright 2024 New Vector Ltd. -Copyright 2021-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. --#} - -{% extends "base.html" %} - -{% from "components/idp_brand.html" import logo %} - -{% block content %} - -
- - {% if next and next.kind == "link_upstream" %} -
-

{{ _("mas.login.link.headline") }}

- {% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %} -

{{ _("mas.login.link.description", provider=name) }}

-
- {% else %} -
-

{{ _("mas.login.headline") }}

- -
- {% endif %} -
- - - {% set params = next["params"] | default({}) | to_params(prefix="?") %} - {% for provider in providers %} - {% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %} - - -
- -

- - Qu’est-ce que ProConnect ? - -

-
- {% endfor %} - - - - - {{ field.separator() }} - - -
- {% if form.errors is not empty %} - {% for error in form.errors %} -
- {{ errors.form_error_message(error=error) }} -
- {% endfor %} - {% endif %} - - - - {% if features.login_with_email_allowed %} - {% call(f) field.field(label=_("mas.login.username_or_email"), name="username", form_state=form) %} - - {% endcall %} - {% else %} - {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %} - - {% endcall %} - {% endif %} - - {% if features.password_login %} - {% call(f) field.field(label=_("common.password"), name="password", form_state=form) %} - - {% endcall %} - - {% if features.account_recovery %} - {{ button.link_text(text=_("mas.login.forgot_password"), href="/recover", class="self-center") }} - {% endif %} - {% endif %} -
- -
- {% if features.password_login %} - {{ button.button(text=_("action.continue")) }} - {% endif %} - - - - -
- - {% if (not next or next.kind != "link_upstream") and features.password_registration %} -
-

- {{ _("mas.login.call_to_register") }} -

- - - {% set params = next["params"] | default({}) | to_params(prefix="?") %} - {{ button.link_text(text=_("action.create_account"), href="/register" ~ params) }} -
- {% endif %} - - {% if not providers and not features.password_login %} -
- {{ _("mas.login.no_login_methods") }} -
- {% endif %} - -{% endblock content %} diff --git a/resources/templates/tchap/footer.html b/resources/templates/tchap/footer.html deleted file mode 100644 index c944802..0000000 --- a/resources/templates/tchap/footer.html +++ /dev/null @@ -1,111 +0,0 @@ - - diff --git a/resources/templates/tchap/header.html b/resources/templates/tchap/header.html deleted file mode 100644 index bfb514a..0000000 --- a/resources/templates/tchap/header.html +++ /dev/null @@ -1,52 +0,0 @@ - - diff --git a/resources/translations/en.json b/resources/translations/en.json deleted file mode 100644 index d5b6c0a..0000000 --- a/resources/translations/en.json +++ /dev/null @@ -1,813 +0,0 @@ -{ - "action": { - "back": "Back", - "@back": { - "context": "pages/recovery/disabled.html:22:32-48" - }, - "cancel": "Cancel", - "@cancel": { - "context": "pages/consent.html:69:11-29, pages/device_consent.html:127:13-31, pages/policy_violation.html:44:13-31" - }, - "continue": "Continue", - "@continue": { - "context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, pages/login.html:110:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48" - }, - "create_account": "Create Account", - "@create_account": { - "context": "pages/login.html:126:33-59, pages/upstream_oauth2/do_register.html:191:26-52" - }, - "sign_in": "Sign in", - "@sign_in": { - "context": "pages/account/deactivated.html:23:28-47, pages/account/locked.html:23:28-47, pages/index.html:30:26-45" - }, - "sign_out": "Sign out", - "@sign_out": { - "context": "pages/account/logged_out.html:22:28-48, pages/consent.html:65:28-48, pages/device_consent.html:136:30-50, pages/index.html:28:28-48, pages/policy_violation.html:38:28-48, pages/sso.html:45:28-48, pages/upstream_oauth2/link_mismatch.html:24:24-44, pages/upstream_oauth2/suggest_link.html:32:26-46" - }, - "skip": "Skip", - "@skip": { - "context": "pages/register/steps/display_name.html:49:28-44" - }, - "start_over": "Start over", - "@start_over": { - "context": "pages/recovery/consumed.html:22:32-54, pages/recovery/expired.html:30:32-54, pages/register/steps/email_in_use.html:28:32-54" - } - }, - "app": { - "human_name": "Matrix Authentication Service", - "@human_name": { - "context": "pages/index.html:15:29-48", - "description": "Human readable name of the application" - }, - "name": "matrix-authentication-service", - "@name": { - "context": "app.html:17:14-27, base.html:25:31-44", - "description": "Name of the application" - }, - "technical_description": "OpenID Connect discovery document: %(discovery_url)s", - "@technical_description": { - "context": "pages/index.html:17:13-72", - "description": "Introduction text displayed on the home page" - } - }, - "branding": { - "privacy_policy": { - "alt": "Link to the service privacy policy", - "@alt": { - "context": "components/footer.html:13:83-115" - }, - "link": "Privacy Policy", - "@link": { - "context": "components/footer.html:14:14-47" - } - }, - "terms_and_conditions": { - "alt": "Link to the service terms and conditions", - "@alt": { - "context": "components/footer.html:23:80-118" - }, - "link": "Terms & Conditions", - "@link": { - "context": "components/footer.html:24:14-53" - } - } - }, - "common": { - "display_name": "Display Name", - "@display_name": { - "context": "pages/register/steps/display_name.html:34:35-59, pages/upstream_oauth2/do_register.html:146:37-61" - }, - "email_address": "Email address", - "@email_address": { - "context": "pages/recovery/start.html:34:33-58, pages/register/password.html:38:33-58, pages/upstream_oauth2/do_register.html:114:37-62" - }, - "loading": "Loading…", - "@loading": { - "context": "form_post.html:14:27-46" - }, - "mxid": "Matrix ID", - "@mxid": { - "context": "pages/upstream_oauth2/do_register.html:93:35-51" - }, - "password": "Password", - "@password": { - "context": "pages/login.html:98:37-57, pages/reauth.html:28:35-55, pages/register/password.html:42:33-53" - }, - "password_confirm": "Confirm password", - "@password_confirm": { - "context": "pages/register/password.html:46:33-61" - }, - "username": "Username", - "@username": { - "context": "pages/login.html:92:37-57, pages/register/index.html:30:35-55, pages/register/password.html:34:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:106:39-59" - } - }, - "error": { - "unexpected": "Unexpected error", - "@unexpected": { - "context": "pages/error.html:22:29-50", - "description": "Error message displayed when an unexpected error occurs" - } - }, - "mas": { - "account": { - "deactivated": { - "description": "This account (%(mxid)s) has been deleted. If this is not expected, contact your server administrator.", - "@description": { - "context": "pages/account/deactivated.html:20:27-78" - }, - "heading": "Account deleted", - "@heading": { - "context": "pages/account/deactivated.html:18:29-65" - } - }, - "locked": { - "description": "This account (%(mxid)s) has been locked. If this is not expected, contact your server administrator.", - "@description": { - "context": "pages/account/locked.html:20:27-73" - }, - "heading": "Account locked", - "@heading": { - "context": "pages/account/locked.html:18:29-60" - } - }, - "logged_out": { - "description": "This session has been terminated. Sign out to be able to log back in", - "@description": { - "context": "pages/account/logged_out.html:19:27-66" - }, - "heading": "Session terminated", - "@heading": { - "context": "pages/account/logged_out.html:18:29-64" - } - } - }, - "back_to_homepage": "Go back to the homepage", - "@back_to_homepage": { - "context": "pages/404.html:16:29-54" - }, - "captcha": { - "noscript": "This form is protected by a CAPTCHA and requires JavaScript to be enabled to submit it. Please enable JavaScript in your browser and reload this page.", - "@noscript": { - "context": "components/captcha.html:13:11-36" - } - }, - "change_password": { - "change": "Change password", - "@change": { - "description": "Button to change the user's password" - }, - "confirm": "Confirm password", - "@confirm": { - "description": "Confirmation field for the new password" - }, - "current": "Current password", - "@current": { - "description": "Field for the user's current password" - }, - "description": "This will change the password on your account.", - "@description": {}, - "heading": "Change my password", - "@heading": { - "description": "Heading on the change password page" - }, - "new": "New password", - "@new": { - "description": "Field for the user's new password" - } - }, - "choose_display_name": { - "description": "This is the name other people will see. You can change this at any time.", - "@description": { - "context": "pages/register/steps/display_name.html:17:25-65", - "description": "During the registration flow, the user is asked to choose a display name. This is the description of that form." - }, - "headline": "Choose your display name", - "@headline": { - "context": "pages/register/steps/display_name.html:16:27-64", - "description": "During the registration flow, the user is asked to choose a display name. This is the headline of that form." - } - }, - "consent": { - "client_wants_access": "%(client_name)s at %(redirect_uri)s wants to access your account.", - "@client_wants_access": { - "context": "pages/consent.html:27:11-122" - }, - "heading": "Allow access to your account?", - "@heading": { - "context": "pages/consent.html:25:27-51, pages/device_consent.html:28:29-53" - }, - "make_sure_you_trust": "Make sure that you trust %(client_name)s.", - "@make_sure_you_trust": { - "context": "pages/consent.html:38:81-142, pages/device_consent.html:104:83-144" - }, - "this_will_allow": "This will allow %(client_name)s to:", - "@this_will_allow": { - "context": "pages/consent.html:28:11-68, pages/device_consent.html:94:13-70" - }, - "you_may_be_sharing": "You may be sharing sensitive information with this site or app.", - "@you_may_be_sharing": { - "context": "pages/consent.html:39:7-42, pages/device_consent.html:105:9-44" - } - }, - "device_card": { - "access_requested": "Access requested", - "@access_requested": { - "context": "pages/device_consent.html:82:34-71" - }, - "device_code": "Code", - "@device_code": { - "context": "pages/device_consent.html:86:34-66" - }, - "generic_device": "Device", - "@generic_device": { - "context": "pages/device_consent.html:70:22-57" - }, - "ip_address": "IP address", - "@ip_address": { - "context": "pages/device_consent.html:77:36-67" - } - }, - "device_code_link": { - "description": "Link a device", - "@description": { - "context": "pages/device_link.html:19:25-62" - }, - "headline": "Enter the code displayed on your device", - "@headline": { - "context": "pages/device_link.html:18:27-61" - } - }, - "device_consent": { - "another_device_access": "Another device wants to access your account.", - "@another_device_access": { - "context": "pages/device_consent.html:93:13-58" - }, - "denied": { - "description": "You denied access to %(client_name)s. You can close this window.", - "@description": { - "context": "pages/device_consent.html:147:27-94" - }, - "heading": "Access denied", - "@heading": { - "context": "pages/device_consent.html:146:29-67" - } - }, - "granted": { - "description": "You granted access to %(client_name)s. You can close this window.", - "@description": { - "context": "pages/device_consent.html:158:27-95" - }, - "heading": "Access granted", - "@heading": { - "context": "pages/device_consent.html:157:29-68" - } - } - }, - "device_display_name": { - "client_on_device": "%(client_name)s on %(device_name)s", - "@client_on_device": { - "context": "device_name.txt:28:4-99", - "description": "The automatic device name generated for a client, e.g. 'Element on iPhone'" - }, - "name_for_platform": "%(name)s for %(platform)s", - "@name_for_platform": { - "context": "device_name.txt:19:10-102", - "description": "Part of the automatic device name for the platfom, e.g. 'Safari for macOS'" - }, - "unknown_device": "Unknown device", - "@unknown_device": { - "context": "device_name.txt:24:8-51" - } - }, - "email_in_use": { - "description": "If you have forgotten your account credentials, you can recover your account. You can also start over and use a different email address.", - "@description": { - "context": "pages/register/steps/email_in_use.html:22:13-46" - }, - "title": "The email address %(email)s is already in use", - "@title": { - "context": "pages/register/steps/email_in_use.html:19:13-53" - } - }, - "emails": { - "greeting": "Hello %(username)s,", - "@greeting": { - "context": "emails/verification.html:17:3-64, emails/verification.txt:17:3-64", - "description": "Greeting at the top of emails sent to the user" - }, - "recovery": { - "click_button": "Click on the button below to create a new password:", - "@click_button": { - "context": "emails/recovery.html:28:7-44" - }, - "copy_link": "Copy the following link and paste it into a browser to create a new password:", - "@copy_link": { - "context": "emails/recovery.html:45:49-83, emails/recovery.txt:12:3-37" - }, - "create_new_password": "Create new password", - "@create_new_password": { - "context": "emails/recovery.html:43:9-53" - }, - "fallback": "The button doesn't work for you?", - "@fallback": { - "context": "emails/recovery.html:45:9-42" - }, - "headline": "You requested a password reset for your %(server_name)s account.", - "@headline": { - "context": "emails/recovery.html:26:7-74, emails/recovery.txt:10:3-70" - }, - "subject": "Reset your account password (%(mxid)s)", - "@subject": { - "context": "emails/recovery.subject:14:3-46" - }, - "you_can_ignore": "If you didn't ask for a new password, you can ignore this email. Your current password will continue to work.", - "@you_can_ignore": { - "context": "emails/recovery.html:50:7-46, emails/recovery.txt:16:3-42" - } - }, - "verify": { - "body_html": "Your verification code to confirm this email address is: %(code)s", - "@body_html": { - "context": "emails/verification.html:19:3-66", - "description": "The body of the email sent to verify an email address (HTML)" - }, - "body_text": "Your verification code to confirm this email address is: %(code)s", - "@body_text": { - "context": "emails/verification.txt:19:3-66", - "description": "The body of the email sent to verify an email address (text)" - }, - "subject": "Your email verification code is: %(code)s", - "@subject": { - "context": "emails/verification.subject:11:3-64", - "description": "The subject line of the email sent to verify an email address" - } - } - }, - "errors": { - "captcha": "CAPTCHA verification failed, please try again", - "@captcha": { - "context": "components/errors.html:19:7-30" - }, - "denied_policy": "Denied by policy: %(policy)s", - "@denied_policy": { - "context": "components/errors.html:17:7-58, components/field.html:85:19-70" - }, - "email_banned": "Email is banned by the server policy", - "@email_banned": { - "context": "components/field.html:83:19-47" - }, - "email_domain_banned": "Email domain is banned by the server policy", - "@email_domain_banned": { - "context": "components/field.html:79:19-54" - }, - "email_domain_not_allowed": "Email domain is not allowed by the server policy", - "@email_domain_not_allowed": { - "context": "components/field.html:77:19-59" - }, - "email_not_allowed": "Email is not allowed by the server policy", - "@email_not_allowed": { - "context": "components/field.html:81:19-52" - }, - "field_required": "This field is required", - "@field_required": { - "context": "components/field.html:60:17-47" - }, - "invalid_credentials": "Invalid credentials", - "@invalid_credentials": { - "context": "components/errors.html:11:7-42" - }, - "password_mismatch": "Password fields don't match", - "@password_mismatch": { - "context": "components/errors.html:13:7-40, components/field.html:88:17-50" - }, - "rate_limit_exceeded": "You've made too many requests in a short period. Please wait a few minutes and try again.", - "@rate_limit_exceeded": { - "context": "components/errors.html:15:7-42, pages/recovery/progress.html:26:11-46" - }, - "username_all_numeric": "Username cannot consist solely of numbers", - "@username_all_numeric": { - "context": "components/field.html:71:19-55" - }, - "username_banned": "Username is banned by the server policy", - "@username_banned": { - "context": "components/field.html:73:19-50", - "description": "Error message shown on registration, when the username matches a pattern that is banned by the server policy." - }, - "username_invalid_chars": "Username contains invalid characters. Use lowercase letters, numbers, dashes and underscores only.", - "@username_invalid_chars": { - "context": "components/field.html:69:19-57" - }, - "username_not_allowed": "Username is not allowed by the server policy", - "@username_not_allowed": { - "context": "components/field.html:75:19-55", - "description": "Error message shown on registration, when the username *does not match* any of the patterns that are allowed by the server policy." - }, - "username_taken": "This username is already taken", - "@username_taken": { - "context": "components/field.html:62:17-47" - }, - "username_too_long": "Username is too long", - "@username_too_long": { - "context": "components/field.html:67:19-52" - }, - "username_too_short": "Username is too short", - "@username_too_short": { - "context": "components/field.html:65:19-53" - } - }, - "login": { - "call_to_register": "Don't have an account yet?", - "@call_to_register": { - "context": "pages/login.html:121:13-44" - }, - "continue_with_provider": "Continue with %(provider)s", - "@continue_with_provider": { - "context": "pages/login.html:66:11-63, pages/register/index.html:53:15-67", - "description": "Button to log in with an upstream provider" - }, - "description": "Please sign in to continue:", - "@description": { - "context": "pages/login.html:30:34-60" - }, - "forgot_password": "Forgot password?", - "@forgot_password": { - "context": "pages/login.html:103:35-65", - "description": "On the login page, link to the account recovery process" - }, - "headline": "Sign in", - "@headline": { - "context": "pages/login.html:29:31-54" - }, - "link": { - "description": "Linking your %(provider)s account", - "@description": { - "context": "pages/login.html:25:29-75" - }, - "headline": "Sign in to link", - "@headline": { - "context": "pages/login.html:23:31-59" - } - }, - "no_login_methods": "No login methods available.", - "@no_login_methods": { - "context": "pages/login.html:132:11-42" - }, - "username_or_email": "Username or Email", - "@username_or_email": { - "context": "pages/login.html:88:37-69" - } - }, - "navbar": { - "my_account": "My account", - "@my_account": { - "context": "pages/index.html:27:26-52" - }, - "register": "Create an account", - "@register": { - "context": "pages/index.html:33:36-60" - }, - "signed_in_as": "Signed in as %(username)s.", - "@signed_in_as": { - "context": "pages/index.html:24:11-79", - "description": "Displayed in the navbar when the user is signed in" - } - }, - "not_found": { - "description": "The page you were looking for doesn't exist or has been moved", - "@description": { - "context": "pages/404.html:14:8-38" - }, - "heading": "Page not found", - "@heading": { - "context": "pages/404.html:13:39-65" - } - }, - "not_you": "Not %(username)s?", - "@not_you": { - "context": "pages/consent.html:62:11-67, pages/device_consent.html:133:13-69, pages/sso.html:42:11-67", - "description": "Suggestions for the user to log in as a different user" - }, - "or_separator": "Or", - "@or_separator": { - "context": "components/field.html:107:10-31", - "description": "Separator between the login methods" - }, - "policy_violation": { - "description": "This might be because of the client which authored the request, the currently logged in user, or the request itself.", - "@description": { - "context": "pages/policy_violation.html:19:25-62", - "description": "Displayed when an authorization request is denied by the policy" - }, - "heading": "The authorization request was denied the policy enforced by this service", - "@heading": { - "context": "pages/policy_violation.html:18:27-60", - "description": "Displayed when an authorization request is denied by the policy" - }, - "logged_as": "Logged as %(username)s", - "@logged_as": { - "context": "pages/policy_violation.html:35:11-86" - } - }, - "recovery": { - "consumed": { - "description": "To create a new password, start over and select “Forgot password”.", - "@description": { - "context": "pages/recovery/consumed.html:19:25-63", - "description": "Description on the error page shown when a user tries to use a recovery link that has already been used" - }, - "heading": "The link to reset your password has already been used", - "@heading": { - "context": "pages/recovery/consumed.html:18:27-61", - "description": "Title on the error page shown when a user tries to use a recovery link that has already been used" - } - }, - "disabled": { - "description": "If you have lost your credentials, please contact the administrator to recover your account.", - "@description": { - "context": "pages/recovery/disabled.html:19:25-63" - }, - "heading": "Account recovery is disabled", - "@heading": { - "context": "pages/recovery/disabled.html:18:27-61" - } - }, - "expired": { - "description": "Request a new email that will be sent to: %(email)s.", - "@description": { - "context": "pages/recovery/expired.html:19:46-104", - "description": "Description on the page shown when a user tries to use an expired recovery link" - }, - "heading": "The link to reset your password has expired", - "@heading": { - "context": "pages/recovery/expired.html:18:27-60", - "description": "Title on the page shown when a user tries to use an expired recovery link" - }, - "resend_email": "Resend email", - "@resend_email": { - "context": "pages/recovery/expired.html:27:28-66" - } - }, - "finish": { - "confirm": "Enter new password again", - "@confirm": { - "context": "pages/recovery/finish.html:41:33-65", - "description": "Label for the password confirmation field" - }, - "description": "Choose a new password for your account.", - "@description": { - "context": "pages/recovery/finish.html:19:25-61", - "description": "Description for the final password recovery page" - }, - "heading": "Reset your password", - "@heading": { - "context": "pages/recovery/finish.html:18:27-59", - "description": "Heading for the final password recovery page" - }, - "new": "New password", - "@new": { - "context": "pages/recovery/finish.html:37:33-61", - "description": "Label for the new password field" - }, - "save_and_continue": "Save and continue", - "@save_and_continue": { - "context": "pages/recovery/finish.html:45:26-68", - "description": "Button to save the new password and continue" - } - }, - "progress": { - "change_email": "Try a different email", - "@change_email": { - "context": "pages/recovery/progress.html:35:33-72", - "description": "Button to change the email address for the password recovery link" - }, - "description": "We sent an email with a link to reset your password if there's an account using %(email)s.", - "@description": { - "context": "pages/recovery/progress.html:19:46-105", - "description": "The description of the password recovery page, informing the user that an email has been sent to reset their password" - }, - "heading": "Check your email", - "@heading": { - "context": "pages/recovery/progress.html:18:27-61", - "description": "The title of the password recovery page, informing the user that an email has been sent to reset their password" - }, - "resend_email": "Resend email", - "@resend_email": { - "context": "pages/recovery/progress.html:32:36-75", - "description": "Button to resend the email with the password recovery link" - } - }, - "start": { - "description": "An email will be sent with a link to reset your password.", - "@description": { - "context": "pages/recovery/start.html:19:25-60", - "description": "The description of the page to initiate an account recovery" - }, - "heading": "Enter your email to continue", - "@heading": { - "context": "pages/recovery/start.html:18:27-58", - "description": "The title of the page to initiate an account recovery" - } - } - }, - "register": { - "call_to_login": "Already have an account?", - "@call_to_login": { - "context": "pages/register/index.html:59:35-66, pages/register/password.html:77:33-64", - "description": "Displayed on the registration page to suggest to log in instead" - }, - "continue_with_email": "Continue with email address", - "@continue_with_email": { - "context": "pages/register/index.html:44:30-67" - }, - "create_account": { - "description": "Choose a username to continue.", - "@description": { - "context": "pages/register/index.html:24:29-73" - }, - "heading": "Create an account", - "@heading": { - "context": "pages/register/index.html:21:29-69, pages/register/password.html:18:27-67" - } - }, - "terms_of_service": "I agree to the Terms and Conditions", - "@terms_of_service": { - "context": "pages/register/password.html:51:35-95, pages/upstream_oauth2/do_register.html:179:35-95" - } - }, - "registration_token": { - "description": "", - "@description": { - "context": "pages/register/steps/registration_token.html:17:25-64" - }, - "field": "", - "@field": { - "context": "pages/register/steps/registration_token.html:33:35-68" - }, - "headline": "", - "@headline": { - "context": "pages/register/steps/registration_token.html:16:27-63" - } - }, - "scope": { - "edit_profile": "Edit your profile and contact details", - "@edit_profile": { - "context": "components/scope.html:15:35-62", - "description": "Displayed when the 'urn:mas:graphql:*' scope is requested" - }, - "manage_sessions": "Manage your devices and sessions", - "@manage_sessions": { - "context": "components/scope.html:16:39-69", - "description": "Displayed when the 'urn:mas:graphql:*' scope is requested" - }, - "mas_admin": "Administer any user on the matrix-authentication-service", - "@mas_admin": { - "context": "components/scope.html:23:42-66", - "description": "Displayed when the 'urn:mas:admin' scope is requested" - }, - "send_messages": "Send new messages on your behalf", - "@send_messages": { - "context": "components/scope.html:19:35-63" - }, - "synapse_admin": "Administer the Synapse homeserver", - "@synapse_admin": { - "context": "components/scope.html:21:42-70", - "description": "Displayed when the 'urn:synapse:admin:*' scope is requested" - }, - "view_messages": "View your existing messages and data", - "@view_messages": { - "context": "components/scope.html:18:35-63", - "description": "Displayed when the 'urn:matrix:client:api:*' scope is requested" - }, - "view_profile": "See your profile info and contact details", - "@view_profile": { - "context": "components/scope.html:13:43-70", - "description": "Displayed when the 'openid' scope is requested" - } - }, - "upstream_oauth2": { - "link_mismatch": { - "heading": "This upstream account is already linked to another account.", - "@heading": { - "context": "pages/upstream_oauth2/link_mismatch.html:19:11-57", - "description": "Page shown when the user tries to link an upstream account that is already linked to another account" - } - }, - "login_link": { - "action": "Link", - "@action": { - "context": "pages/upstream_oauth2/login_link.html:27:28-70" - }, - "description": "An account exists for the username : %(username)s", - "@description": { - "context": "pages/upstream_oauth2/login_link.html:21:7-85" - }, - "heading": "Link to your existing account", - "@heading": { - "context": "pages/upstream_oauth2/login_link.html:17:27-70" - } - }, - "register": { - "choose_username": { - "description": "This cannot be changed later.", - "@description": { - "context": "pages/upstream_oauth2/do_register.html:52:13-74" - }, - "heading": "Choose your username", - "@heading": { - "context": "pages/upstream_oauth2/do_register.html:49:13-70", - "description": "Displayed when creating a new account from an SSO login, and the username is not forced" - } - }, - "create_account": "Create a new account", - "@create_account": { - "description": "Displayed when creating a new account from an SSO login, and the username is pre-filled and forced" - }, - "enforced_by_policy": "Enforced by server policy", - "@enforced_by_policy": { - "context": "pages/upstream_oauth2/do_register.html:97:14-66" - }, - "forced_display_name": "Will use the following display name", - "@forced_display_name": { - "description": "Tells the user what display name will be imported" - }, - "forced_email": "Will use the following email address", - "@forced_email": { - "description": "Tells the user which email address will be imported" - }, - "forced_localpart": "Will use the following username", - "@forced_localpart": { - "description": "Tells the user which username will be used" - }, - "import_data": { - "description": "Confirm the information that will be linked to your new %(server_name)s account.", - "@description": { - "context": "pages/upstream_oauth2/do_register.html:25:13-104" - }, - "heading": "Import your data", - "@heading": { - "context": "pages/upstream_oauth2/do_register.html:22:13-66" - } - }, - "imported_from_upstream": "Imported from your upstream account", - "@imported_from_upstream": { - "context": "pages/upstream_oauth2/do_register.html:121:18-74, pages/upstream_oauth2/do_register.html:153:18-74" - }, - "imported_from_upstream_with_name": "Imported from your %(human_name)s account", - "@imported_from_upstream_with_name": { - "context": "pages/upstream_oauth2/do_register.html:119:18-131, pages/upstream_oauth2/do_register.html:151:18-131" - }, - "link_existing": "Link to an existing account", - "@link_existing": { - "description": "Button to link an existing account after an SSO login" - }, - "provider_name": "%(human_name)s account", - "@provider_name": { - "context": "pages/upstream_oauth2/do_register.html:68:14-108" - }, - "signup_with_upstream": { - "heading": "Continue signing up with your %(human_name)s account", - "@heading": { - "context": "pages/upstream_oauth2/do_register.html:37:13-122" - } - }, - "suggested_display_name": "Import display name", - "@suggested_display_name": { - "description": "Option to let the user import their display name after an SSO login" - }, - "suggested_email": "Import email address", - "@suggested_email": { - "description": "Option to let the user import their email address after an SSO login" - }, - "use": "Use", - "@use": { - "context": "pages/upstream_oauth2/do_register.html:137:18-55, pages/upstream_oauth2/do_register.html:170:20-57" - } - }, - "suggest_link": { - "action": "Link", - "@action": { - "context": "pages/upstream_oauth2/do_login.html:28:28-72, pages/upstream_oauth2/suggest_link.html:27:28-72" - }, - "heading": "Link to your existing account", - "@heading": { - "context": "pages/upstream_oauth2/do_login.html:18:27-72, pages/upstream_oauth2/suggest_link.html:18:27-72" - } - } - }, - "verify_email": { - "6_digit_code": "6-digit code", - "@6_digit_code": { - "context": "pages/register/steps/verify_email.html:33:33-67" - }, - "description": "Enter the 6-digit code sent to: %(email)s", - "@description": { - "context": "pages/register/steps/verify_email.html:18:25-86" - }, - "headline": "Verify your email", - "@headline": { - "context": "pages/register/steps/verify_email.html:17:27-57" - } - } - } -} \ No newline at end of file diff --git a/resources/translations/fr.json b/resources/translations/fr.json deleted file mode 100644 index 7f02a2e..0000000 --- a/resources/translations/fr.json +++ /dev/null @@ -1,275 +0,0 @@ -{ - "action": { - "back": "Retour", - "cancel": "Annuler", - "continue": "Continuer", - "create_account": "Créer un compte", - "sign_in": "Se connecter", - "sign_out": "Se déconnecter", - "skip": "Passer", - "start_over": "Recommencer", - "submit": "Soumettre" - }, - "app": { - "human_name": "Matrix Authentication Service", - "name": "matrix-authentication-service", - "technical_description": "Document de découverte OpenID Connect : %(discovery_url)s" - }, - "branding": { - "privacy_policy": { - "alt": "Lien vers la politique de confidentialité", - "link": "Politique de confidentialité" - }, - "terms_and_conditions": { - "alt": "Lien vers les conditions d'utilisation", - "link": "Conditions d'utilisation" - } - }, - "common": { - "display_name": "Pseudonyme", - "email_address": "Adresse mail", - "loading": "Chargement…", - "mxid": "Matrix ID", - "password": "Mot de passe", - "password_confirm": "Confirmer le mot de passe", - "username": "Nom d’utilisateur" - }, - "error": { - "unexpected": "Erreur inattendue" - }, - "mas": { - "account": { - "deactivated": { - "description": "Ce compte (%(mxid)s) a été supprimé. Si vous ne vous attendez pas à cela, contactez l'administrateur de votre serveur.", - "heading": "Compte supprimé" - }, - "locked": { - "description": "Ce compte (%(mxid)s) a été verrouillé. Si vous ne vous attendez pas à cela, contactez l'administrateur de votre serveur.", - "heading": "Compte bloqué" - }, - "logged_out": { - "description": "Cette session est terminée. Déconnectez-vous pour pouvoir vous reconnecter", - "heading": "Session terminée" - } - }, - "add_email": { - "description": "Entrez une adresse mail qui sera utilisée pour récupérer votre compte au cas où vous perdriez l'accès à celui-ci.", - "heading": "Ajouter une adresse mail" - }, - "back_to_homepage": "Retourner sur la page d'accueil", - "captcha": { - "noscript": "Ce formulaire est protégé par un CAPTCHA et nécessite l'activation de JavaScript pour le soumettre. Veuillez activer JavaScript dans votre navigateur et recharger cette page." - }, - "change_password": { - "change": "Changer de mot de passe", - "confirm": "Confirmer le mot de passe", - "current": "Mot de passe actuel", - "description": "Cela modifiera le mot de passe de votre compte.", - "heading": "Modifier mon mot de passe", - "new": "Nouveau mot de passe" - }, - "choose_display_name": { - "description": "C'est le nom que les autres personnes verront. Vous pouvez le modifier à tout moment.", - "headline": "Choisissez votre pseudonyme" - }, - "consent": { - "client_wants_access": "%(client_name)s à l'adresse %(redirect_uri)s souhaite accéder à votre compte.", - "heading": "Autoriser l'accès à votre compte ?", - "make_sure_you_trust": "Assurez-vous de faire confiance %(client_name)s.", - "this_will_allow": "Cela va permettre à %(client_name)s de :", - "you_may_be_sharing": "Vous partagez peut-être des informations sensibles avec ce site ou cette application." - }, - "device_card": { - "access_requested": "Accès demandé", - "device_code": "Code", - "generic_device": "Appareil", - "ip_address": "Adresse IP" - }, - "device_code_link": { - "description": "Associer un appareil", - "headline": "Entrez le code affiché sur votre appareil" - }, - "device_consent": { - "another_device_access": "Un autre appareil souhaite accéder à votre compte.", - "denied": { - "description": "Vous avez refusé l'accès à %(client_name)s. Vous pouvez fermer cette fenêtre.", - "heading": "Accès refusé" - }, - "granted": { - "description": "Vous avez accordé l'accès à %(client_name)s. Vous pouvez fermer cette fenêtre.", - "heading": "Accès accordé" - } - }, - "device_display_name": { - "client_on_device": "%(client_name)s sur %(device_name)s", - "name_for_platform": "%(name)s pour %(platform)s", - "unknown_device": "Appareil inconnu" - }, - "email_in_use": { - "description": "Si vous avez oublié vos identifiants de compte, vous pouvez récupérer votre compte. Vous pouvez également recommencer l'enregistrement et utiliser une adresse mail différente.", - "title": "L’adresse mail %(email)s est déjà utilisée" - }, - "emails": { - "greeting": "Bonjour %(username)s,", - "recovery": { - "click_button": "Cliquez sur le bouton ci-dessous pour créer un nouveau mot de passe :", - "copy_link": "Copiez le lien suivant et collez-le dans un navigateur pour créer un nouveau mot de passe :", - "create_new_password": "Créer un nouveau mot de passe", - "fallback": "Le bouton ne fonctionne pas pour vous ?", - "headline": "Vous avez demandé la réinitialisation du mot de passe de votre compte %(server_name)s.", - "subject": "Réinitialisez le mot de passe de votre compte (%(mxid)s)", - "you_can_ignore": "Si vous n’avez pas demandé à réinitialisation votre mot de passe, vous pouvez ignorer cet e-mail. Votre mot de passe actuel continuera de fonctionner." - }, - "verify": { - "body_html": "Votre code de vérification pour confirmer cette adresse mail est : %(code)s", - "body_text": "Votre code de vérification pour confirmer cette adresse mail est : %(code)s", - "subject": "Votre code de vérification est : %(code)s" - } - }, - "errors": { - "captcha": "La vérification du CAPTCHA a échoué, veuillez réessayer", - "denied_policy": "Refusé par la politique du serveur : %(policy)s", - "email_banned": "Cette adresse mail est interdite par la politique du serveur", - "email_domain_banned": "Le domaine de l'adresse mail est interdit par la politique du serveur.", - "email_domain_not_allowed": "Le domaine de l'adresse mail n'est pas autorisée par la politique du serveur.", - "email_not_allowed": "Cette adresse mail n'est pas autorisée par la politique du serveur", - "field_required": "Ce champ est requis", - "invalid_credentials": "Identifiants invalides", - "password_mismatch": "Les champs du mot de passe ne correspondent pas.", - "rate_limit_exceeded": "Vous avez effectué trop de requêtes sur une courte période. Veuillez patienter quelques minutes et réessayer.", - "username_all_numeric": "Le nom d'utilisateur ne peut pas être composé uniquement de chiffres", - "username_banned": "Ce nom d'utilisateur est interdit par la politique du serveur", - "username_invalid_chars": "Le nom d'utilisateur contient des caractères non valides. Utilisez uniquement des lettres minuscules, des chiffres, des tirets et des traits de soulignement.", - "username_not_allowed": "Ce nom d'utilisateur n'est pas autorisé par la politique du serveur", - "username_taken": "Ce nom d'utilisateur est déjà utilisé", - "username_too_long": "Le nom d'utilisateur est trop long", - "username_too_short": "Le nom d'utilisateur est trop court" - }, - "login": { - "call_to_register": "Vous n’avez pas encore de compte ?", - "continue_with_provider": "Poursuivre avec %(provider)s", - "description": "Veuillez vous connecter pour continuer :", - "forgot_password": "Mot de passe oublié ?", - "headline": "Se connecter", - "link": { - "description": "Associer votre compte %(provider)s", - "headline": "Se connecter pour associer" - }, - "no_login_methods": "Aucune méthode de connexion n'est disponible.", - "separator": "Ou", - "username_or_email": "Adresse mail professionnelle" - }, - "navbar": { - "my_account": "Mon compte", - "register": "Créer un compte", - "signed_in_as": "Connecté en tant que %(username)s" - }, - "not_found": { - "description": "La page que vous recherchez n’existe pas ou a été déplacée", - "heading": "Page introuvable" - }, - "not_you": "Vous n'êtes pas %(username)s ?", - "or_separator": "Ou", - "policy_violation": { - "description": "Cela peut être dû à l'application auteur de la demande, à l'utilisateur actuellement connecté ou à la demande elle-même.", - "heading": "La demande d'autorisation a été refusée, conformément à la politique appliquée par ce service.", - "logged_as": "Connecté en tant que %(username)s" - }, - "recovery": { - "consumed": { - "description": "Pour créer un nouveau mot de passe, recommencez et sélectionnez « Mot de passe oublié ».", - "heading": "Le lien pour réinitialiser votre mot de passe a déjà été utilisé" - }, - "disabled": { - "description": "Si vous avez perdu vos identifiants, veuillez contacter l'administrateur pour récupérer votre compte.", - "heading": "La récupération de compte est désactivée" - }, - "expired": { - "description": "Demander un nouvel e-mail qui sera envoyé à : %(email)s.", - "heading": "Le lien pour réinitialiser votre mot de passe a expiré", - "resend_email": "Renvoyer l’e-mail" - }, - "finish": { - "confirm": "Entrez de nouveau votre nouveau mot de passe", - "description": "Choisissez un nouveau mot de passe pour votre compte.", - "heading": "Réinitialiser votre mot de passe", - "new": "Nouveau mot de passe", - "save_and_continue": "Sauvegarder et continuer" - }, - "progress": { - "change_email": "Essayez une autre adresse mail", - "description": "Nous avons envoyé un e-mail contenant un lien pour réinitialiser votre mot de passe si un compte utilise %(email)s.", - "heading": "Vérifiez vos e-mails", - "resend_email": "Renvoyer l’e-mail" - }, - "start": { - "description": "", - "heading": "Entrez votre adresse mail pour réinitialiser votre mot de passe" - } - }, - "register": { - "call_to_login": "Vous avez déjà un compte ?", - "continue_with_email": "Continuer avec une adresse mail", - "create_account": { - "description": "Choisissez un nom d'utilisateur pour continuer.", - "heading": "Créer un compte" - }, - "sign_in_instead": "Se connecter", - "terms_of_service": "J'accepte les Conditions d'utilisation" - }, - "scope": { - "edit_profile": "Modifier votre profil et vos coordonnées", - "manage_sessions": "Gérer vos appareils et vos sessions", - "mas_admin": "Administrer n'importe quel utilisateur dans matrix-authentication-service", - "send_messages": "Envoyez de nouveaux messages en votre nom", - "synapse_admin": "Administrer le serveur d’accueil Synapse", - "view_messages": "Afficher vos messages et données existants", - "view_profile": "Voir les informations de votre profil et vos coordonnées" - }, - "upstream_oauth2": { - "link_mismatch": { - "heading": "Ce compte est déjà associé à un autre compte." - }, - "register": { - "choose_username": { - "description": "Cela ne peut pas être modifié ultérieurement.", - "heading": "Choisissez votre nom d'utilisateur" - }, - "create_account": "Créer un nouveau compte", - "enforced_by_policy": "Importé selon la politique du serveur", - "forced_display_name": "Utilisera le pseudonyme suivant", - "forced_email": "Utilisera l’adresse mail suivante", - "forced_localpart": "Utilisera le nom d'utilisateur suivant", - "import_data": { - "description": "Confirmez les informations qui seront associées à votre nouveau compte %(server_name)s.", - "heading": "Importez vos données" - }, - "imported_from_upstream": "Importé de votre compte en amont", - "imported_from_upstream_with_name": "Importé depuis votre compte %(human_name)s", - "link_existing": "Associer un compte existant", - "provider_name": "Compte %(human_name)s", - "signup_with_upstream": { - "heading": "Continuez à vous inscrire avec votre compte %(human_name)s" - }, - "suggested_display_name": "Importer le pseudonyme", - "suggested_email": "Importer l’adresse mail", - "use": "Importer" - }, - "suggest_link": { - "action": "Associer", - "heading": "Associer votre compte existant" - }, - "login_link": { - "action": "Continuer", - "heading": "Associer votre compte existant", - "description": "Un compte existe pour ce nom d'utilisateur: %(username)s, il sera associé à votre compte Proconnect" - } - }, - "verify_email": { - "6_digit_code": "Code à 6 chiffres", - "code": "Code", - "description": "Veuillez saisir le code à 6 chiffres envoyé à : %(email)s", - "headline": "Vérifiez votre adresse mail" - } - } -} \ No newline at end of file diff --git a/start-local-mas-hot-reload.sh b/start-local-mas-hot-reload.sh index 6e62e8a..acba56e 100755 --- a/start-local-mas-hot-reload.sh +++ b/start-local-mas-hot-reload.sh @@ -20,10 +20,10 @@ if [ -z "$MAS_HOME" ]; then exit 1 fi -# Check if TEMPLATE_SOURCE is defined -if [ -z "$TEMPLATE_SOURCE" ]; then - echo "Error: TEMPLATE_SOURCE environment variable is not defined" - echo "Please set TEMPLATE_SOURCE to the path of your templates directory" +# Check if TEMPLATE_WATCH is defined +if [ -z "$TEMPLATE_WATCH" ]; then + echo "Error: TEMPLATE_WATCH environment variable is not defined" + echo "Please set TEMPLATE_WATCH to the path of your templates directory" exit 1 fi @@ -41,10 +41,10 @@ send_sighup() { # Function to watch for template changes watch_templates() { - echo "Watching for changes in $TEMPLATE_SOURCE..." + echo "Watching for changes in $TEMPLATE_WATCH..." # Use fswatch to monitor the templates directory - fswatch -o "$TEMPLATE_SOURCE" | while read; do + fswatch -o "$TEMPLATE_WATCH" | while read; do echo "Template change detected..." $MAS_TCHAP_HOME/tools/build_conf.sh send_sighup @@ -80,6 +80,4 @@ cleanup() { # Set up trap for cleanup trap cleanup EXIT INT TERM - - ./start-local-mas.sh diff --git a/tools/build_conf.sh b/tools/build_conf.sh index c918f38..1571b1f 100755 --- a/tools/build_conf.sh +++ b/tools/build_conf.sh @@ -25,7 +25,7 @@ fi cp -r "$MAS_HOME/templates" "$MAS_TCHAP_DATA" # Override MAS template with custom tchap template -cp -r "$MAS_TCHAP_HOME/resources/templates" "$MAS_TCHAP_DATA" +cp -r "$MAS_HOME/tchap/resources/templates" "$MAS_TCHAP_DATA" echo "Building MAS config..." @@ -38,7 +38,7 @@ MAS_TCHAP_TEMPLATES="$MAS_TCHAP_DATA/templates" sed -i '' -E "/^templates:/,/^[^[:space:]]/ s|^[[:space:]]*path:.*| path: \"$MAS_TCHAP_TEMPLATES\"|" "$yaml_file" echo "Updating translations..." -MAS_TCHAP_TRANSLATIONS="$MAS_TCHAP_HOME/resources/translations" +MAS_TCHAP_TRANSLATIONS="$MAS_HOME/tchap/resources/translations" cargo run -p mas-i18n-scan -- --update "${MAS_TCHAP_TEMPLATES}" "${MAS_TCHAP_TRANSLATIONS}/en.json" From f8dea89305c620062be3771310bebcf3f9b14ab7 Mon Sep 17 00:00:00 2001 From: olivier Date: Fri, 5 Sep 2025 19:18:43 +0200 Subject: [PATCH 25/71] deprecate --- README.md | 14 +++++++------- ...er-compose.yml => docker-compose.yml.deprecated | 0 ....sh => start-local-mas-hot-reload.sh.deprecated | 0 ...t-local-mas.sh => start-local-mas.sh.deprecated | 0 ...cal-stack.sh => start-local-stack.sh.deprecated | 0 5 files changed, 7 insertions(+), 7 deletions(-) rename docker-compose.yml => docker-compose.yml.deprecated (100%) rename start-local-mas-hot-reload.sh => start-local-mas-hot-reload.sh.deprecated (100%) rename start-local-mas.sh => start-local-mas.sh.deprecated (100%) rename start-local-stack.sh => start-local-stack.sh.deprecated (100%) diff --git a/README.md b/README.md index 5875648..e3f1a11 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # folder structure -- /.github custom CI (if needed) -- /conf : configuration du MAS special tchap + +- /conf : configuration du MAS special tchap (deprecated) - /playwright : test E2E -- /tools : for local dev -- /wiremock : mock http response for identity server sydent +- /tools : for local dev (deprecated) +- /wiremock : mock http response for identity server sydent (deprecated) export $MAS_HOME : path of /matrix-authentication-service, fork https://github.com/tchapgouv/matrix-authentication-service @@ -14,7 +14,7 @@ export $MAS_TCHAP_HOME : path of /matrix-authentication-service-tchap # playwright tests -## setup synapse +## setup synapse checkout la branche : https://github.com/tchapgouv/element-docker-demo/tree/local-dev @@ -37,7 +37,7 @@ créer un compte à https://element.tchapgouv.com/ c'est normal le MAS n'est pas démarré -## setup MAS +## setup MAS (deprecated) - launch postres services @@ -49,7 +49,7 @@ cp .env.sample .env matrix-authentication-service-tchap % ./start-local-stack.sh ``` -keycloak container might take some time to start up +keycloak container might take some time to start up (deprecated) ```bash export MAS_HOME=/Users/olivier/workspace/beta/Tchap/matrix-authentication-service diff --git a/docker-compose.yml b/docker-compose.yml.deprecated similarity index 100% rename from docker-compose.yml rename to docker-compose.yml.deprecated diff --git a/start-local-mas-hot-reload.sh b/start-local-mas-hot-reload.sh.deprecated similarity index 100% rename from start-local-mas-hot-reload.sh rename to start-local-mas-hot-reload.sh.deprecated diff --git a/start-local-mas.sh b/start-local-mas.sh.deprecated similarity index 100% rename from start-local-mas.sh rename to start-local-mas.sh.deprecated diff --git a/start-local-stack.sh b/start-local-stack.sh.deprecated similarity index 100% rename from start-local-stack.sh rename to start-local-stack.sh.deprecated From 9062750d38033bbf4ff116de551c0ef43b90dd1f Mon Sep 17 00:00:00 2001 From: olivier Date: Fri, 5 Sep 2025 19:18:58 +0200 Subject: [PATCH 26/71] add tchap e2e test --- playwright/tests/tchap-oidc-auth.spec.ts | 50 +++++++++++++++++++++++ playwright/tests/utils/auth-helpers.ts | 51 ++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 playwright/tests/tchap-oidc-auth.spec.ts diff --git a/playwright/tests/tchap-oidc-auth.spec.ts b/playwright/tests/tchap-oidc-auth.spec.ts new file mode 100644 index 0000000..00e1592 --- /dev/null +++ b/playwright/tests/tchap-oidc-auth.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from '../fixtures/auth-fixture'; +import { + verifyUserInMas, + performOidcLoginFromTchap +} from './utils/auth-helpers'; +import { checkMasUserExistsByEmail, createMasUserWithPassword } from './utils/mas-admin'; +import { SCREENSHOTS_DIR, TCHAP_LEGACY } from './utils/config'; + + +//flaky on await expect(page.locator('text=Configuration')).toBeVisible({timeout: 20000}); +test.describe('Tchap : Login via OIDC', () => { + test('tchap match account by username', async ({ page, userLegacy: userLegacy }) => { + const screenshot_path = test.info().title.replace(" ", "_"); + + userLegacy.masId = await createMasUserWithPassword(userLegacy.kc_username, userLegacy.kc_email, userLegacy.kc_password); + + // Verify the test user doesn't exist in MAS yet + const existsBeforeLogin = await checkMasUserExistsByEmail(userLegacy.kc_email); + expect(existsBeforeLogin).toBe(true); + + // Perform the OIDC login flow + await performOidcLoginFromTchap(page, userLegacy,screenshot_path, TCHAP_LEGACY); + + // Click the create account button + await page.locator('button[type="submit"]').click(); + + // Verify we're successfully logged in, confirgmation page + await expect(page.locator('text=Continuer')).toBeVisible(); + + // Take a screenshot of the authenticated state + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/05-confirmation.png` }); + + await page.locator('button[type="submit"]').filter({hasText:'Continuer'}).click(); + + //flaky condition + await expect(page.locator('text=Bienvenue')).toBeVisible({timeout: 20000}); + + // Take a screenshot of the authenticated state + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/06-auth-success.png` }); + + // Verify the user was created in MAS + await verifyUserInMas(userLegacy); + + // Double-check with the API + const existsAfterLogin = await checkMasUserExistsByEmail(userLegacy.kc_email); + expect(existsAfterLogin).toBe(true); + + console.log(`Successfully authenticated and verified user ${userLegacy.kc_username} (${userLegacy.kc_email})`); + }); +}); diff --git a/playwright/tests/utils/auth-helpers.ts b/playwright/tests/utils/auth-helpers.ts index 65a2b47..a9daedf 100644 --- a/playwright/tests/utils/auth-helpers.ts +++ b/playwright/tests/utils/auth-helpers.ts @@ -73,6 +73,57 @@ export async function performOidcLogin(page: Page, user: TestUser, screenshot_pa await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-after-login.png` }); } +/** + * Perform OIDC login starting from Element client + */ +export async function performOidcLoginFromTchap(page: Page, user: TestUser, screenshot_path: string, tchap_legacy:boolean=false): Promise { + + //we go to the welcome and then to the login page because sometimes the email field disapears + await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); + + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-tchap-login-page.png` }); + + await page.getByRole('link').filter({hasText : "Se connecter par email"}).click(); + + await page.locator('#mx_Field_1').fill(user.kc_email); + + // Click on "Continuer" button + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + // Wait for navigation to MAS + await page.waitForURL(url => url.toString().includes(MAS_URL)); + + // Take a screenshot of the MAS login page + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-mas-login-page.png` }); + + // Find and click the OIDC provider button + //const oidcButton = page.locator('a.cpd-button[href*="/upstream/authorize/"]'); + const oidcButton = page.locator('button.proconnect-button'); + + await oidcButton.click(); + + // Wait for navigation to Keycloak + await page.waitForURL(url => url.toString().includes(KEYCLOAK_URL)); + + // Take a screenshot of the Keycloak login page + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-keycloak-login.png` }); + + // Fill in the username and password + await page.locator('#username').fill(user.kc_username); + await page.locator('#password').fill(user.kc_password); + + // Click the login button + await page.locator('button[type="submit"]').click(); + + // Wait for redirect back to MAS + await page.waitForURL(url => url.toString().includes(MAS_URL)); + + // Take a screenshot after successful login + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-after-login.png` }); +} + + + /** * Perform OIDC login starting from Element client * This function handles the entire authentication flow: From 42c4f35f8b01bf8157cccdb5de77dea2443aaed5 Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 9 Sep 2025 17:04:11 +0200 Subject: [PATCH 27/71] add special char --- playwright/tests/utils/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright/tests/utils/config.ts b/playwright/tests/utils/config.ts index 09fb794..5571f27 100644 --- a/playwright/tests/utils/config.ts +++ b/playwright/tests/utils/config.ts @@ -50,7 +50,7 @@ export function generateTestUser(domain:string) { const timestamp = new Date().getTime(); const randomSuffix = Math.floor(Math.random() * 10000); const kc_username = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}`; - const kc_email = `${kc_username}@${domain}`; + const kc_email = `${kc_username}+1@${domain}`; return { kc_username: kc_username, From 86d1e716a7b1282817d67a894c170f2d3f06dd4d Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 9 Sep 2025 17:04:23 +0200 Subject: [PATCH 28/71] comment flaky test --- playwright/tests/element-oidc-auth.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright/tests/element-oidc-auth.spec.ts b/playwright/tests/element-oidc-auth.spec.ts index 8d9cf23..50cc786 100644 --- a/playwright/tests/element-oidc-auth.spec.ts +++ b/playwright/tests/element-oidc-auth.spec.ts @@ -33,7 +33,7 @@ test.describe('Element : Login via OIDC', () => { await page.locator('button[type="submit"]').filter({hasText:'Continuer'}).click(); //flaky condition - await expect(page.locator('text=Configuration')).toBeVisible({timeout: 20000}); + //await expect(page.locator('text=Configuration')).toBeVisible({timeout: 20000}); // Take a screenshot of the authenticated state await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/06-auth-success.png` }); From 2d41018774fcc3bed854744d8f6f519e244865f5 Mon Sep 17 00:00:00 2001 From: olivierdelcroix Date: Mon, 6 Oct 2025 17:24:01 +0200 Subject: [PATCH 29/71] add login_hint test on password login --- playwright/tests/tchap-login-password.spec.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 playwright/tests/tchap-login-password.spec.ts diff --git a/playwright/tests/tchap-login-password.spec.ts b/playwright/tests/tchap-login-password.spec.ts new file mode 100644 index 0000000..ebf48f4 --- /dev/null +++ b/playwright/tests/tchap-login-password.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from '../fixtures/auth-fixture'; +import { + verifyUserInMas, + performOidcLoginFromTchap +} from './utils/auth-helpers'; +import { checkMasUserExistsByEmail, createMasUserWithPassword } from './utils/mas-admin'; +import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from './utils/config'; + + +test.describe('Tchap : Login password', () => { + + test('tchap login with password and login_hint', async ({ page, userLegacy: userLegacy }) => { + const screenshot_path = test.info().title.replace(" ", "_"); + + userLegacy.masId = await createMasUserWithPassword(userLegacy.kc_username, userLegacy.kc_email, userLegacy.kc_password); + const existsBeforeLogin = await checkMasUserExistsByEmail(userLegacy.kc_email); + expect(existsBeforeLogin).toBe(true); + + await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); + + //welcome + await page.waitForURL(url => url.toString().includes(`#/welcome`)); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-tchap-login-page.png` }); + await page.getByRole('link').filter({hasText : "Se connecter par email"}).click(); + await page.waitForURL(url => url.toString().includes(`#/email-precheck-sso`)); + await page.locator('input').fill(userLegacy.kc_email); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + //login + await page.waitForURL(url => url.toString().includes(`${MAS_URL}/login`)); + await expect(page.locator('input[name="username"]')).toHaveValue(userLegacy.kc_email); + await page.locator('input[name="password"]').fill(userLegacy.kc_password); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-password-login-filled.png` }); + await page.locator('button[type="submit"]').click(); + + //consent + await page.waitForURL(url => url.toString().includes('/consent')); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-consent_page.png` }); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + + //tchap + await expect(page.locator('text=Bienvenue')).toBeVisible({timeout: 20000}); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/05-auth-success.png` }); + + // Double-check with the API + const existsAfterLogin = await checkMasUserExistsByEmail(userLegacy.kc_email); + expect(existsAfterLogin).toBe(true); + + console.log(`Successfully authenticated and verified user ${userLegacy.kc_username} (${userLegacy.kc_email})`); + }); +}); From 9bcc66e086452f8579c4dda15717ae0acd488aa9 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Tue, 7 Oct 2025 10:32:54 +0200 Subject: [PATCH 30/71] add test to match account by email (#9) * wip add test to match a deactivated account * test when multi account but only one is valid * add test when account is deactivated and is reactivated by support --------- Co-authored-by: mcalinghee --- README.md | 1 + playwright/tests/login-oidc.spec.ts | 141 +++++++++++++++++++++++++++- playwright/tests/utils/mas-admin.ts | 119 +++++++++++++++++++++++ 3 files changed, 260 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e3f1a11..ab311d0 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ Recharger la page https://element.tchapgouv.com/, le MAS est accessible ## launch tests ```bash +cd playwright playwright % npm test ``` diff --git a/playwright/tests/login-oidc.spec.ts b/playwright/tests/login-oidc.spec.ts index aee7e7c..4bf93cf 100644 --- a/playwright/tests/login-oidc.spec.ts +++ b/playwright/tests/login-oidc.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from '../fixtures/auth-fixture'; import { performOidcLogin, } from './utils/auth-helpers'; -import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser,oauthLinkExistsByUserId, oauthLinkExistsBySubject } from './utils/mas-admin'; +import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser,oauthLinkExistsByUserId, oauthLinkExistsBySubject, getOauthLinkExistsByUserId, deleteOauthLink, getOauthLinkBySubject, reactivateMasUser, addUserEmail } from './utils/mas-admin'; import { SCREENSHOTS_DIR } from './utils/config'; test.describe('Login via OIDC', () => { @@ -120,4 +120,143 @@ test.describe('Login via OIDC', () => { console.log(`Cleaned up MAS user: ${userLegacy.kc_username}`); } }); + + + test('match account by email when former account is deactivated but another one is valid', async ({browser, context, page, userLegacy: userLegacy }) => { + const screenshot_path = test.info().title.replace(" ", "_"); + + // Create a user in MAS with the same email as the Keycloak user + console.log(`Creating MAS user with same email as Keycloak user: ${userLegacy.kc_email}`); + + const formerTchapAccountMasId = await createMasUserWithPassword(userLegacy.kc_username, userLegacy.kc_email, userLegacy.kc_password); + await deactivateMasUser(formerTchapAccountMasId); + + const newTchapAccountWithIndex = { + kc_username: userLegacy.kc_username + "2", + kc_email: userLegacy.kc_email, + kc_password: userLegacy.kc_password, + masId: "", + } + newTchapAccountWithIndex.masId = await createMasUserWithPassword(newTchapAccountWithIndex.kc_username, newTchapAccountWithIndex.kc_email, newTchapAccountWithIndex.kc_password); + + try { + + // Perform the OIDC login flow with KC Account(=userLegacy) + await performOidcLogin(page, userLegacy, screenshot_path); + + // Click the link account button + await page.locator('button[type="submit"]').click(); + + // Since the account already exists, we should be automatically logged in + // Verify we're successfully logged in + await expect(page.locator('text=Mon compte')).toBeVisible(); + + // Take a screenshot of the authenticated state + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); + + // Verify the user in MAS is linked to the indexed account + const userAfterLogin = await getMasUserByEmail(newTchapAccountWithIndex.kc_email); + expect(userAfterLogin.id).toBe(newTchapAccountWithIndex.masId); + expect(userAfterLogin.attributes['username']).toBe(newTchapAccountWithIndex.kc_username); + await expect(page.locator(`text=${newTchapAccountWithIndex.kc_username}`)).toBeVisible(); + expect(await oauthLinkExistsBySubject(userLegacy.kc_username)).toBe(true); + + console.log(`Successfully verified account linking for user with email: ${newTchapAccountWithIndex.kc_email}`); + + } finally { + // Clean up the MAS user + await deactivateMasUser(newTchapAccountWithIndex.masId); + console.log(`Cleaned up MAS user: ${newTchapAccountWithIndex.kc_username}`); + } + }); + + + test('match account by email when account was deactivated but is reactivated by support', async ({browser, context, page, userLegacy: userLegacy }) => { + const screenshot_path = test.info().title.replace(" ", "_"); + + // Create a user in MAS with the same email as the Keycloak user + console.log(`Creating MAS user with same email as Keycloak user: ${userLegacy.kc_email}`); + + userLegacy.masId = await createMasUserWithPassword(userLegacy.kc_username, userLegacy.kc_email, userLegacy.kc_password); + + try { + + // Perform the OIDC login flow + await performOidcLogin(page, userLegacy, screenshot_path); + + // Click the link account button + await page.locator('button[type="submit"]').click(); + + // Since the account already exists, we should be automatically logged in + // Verify we're successfully logged in + await expect(page.locator('text=Mon compte')).toBeVisible(); + + // Take a screenshot of the authenticated state + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); + + // Verify the user in MAS is still the same (account was linked, not created new) + const userAfterLogin = await getMasUserByEmail(userLegacy.kc_email); + expect(userAfterLogin.id).toBe(userLegacy.masId); + //expect(await oauthLinkExistsByUserId(userLegacy.masId)).toBe(true); + expect(await oauthLinkExistsBySubject(userLegacy.kc_username)).toBe(true); + + console.log(`Successfully verified account linking for user with email: ${userLegacy.kc_email}`); + + // deactvate account + await deactivateMasUser(userLegacy.masId); + + + // SUPPORT PROCESS PERFORMED BY BOT ADMIN + const links = await getOauthLinkBySubject(userLegacy.kc_username); + await deleteOauthLink(links[0]['id']) + await reactivateMasUser(userLegacy.masId); + await addUserEmail(userLegacy.masId, userLegacy.kc_email) + // END OF SUPPORT PROCESS + + // Create a new incognito browser context + const context2 = await browser.newContext(); + + // Create a page. + const page2 = await context2.newPage(); + + //RESTART ANOTHER OIDC LOGIN + await page2.goto('/login'); + + const screenshot_path_2 = screenshot_path + "_2" + // Take a screenshot of the login page + await page2.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path_2}/01-login-page.png` }); + + // Perform the OIDC login flow + await performOidcLogin(page2, userLegacy, screenshot_path_2); + + // Click the link account button + await page2.locator('button[type="submit"]').click(); + + await page2.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path_2}/04-linked-account.png` }); + + // Since the account already exists, we should be automatically logged in + // Verify we're successfully logged in + await expect(page2.locator('text=Mon compte')).toBeVisible(); + + // Take a screenshot of the authenticated state + await page2.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path_2}/05-connected-account.png` }); + + + console.log(`Successfully verified account linking for user with email: ${userLegacy.kc_email}`); + + + // Verify the user in MAS is still the same (account was linked, not created new) + const userAfterLogin2 = await getMasUserByEmail(userLegacy.kc_email); + expect(userAfterLogin2.id).toBe(userLegacy.masId); + //expect(await oauthLinkExistsByUserId(userLegacy.masId)).toBe(true); + expect(await oauthLinkExistsBySubject(userLegacy.kc_username)).toBe(true); + + console.log(`Successfully verified account linking for user with email: ${userLegacy.kc_email}`); + + } finally { + // Clean up the MAS user + await deactivateMasUser(userLegacy.masId); + console.log(`Cleaned up MAS user: ${userLegacy.kc_username}`); + } + }); }); diff --git a/playwright/tests/utils/mas-admin.ts b/playwright/tests/utils/mas-admin.ts index 8514961..f2bb7a5 100644 --- a/playwright/tests/utils/mas-admin.ts +++ b/playwright/tests/utils/mas-admin.ts @@ -316,3 +316,122 @@ export async function disposeApiContext(): Promise { console.log(`[MAS API] No API context to dispose`); } } + + +/** + * Check if a oauth link exists + */ +export async function getOauthLinkByUserId(userId: string): Promise { + const token = await getMasAdminToken(); + const apiRequestContext = await getApiContext(); + + const response = await apiRequestContext.get(`/api/admin/v1/upstream-oauth-links?filter[user]=${userId}`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok()) { + const errorText = await response.text(); + console.error(`[MAS API] Failed to delete user: ${response.status()} - ${errorText}`); + throw new Error(`Failed to delete MAS user: ${response.status()} - ${errorText}`); + } + const data = await response.json(); + //console.log(data.data) + const links = data.data; + console.log(`[MAS API] Oauth links for user ${userId} : ${JSON.stringify(links)}`); + return links +} + + +export async function getOauthLinkBySubject(subject: string): Promise { + const token = await getMasAdminToken(); + const apiRequestContext = await getApiContext(); + + const response = await apiRequestContext.get(`/api/admin/v1/upstream-oauth-links?filter[subject]=${subject}`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok()) { + const errorText = await response.text(); + console.error(`[MAS API] Failed to delete user: ${response.status()} - ${errorText}`); + throw new Error(`Failed to delete MAS user: ${response.status()} - ${errorText}`); + } + const data = await response.json(); + //console.log(data.data) + const links = data.data; + console.log(`[MAS API] Oauth links for user ${subject} : ${JSON.stringify(links)}`); + return links +} + + +export async function deleteOauthLink(id: string): Promise { + const token = await getMasAdminToken(); + const apiRequestContext = await getApiContext(); + + const response = await apiRequestContext.delete(`/api/admin/v1/upstream-oauth-links/${id}`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok()) { + const errorText = await response.text(); + console.error(`[MAS API] Failed to delete user: ${response.status()} - ${errorText}`); + throw new Error(`Failed to delete MAS user: ${response.status()} - ${errorText}`); + } + console.log(`[MAS API] Oauth links deleted for id:${id}, response : ${JSON.stringify(response)} `); + return; +} + +export async function addUserEmail(userId: string, email: string): Promise { + const token = await getMasAdminToken(); + const apiRequestContext = await getApiContext(); + + const response = await apiRequestContext.post(`/api/admin/v1/user-emails`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + data: { + "user_id": userId, + "email": email + } + }); + + if (!response.ok()) { + const errorText = await response.text(); + console.error(`[MAS API] Failed to set email for user: ${response.status()} - ${errorText}`); + throw new Error(`Failed to set email for user: ${response.status()} - ${errorText}`); + } + console.log(`[MAS API] User email added for user_id:${userId}, response : ${JSON.stringify(response)} `); + return; +} + + + +/** + * Reactivate a user from MAS + */ +export async function reactivateMasUser(userId: string): Promise { + console.log(`[MAS API] Deleting user with ID: ${userId}`); + const token = await getMasAdminToken(); + const apiRequestContext = await getApiContext(); + + const response = await apiRequestContext.post(`/api/admin/v1/users/${userId}/reactivate`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok()) { + const errorText = await response.text(); + console.error(`[MAS API] Failed to reactivate user: ${response.status()} - ${errorText}`); + throw new Error(`Failed to reactivate MAS user: ${response.status()} - ${errorText}`); + } + + console.log(`[MAS API] User reactivated successfully`); +} + From 7e4c29e2fd48852e9ef9ec1ee11c79b042df137a Mon Sep 17 00:00:00 2001 From: Olivier D Date: Mon, 13 Oct 2025 15:03:22 +0200 Subject: [PATCH 31/71] test/add e2e for register with pwd (#10) * add register pwd test * add fixture * add screenshot * reduce username lenght * fix test * finish register password test * update doc * add parallel env var --- playwright/.env.local | 3 + playwright/fixtures/auth-fixture.ts | 19 +++ playwright/init.sh | 2 +- playwright/playwright.config.ts | 2 +- .../tests/tchap-register-password.spec.ts | 115 ++++++++++++++++++ playwright/tests/utils/auth-helpers.ts | 2 +- playwright/tests/utils/config.ts | 2 +- 7 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 playwright/tests/tchap-register-password.spec.ts diff --git a/playwright/.env.local b/playwright/.env.local index 06ace38..b2cc3c9 100644 --- a/playwright/.env.local +++ b/playwright/.env.local @@ -31,3 +31,6 @@ BROWSER_LOCALE=fr-FR # Screenshots Directory SCREENSHOTS_DIR=playwright-results/local + +#Run tests in parallel +TEST_IN_PARALLEL=false \ No newline at end of file diff --git a/playwright/fixtures/auth-fixture.ts b/playwright/fixtures/auth-fixture.ts index d7ecd8d..54e3147 100644 --- a/playwright/fixtures/auth-fixture.ts +++ b/playwright/fixtures/auth-fixture.ts @@ -12,6 +12,23 @@ import { NUMERIQUE_EMAIL_DOMAIN } from '../tests/utils/config'; + +function generateSimpleUserFixture(domain: string) { + return async ({}, use: (user: TestUser) => Promise) => { + try { + const user = generateTestUser(domain); + + // Use the test user in the test + await use(user); + + } finally { + // Dispose API contexts + await Promise.all([ + ]); + } + }; +} + /** * Function to create a test user fixture with a specific domain */ @@ -77,6 +94,7 @@ function createLegacyUserFixture(domain: string) { * Extend the basic test fixtures with our authentication fixtures */ export const test = base.extend<{ + simpleUser: TestUser, testUser: TestUser; testExternalUserWithInvit: TestUser; testExternalUserWitoutInvit: TestUser; @@ -87,6 +105,7 @@ export const test = base.extend<{ /** * Create a test user in Keycloak before the test and clean it up after */ + simpleUser: generateSimpleUserFixture(STANDARD_EMAIL_DOMAIN), testUser: createTestUserFixture(STANDARD_EMAIL_DOMAIN), testExternalUserWithInvit: createTestUserFixture(INVITED_EMAIL_DOMAIN), testExternalUserWitoutInvit: createTestUserFixture(NOT_INVITED_EMAIL_DOMAIN), diff --git a/playwright/init.sh b/playwright/init.sh index 677efc1..e49f311 100755 --- a/playwright/init.sh +++ b/playwright/init.sh @@ -17,7 +17,7 @@ npx playwright install echo "Initialization complete!" echo "" echo "Next steps:" -echo "1. Start the services with: ./tchap/start-local-stack.sh and ./tchap/start-local-mas.sh" +echo "1. Start the services https://github.com/tchapgouv/tchap-docker-integration" echo "2. Run the tests with: npm test" echo "" echo "For more information, see the README.md file." diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts index 805dc3b..f012a62 100644 --- a/playwright/playwright.config.ts +++ b/playwright/playwright.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ /* Maximum time one test can run for */ timeout: 30 * 1000, /* Run tests in files in parallel */ - fullyParallel: false, + fullyParallel: process.env.TEST_IN_PARALLEL === 'true' ? true : false, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ diff --git a/playwright/tests/tchap-register-password.spec.ts b/playwright/tests/tchap-register-password.spec.ts new file mode 100644 index 0000000..b86e207 --- /dev/null +++ b/playwright/tests/tchap-register-password.spec.ts @@ -0,0 +1,115 @@ +import { test, expect } from '../fixtures/auth-fixture'; +import { + verifyUserInMas, + performOidcLoginFromTchap +} from './utils/auth-helpers'; +import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail } from './utils/mas-admin'; +import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL, generateTestUser } from './utils/config'; +import { BrowserContext } from '@playwright/test'; +import { assert } from 'console'; + + +test.describe('Tchap : register password', () => { + + test('tchap register with password', async ({ context, page, simpleUser: user }) => { + + const email = user.kc_email; + + const screenshot_path = test.info().title.replace(" ", "_"); + + await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); + + //welcome + await page.waitForURL(url => url.toString().includes(`#/welcome`)); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01--tchap-login-page.png` }); + await page.getByRole('link').filter({hasText : "Se connecter par email"}).click(); + await page.waitForURL(url => url.toString().includes(`#/email-precheck-sso`)); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-tchap-precheck-sso.png` }); + await page.locator('input').fill(email); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + //login + await page.waitForURL(url => url.toString().includes(`${MAS_URL}/login`)); + await expect(page.locator('input[name="username"]')).toHaveValue(email); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-login.png`, fullPage: true }); + + //register + await page.getByRole('link', { name: 'Créer un compte' }).click(); + await page.waitForURL(url => url.toString().includes(`/register`)); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-register.png`, fullPage: true }); + + //password + await page.getByRole('button').filter({ hasText: 'Continuer avec une adresse mail' }).click(); + await page.waitForURL(url => url.toString().includes(`/register/password`)); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-password.png`, fullPage: true }); + let password = "sdf78qsd!9090ssss"; + await page.locator('input[name="password"]').fill(password); + await page.locator('input[name="password_confirm"]').fill(password); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + //verify-email + await page.waitForURL(url => url.toString().includes(`/verify-email`)); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/05-verify_email.png`, fullPage: true }); + let verificationCode = await extractVerificationCode(context, screenshot_path); + console.log("verification code extracted : ", verificationCode); + await page.locator('input[name="code"]').fill(verificationCode); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + //display-name + await page.waitForURL(url => url.toString().includes(`/display-name`)); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/06-display-name.png`, fullPage: true }); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + //consent + await page.waitForURL(url => url.toString().includes(`/consent`)); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/07-consent.png`, fullPage: true }); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + + //tchap + await page.waitForURL(url => url.toString().includes(`#/home`), { timeout: 20000 }); + //TODO this condition is hardcoded + await expect(page.locator('h1').filter({ hasText: /Bienvenue.*\[Tchapgouv\]/ })).toBeVisible({ timeout: 20000 }); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/08-tchap-home.png`, fullPage: true }); + + + const created_user = await getMasUserByEmail(email); + + //check created username fields + expect(created_user.attributes.username).toContain(user.kc_username); + + //todo : check displayname? -> display name is stored in Synapse, or in the home screen of Tchap + }); + + + // Add this function to extract the verification code + async function extractVerificationCode(context: BrowserContext, screenshot_path:string): Promise { + // Create a new page for mail.tchapgouv.com + const page = await context.newPage(); + + // Navigate to mail.tchapgouv.com + await page.goto('https://mail.tchapgouv.com'); + + // Wait for the page to load and click on the first email + await page.waitForSelector('.msglist-message'); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/00-verification-code.png`, fullPage: true }); + + const firstIncompingMail = await page.locator('.col-md-5').first(); + + let codeText = await firstIncompingMail.textContent(); + if (!codeText) { + throw new Error('Unable to extract verification code'); + } + console.log(codeText); + codeText = codeText.trim(); + console.log(codeText); + const codeMatch = codeText.match(/.*: (\d+)/); + if (!codeMatch) { + throw new Error('Unable to extract verification code from text'); + } + const verificationCode = codeMatch[1]; + + return verificationCode; + } + +}); diff --git a/playwright/tests/utils/auth-helpers.ts b/playwright/tests/utils/auth-helpers.ts index a9daedf..49901d1 100644 --- a/playwright/tests/utils/auth-helpers.ts +++ b/playwright/tests/utils/auth-helpers.ts @@ -85,7 +85,7 @@ export async function performOidcLoginFromTchap(page: Page, user: TestUser, scre await page.getByRole('link').filter({hasText : "Se connecter par email"}).click(); - await page.locator('#mx_Field_1').fill(user.kc_email); + await page.locator('input').fill(user.kc_email); // Click on "Continuer" button await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); diff --git a/playwright/tests/utils/config.ts b/playwright/tests/utils/config.ts index 5571f27..dde3422 100644 --- a/playwright/tests/utils/config.ts +++ b/playwright/tests/utils/config.ts @@ -48,7 +48,7 @@ export const BROWSER_LOCALE = process.env.BROWSER_LOCALE || 'fr-FR'; // Generate a unique username and email for testing export function generateTestUser(domain:string) { const timestamp = new Date().getTime(); - const randomSuffix = Math.floor(Math.random() * 10000); + const randomSuffix = Math.floor(Math.random() * 10); const kc_username = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}`; const kc_email = `${kc_username}+1@${domain}`; From db8c084eea94e92874ec5f319680b6a6e2659c05 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Fri, 17 Oct 2025 11:37:51 +0200 Subject: [PATCH 32/71] update tchap register flow with create account button (#12) --- playwright/tests/register-password.spec.ts | 40 ++++++++++++++++++ .../tests/tchap-register-password.spec.ts | 42 ++----------------- playwright/tests/utils/auth-helpers.ts | 34 ++++++++++++++- 3 files changed, 76 insertions(+), 40 deletions(-) create mode 100644 playwright/tests/register-password.spec.ts diff --git a/playwright/tests/register-password.spec.ts b/playwright/tests/register-password.spec.ts new file mode 100644 index 0000000..fd4767c --- /dev/null +++ b/playwright/tests/register-password.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from '../fixtures/auth-fixture'; +import { + verifyUserInMas, + performOidcLoginFromTchap, + extractVerificationCode +} from './utils/auth-helpers'; +import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail } from './utils/mas-admin'; +import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL, generateTestUser } from './utils/config'; +import { BrowserContext } from '@playwright/test'; +import { assert } from 'console'; + + +test.describe('Register', () => { + + test('without login_hint', async ({ context, page, simpleUser: user }) => { + + const email = user.kc_email; + + const screenshot_path = test.info().title.replace(" ", "_"); + // Navigate to the register page + await page.goto('/register'); + + //register + await page.waitForURL(url => url.toString().includes(`/register`)); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-register.png`, fullPage: true }); + + //password + await page.getByRole('button').filter({ hasText: 'Continuer avec une adresse mail' }).click(); + await page.waitForURL(url => url.toString().includes(`/register/password`)); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-password.png`, fullPage: true }); + let password = "sdf78qsd!9090ssss"; + await page.locator('input[name="password"]').fill(password); + await page.locator('input[name="password_confirm"]').fill(password); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + //form is not submitted because email is missing + await page.waitForURL(url => url.toString().includes(`/register/password`)); + + }); +}); diff --git a/playwright/tests/tchap-register-password.spec.ts b/playwright/tests/tchap-register-password.spec.ts index b86e207..273c882 100644 --- a/playwright/tests/tchap-register-password.spec.ts +++ b/playwright/tests/tchap-register-password.spec.ts @@ -1,7 +1,8 @@ import { test, expect } from '../fixtures/auth-fixture'; import { verifyUserInMas, - performOidcLoginFromTchap + performOidcLoginFromTchap, + extractVerificationCode } from './utils/auth-helpers'; import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail } from './utils/mas-admin'; import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL, generateTestUser } from './utils/config'; @@ -22,19 +23,13 @@ test.describe('Tchap : register password', () => { //welcome await page.waitForURL(url => url.toString().includes(`#/welcome`)); await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01--tchap-login-page.png` }); - await page.getByRole('link').filter({hasText : "Se connecter par email"}).click(); + await page.getByRole('link').filter({hasText : "Créer un compte"}).click(); await page.waitForURL(url => url.toString().includes(`#/email-precheck-sso`)); await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-tchap-precheck-sso.png` }); await page.locator('input').fill(email); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - //login - await page.waitForURL(url => url.toString().includes(`${MAS_URL}/login`)); - await expect(page.locator('input[name="username"]')).toHaveValue(email); - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-login.png`, fullPage: true }); - //register - await page.getByRole('link', { name: 'Créer un compte' }).click(); await page.waitForURL(url => url.toString().includes(`/register`)); await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-register.png`, fullPage: true }); @@ -81,35 +76,4 @@ test.describe('Tchap : register password', () => { //todo : check displayname? -> display name is stored in Synapse, or in the home screen of Tchap }); - - // Add this function to extract the verification code - async function extractVerificationCode(context: BrowserContext, screenshot_path:string): Promise { - // Create a new page for mail.tchapgouv.com - const page = await context.newPage(); - - // Navigate to mail.tchapgouv.com - await page.goto('https://mail.tchapgouv.com'); - - // Wait for the page to load and click on the first email - await page.waitForSelector('.msglist-message'); - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/00-verification-code.png`, fullPage: true }); - - const firstIncompingMail = await page.locator('.col-md-5').first(); - - let codeText = await firstIncompingMail.textContent(); - if (!codeText) { - throw new Error('Unable to extract verification code'); - } - console.log(codeText); - codeText = codeText.trim(); - console.log(codeText); - const codeMatch = codeText.match(/.*: (\d+)/); - if (!codeMatch) { - throw new Error('Unable to extract verification code from text'); - } - const verificationCode = codeMatch[1]; - - return verificationCode; - } - }); diff --git a/playwright/tests/utils/auth-helpers.ts b/playwright/tests/utils/auth-helpers.ts index 49901d1..b04bfcd 100644 --- a/playwright/tests/utils/auth-helpers.ts +++ b/playwright/tests/utils/auth-helpers.ts @@ -1,4 +1,4 @@ -import { Page } from '@playwright/test'; +import { BrowserContext, Page } from '@playwright/test'; import { createKeycloakUser, deleteKeycloakUser } from './keycloak-admin'; import { waitForMasUser, createMasUserWithPassword, deactivateMasUser } from './mas-admin'; import { ELEMENT_URL, generateTestUser, KEYCLOAK_URL, MAS_URL, SCREENSHOTS_DIR } from './config'; @@ -241,3 +241,35 @@ export async function performPasswordLogin(page: Page, user: TestUser, screensho console.log(`[Auth] Password login successful for user: ${user.kc_username}`); } + + + + // Add this function to extract the verification code +export async function extractVerificationCode(context: BrowserContext, screenshot_path:string): Promise { + // Create a new page for mail.tchapgouv.com + const page = await context.newPage(); + + // Navigate to mail.tchapgouv.com + await page.goto('https://mail.tchapgouv.com'); + + // Wait for the page to load and click on the first email + await page.waitForSelector('.msglist-message'); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/00-verification-code.png`, fullPage: true }); + + const firstIncompingMail = await page.locator('.col-md-5').first(); + + let codeText = await firstIncompingMail.textContent(); + if (!codeText) { + throw new Error('Unable to extract verification code'); + } + console.log(codeText); + codeText = codeText.trim(); + console.log(codeText); + const codeMatch = codeText.match(/.*: (\d+)/); + if (!codeMatch) { + throw new Error('Unable to extract verification code from text'); + } + const verificationCode = codeMatch[1]; + + return verificationCode; + } \ No newline at end of file From 8773d6aa9554be99dec51371906da828105ecf1a Mon Sep 17 00:00:00 2001 From: Marc Date: Mon, 20 Oct 2025 15:35:15 +0200 Subject: [PATCH 33/71] add e2e web tests and fixtures (#11) * add e2e web tests and fixtures * fix conflicts * use username in authenticatedUser fixture * test: complete create room test * correct register-password test utils path --------- Co-authored-by: olivierdelcroix --- playwright/fixtures/auth-fixture.ts | 106 ++++--- playwright/package-lock.json | 297 +----------------- playwright/playwright.config.ts | 2 +- .../{ => auth}/element-oidc-auth.spec.ts | 8 +- .../tests/{ => auth}/login-oidc.spec.ts | 8 +- .../tests/{ => auth}/login-password.spec.ts | 8 +- .../tests/{ => auth}/register-oidc.spec.ts | 8 +- .../tests/{ => auth}/tchap-oidc-auth.spec.ts | 8 +- playwright/tests/register-password.spec.ts | 6 +- playwright/tests/tchap-login-password.spec.ts | 8 +- .../tests/tchap-register-password.spec.ts | 10 +- playwright/tests/web/create-room.spec.ts | 140 +++++++++ playwright/utils/TchapAppPage.ts | 191 +++++++++++ playwright/utils/api.ts | 121 +++++++ playwright/{tests => }/utils/auth-helpers.ts | 98 +++++- playwright/{tests => }/utils/config.ts | 35 +-- .../{tests => }/utils/keycloak-admin.ts | 0 playwright/{tests => }/utils/mas-admin.ts | 0 18 files changed, 657 insertions(+), 397 deletions(-) rename playwright/tests/{ => auth}/element-oidc-auth.spec.ts (91%) rename playwright/tests/{ => auth}/login-oidc.spec.ts (97%) rename playwright/tests/{ => auth}/login-password.spec.ts (85%) rename playwright/tests/{ => auth}/register-oidc.spec.ts (95%) rename playwright/tests/{ => auth}/tchap-oidc-auth.spec.ts (91%) create mode 100644 playwright/tests/web/create-room.spec.ts create mode 100644 playwright/utils/TchapAppPage.ts create mode 100644 playwright/utils/api.ts rename playwright/{tests => }/utils/auth-helpers.ts (75%) rename playwright/{tests => }/utils/config.ts (70%) rename playwright/{tests => }/utils/keycloak-admin.ts (100%) rename playwright/{tests => }/utils/mas-admin.ts (100%) diff --git a/playwright/fixtures/auth-fixture.ts b/playwright/fixtures/auth-fixture.ts index 54e3147..3eb5437 100644 --- a/playwright/fixtures/auth-fixture.ts +++ b/playwright/fixtures/auth-fixture.ts @@ -1,16 +1,25 @@ -import { test as base } from '@playwright/test'; -import { createKeycloakTestUser, cleanupKeycloakTestUser, TestUser } from '../tests/utils/auth-helpers'; -import { disposeApiContext as disposeKeycloakApiContext } from '../tests/utils/keycloak-admin'; -import { disposeApiContext as disposeMasApiContext } from '../tests/utils/mas-admin'; -import { generateTestUser } from '../tests/utils/config'; - -import { - STANDARD_EMAIL_DOMAIN, - INVITED_EMAIL_DOMAIN, - NOT_INVITED_EMAIL_DOMAIN, +import { test as base, Page } from "@playwright/test"; +import { + createKeycloakTestUser, + cleanupKeycloakTestUser, + TestUser, + TypeUser, + populateLocalStorageWithCredentials, +} from "../utils/auth-helpers"; +import { disposeApiContext as disposeKeycloakApiContext } from "../utils/keycloak-admin"; +import { createMasUserWithPassword, deactivateMasUser, disposeApiContext as disposeMasApiContext, waitForMasUser } from "../utils/mas-admin"; +import { generateTestUser } from "../utils/auth-helpers"; + +import { + STANDARD_EMAIL_DOMAIN, + INVITED_EMAIL_DOMAIN, + NOT_INVITED_EMAIL_DOMAIN, WRONG_SERVER_EMAIL_DOMAIN, - NUMERIQUE_EMAIL_DOMAIN -} from '../tests/utils/config'; + NUMERIQUE_EMAIL_DOMAIN, + BASE_URL, + ELEMENT_URL +} from "../utils/config"; +import { ClientServerApi, Credentials } from "../utils/api"; function generateSimpleUserFixture(domain: string) { @@ -36,23 +45,20 @@ function createTestUserFixture(domain: string) { return async ({}, use: (user: TestUser) => Promise) => { try { const testUser = generateTestUser(domain); - + // Create a test user in Keycloak const user = await createKeycloakTestUser(testUser); - + // Use the test user in the test await use(user); - + // Clean up the test user after the test await cleanupKeycloakTestUser(user); console.log(`Cleaned up test user: ${user.kc_username}`); } finally { // Dispose API contexts - await Promise.all([ - disposeKeycloakApiContext(), - disposeMasApiContext() - ]); - console.log('API contexts disposed'); + await Promise.all([disposeKeycloakApiContext(), disposeMasApiContext()]); + console.log("API contexts disposed"); } }; } @@ -64,28 +70,25 @@ function createLegacyUserFixture(domain: string) { try { const randomSuffix = Math.floor(Math.random() * 10000); - const testUser:TestUser = { + const testUser: TestUser = { kc_username: `test.user${randomSuffix}-${domain}`, kc_email: `test.user${randomSuffix}@${domain}`, - kc_password: '1234!' - } - + kc_password: "1234!", + }; + // Create a test user in Keycloak const user = await createKeycloakTestUser(testUser); - + // Use the test user in the test await use(user); - + // Clean up the test user after the test await cleanupKeycloakTestUser(user); console.log(`Cleaned up test user: ${user.kc_username}`); } finally { // Dispose API contexts - await Promise.all([ - disposeKeycloakApiContext(), - disposeMasApiContext() - ]); - console.log('API contexts disposed'); + await Promise.all([disposeKeycloakApiContext(), disposeMasApiContext()]); + console.log("API contexts disposed"); } }; } @@ -99,8 +102,10 @@ export const test = base.extend<{ testExternalUserWithInvit: TestUser; testExternalUserWitoutInvit: TestUser; testUserOnWrongServer: TestUser; - userLegacy:TestUser; + userLegacy: TestUser; userLegacyWithFallbackRules: TestUser; + authenticatedUser: Credentials; + typeUser: TypeUser; }>({ /** * Create a test user in Keycloak before the test and clean it up after @@ -110,8 +115,39 @@ export const test = base.extend<{ testExternalUserWithInvit: createTestUserFixture(INVITED_EMAIL_DOMAIN), testExternalUserWitoutInvit: createTestUserFixture(NOT_INVITED_EMAIL_DOMAIN), testUserOnWrongServer: createTestUserFixture(WRONG_SERVER_EMAIL_DOMAIN), - userLegacy:createLegacyUserFixture(STANDARD_EMAIL_DOMAIN), - userLegacyWithFallbackRules:createLegacyUserFixture(NUMERIQUE_EMAIL_DOMAIN) + userLegacy: createLegacyUserFixture(STANDARD_EMAIL_DOMAIN), + userLegacyWithFallbackRules: createLegacyUserFixture(NUMERIQUE_EMAIL_DOMAIN), + typeUser: TypeUser.MAS_PASSWORD_USER, + authenticatedUser: async ({ page, testUser: user, request }, use) => { + // 1. Register user + const userId = await createMasUserWithPassword( + user.kc_username, + user.kc_email, + user.kc_password + ); + const csAPI = new ClientServerApi(BASE_URL, request); + + await waitForMasUser(user.kc_email); + + const credentials = (await csAPI.loginUser( + user.kc_username, + user.kc_password + )) as Credentials; + + // 2. Populate localStorage + await populateLocalStorageWithCredentials(page, credentials); + + // 3. Load app + await page.goto(ELEMENT_URL); + await page.waitForSelector(".mx_MatrixChat", { timeout: 20000 }); + + // 4. Pass page to test + await use(credentials); + + // Clean up, deactivate user + await deactivateMasUser(userId); + console.log(`Cleaned up MAS user: ${user.kc_username}`); + }, }); -export { expect } from '@playwright/test'; +export { expect } from "@playwright/test"; diff --git a/playwright/package-lock.json b/playwright/package-lock.json index dbad1e0..d73d87c 100644 --- a/playwright/package-lock.json +++ b/playwright/package-lock.json @@ -11,9 +11,7 @@ "devDependencies": { "@playwright/test": "^1.40.0", "@types/node": "^20.10.0", - "@types/node-fetch": "^2.6.9", "dotenv": "^16.3.1", - "node-fetch": "^2.6.9", "typescript": "^5.3.2" } }, @@ -41,56 +39,6 @@ "undici-types": "~6.19.2" } }, - "node_modules/@types/node-fetch": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", - "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", - "dev": true, - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.0" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/dotenv": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", @@ -103,80 +51,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", - "dev": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -191,153 +65,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/playwright": { "version": "1.51.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.1.tgz", @@ -368,12 +95,6 @@ "node": ">=18" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true - }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -392,22 +113,6 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "dev": true - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } } } -} \ No newline at end of file +} diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts index f012a62..6ebbc4c 100644 --- a/playwright/playwright.config.ts +++ b/playwright/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig, devices } from '@playwright/test'; import dotenv from 'dotenv'; -import { BROWSER_LOCALE } from './tests/utils/config'; +import { BROWSER_LOCALE } from './utils/config'; // Load environment variables from .env file dotenv.config(); diff --git a/playwright/tests/element-oidc-auth.spec.ts b/playwright/tests/auth/element-oidc-auth.spec.ts similarity index 91% rename from playwright/tests/element-oidc-auth.spec.ts rename to playwright/tests/auth/element-oidc-auth.spec.ts index 50cc786..709e229 100644 --- a/playwright/tests/element-oidc-auth.spec.ts +++ b/playwright/tests/auth/element-oidc-auth.spec.ts @@ -1,10 +1,10 @@ -import { test, expect } from '../fixtures/auth-fixture'; +import { test, expect } from '../../fixtures/auth-fixture'; import { verifyUserInMas, performOidcLoginFromElement -} from './utils/auth-helpers'; -import { checkMasUserExistsByEmail, createMasUserWithPassword } from './utils/mas-admin'; -import { SCREENSHOTS_DIR, TCHAP_LEGACY } from './utils/config'; +} from '../../utils/auth-helpers'; +import { checkMasUserExistsByEmail, createMasUserWithPassword } from '../../utils/mas-admin'; +import { SCREENSHOTS_DIR, TCHAP_LEGACY } from '../../utils/config'; //flaky on await expect(page.locator('text=Configuration')).toBeVisible({timeout: 20000}); diff --git a/playwright/tests/login-oidc.spec.ts b/playwright/tests/auth/login-oidc.spec.ts similarity index 97% rename from playwright/tests/login-oidc.spec.ts rename to playwright/tests/auth/login-oidc.spec.ts index 4bf93cf..803f444 100644 --- a/playwright/tests/login-oidc.spec.ts +++ b/playwright/tests/auth/login-oidc.spec.ts @@ -1,9 +1,9 @@ -import { test, expect } from '../fixtures/auth-fixture'; +import { test, expect } from '../../fixtures/auth-fixture'; import { performOidcLogin, -} from './utils/auth-helpers'; -import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser,oauthLinkExistsByUserId, oauthLinkExistsBySubject, getOauthLinkExistsByUserId, deleteOauthLink, getOauthLinkBySubject, reactivateMasUser, addUserEmail } from './utils/mas-admin'; -import { SCREENSHOTS_DIR } from './utils/config'; +} from '../../utils/auth-helpers'; +import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser,oauthLinkExistsByUserId, oauthLinkExistsBySubject, getOauthLinkBySubject, deleteOauthLink, reactivateMasUser, addUserEmail } from '../../utils/mas-admin'; +import { SCREENSHOTS_DIR } from '../../utils/config'; test.describe('Login via OIDC', () => { diff --git a/playwright/tests/login-password.spec.ts b/playwright/tests/auth/login-password.spec.ts similarity index 85% rename from playwright/tests/login-password.spec.ts rename to playwright/tests/auth/login-password.spec.ts index a247e71..b9a0024 100644 --- a/playwright/tests/login-password.spec.ts +++ b/playwright/tests/auth/login-password.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../fixtures/auth-fixture'; +import { test, expect } from '../../fixtures/auth-fixture'; import { performOidcLogin, verifyUserInMas, @@ -6,9 +6,9 @@ import { cleanupMasTestUser, performPasswordLogin, TestUser -} from './utils/auth-helpers'; -import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser } from './utils/mas-admin'; -import { SCREENSHOTS_DIR } from './utils/config'; +} from '../../utils/auth-helpers'; +import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser } from '../../utils/mas-admin'; +import { SCREENSHOTS_DIR } from '../../utils/config'; test.describe('Login with password', () => { diff --git a/playwright/tests/register-oidc.spec.ts b/playwright/tests/auth/register-oidc.spec.ts similarity index 95% rename from playwright/tests/register-oidc.spec.ts rename to playwright/tests/auth/register-oidc.spec.ts index 47dab3d..40eba42 100644 --- a/playwright/tests/register-oidc.spec.ts +++ b/playwright/tests/auth/register-oidc.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../fixtures/auth-fixture'; +import { test, expect } from '../../fixtures/auth-fixture'; import { performOidcLogin, verifyUserInMas, @@ -6,9 +6,9 @@ import { cleanupMasTestUser, performPasswordLogin, TestUser -} from './utils/auth-helpers'; -import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser } from './utils/mas-admin'; -import { SCREENSHOTS_DIR } from './utils/config'; +} from '../../utils/auth-helpers'; +import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser } from '../../utils/mas-admin'; +import { SCREENSHOTS_DIR } from '../../utils/config'; test.describe('Register', () => { diff --git a/playwright/tests/tchap-oidc-auth.spec.ts b/playwright/tests/auth/tchap-oidc-auth.spec.ts similarity index 91% rename from playwright/tests/tchap-oidc-auth.spec.ts rename to playwright/tests/auth/tchap-oidc-auth.spec.ts index 00e1592..82dfe95 100644 --- a/playwright/tests/tchap-oidc-auth.spec.ts +++ b/playwright/tests/auth/tchap-oidc-auth.spec.ts @@ -1,10 +1,10 @@ -import { test, expect } from '../fixtures/auth-fixture'; +import { test, expect } from '../../fixtures/auth-fixture'; import { verifyUserInMas, performOidcLoginFromTchap -} from './utils/auth-helpers'; -import { checkMasUserExistsByEmail, createMasUserWithPassword } from './utils/mas-admin'; -import { SCREENSHOTS_DIR, TCHAP_LEGACY } from './utils/config'; +} from '../../utils/auth-helpers'; +import { checkMasUserExistsByEmail, createMasUserWithPassword } from '../../utils/mas-admin'; +import { SCREENSHOTS_DIR, TCHAP_LEGACY } from '../../utils/config'; //flaky on await expect(page.locator('text=Configuration')).toBeVisible({timeout: 20000}); diff --git a/playwright/tests/register-password.spec.ts b/playwright/tests/register-password.spec.ts index fd4767c..b1fd2f2 100644 --- a/playwright/tests/register-password.spec.ts +++ b/playwright/tests/register-password.spec.ts @@ -3,9 +3,9 @@ import { verifyUserInMas, performOidcLoginFromTchap, extractVerificationCode -} from './utils/auth-helpers'; -import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail } from './utils/mas-admin'; -import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL, generateTestUser } from './utils/config'; +} from '../utils/auth-helpers'; +import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail } from '../utils/mas-admin'; +import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from '../utils/config'; import { BrowserContext } from '@playwright/test'; import { assert } from 'console'; diff --git a/playwright/tests/tchap-login-password.spec.ts b/playwright/tests/tchap-login-password.spec.ts index ebf48f4..7b42933 100644 --- a/playwright/tests/tchap-login-password.spec.ts +++ b/playwright/tests/tchap-login-password.spec.ts @@ -1,10 +1,6 @@ import { test, expect } from '../fixtures/auth-fixture'; -import { - verifyUserInMas, - performOidcLoginFromTchap -} from './utils/auth-helpers'; -import { checkMasUserExistsByEmail, createMasUserWithPassword } from './utils/mas-admin'; -import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from './utils/config'; +import { checkMasUserExistsByEmail, createMasUserWithPassword } from '../utils/mas-admin'; +import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from '../utils/config'; test.describe('Tchap : Login password', () => { diff --git a/playwright/tests/tchap-register-password.spec.ts b/playwright/tests/tchap-register-password.spec.ts index 273c882..63e0437 100644 --- a/playwright/tests/tchap-register-password.spec.ts +++ b/playwright/tests/tchap-register-password.spec.ts @@ -1,13 +1,9 @@ import { test, expect } from '../fixtures/auth-fixture'; import { - verifyUserInMas, - performOidcLoginFromTchap, extractVerificationCode -} from './utils/auth-helpers'; -import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail } from './utils/mas-admin'; -import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL, generateTestUser } from './utils/config'; -import { BrowserContext } from '@playwright/test'; -import { assert } from 'console'; +} from '../utils/auth-helpers'; +import { getMasUserByEmail } from '../utils/mas-admin'; +import { SCREENSHOTS_DIR, ELEMENT_URL } from '../utils/config'; test.describe('Tchap : register password', () => { diff --git a/playwright/tests/web/create-room.spec.ts b/playwright/tests/web/create-room.spec.ts new file mode 100644 index 0000000..217e036 --- /dev/null +++ b/playwright/tests/web/create-room.spec.ts @@ -0,0 +1,140 @@ +import { test, expect } from "../../fixtures/auth-fixture"; +import { env } from "../../utils/config"; + +test.describe("Create Room", () => { + + test("should allow us to create a public room with name", async ({ + page, + authenticatedUser + }) => { + // Listen for all console logs + page.on("console", (msg) => console.log(msg.text())); + + const name = "Test room public 1"; + + await page.getByRole("button", { name: "Add room", exact: true }).click(); + + await page.getByRole("menuitem", { name: "New room", exact: true }).click(); + const dialog = page.locator(".tc_TchapCreateRoomDialog"); + + // Fill name + await dialog.getByRole("textbox").fill(name); + + // Select public room option + await dialog + .locator(".tc_TchapRoomTypeSelector_RadioButton_title") + .getByText("Public room") + .click(); + + // Submit + await dialog.getByRole("button", { name: "Create New Room" }).click(); + + // In local test An error dialog should appear first complaining about wss socket and SSL certificate error + // So not really working locally + if (env == "local") { + await page.getByRole("button").getByText("OK").click(); + } else { + // Takes some time to appear + await page.waitForSelector(".mx_NewRoomIntro", { timeout: 10000 }); + + await expect(page).toHaveURL( + new RegExp( + `/#/room/#test-room-public-1:${authenticatedUser.homeServer}` + ) + ); + const header = page.locator(".mx_RoomHeader"); + + await expect(header).toContainText(name); + await expect(header).toHaveClass(".mx_DecoratedRoomAvatar_icon_forum"); + } + }); + + test("should allow us to create a private room with name", async ({ + page, + authenticatedUser + }) => { + console.log("authenticatedUser", authenticatedUser); + const name = "Test room private 1"; + + await page.getByRole("button", { name: "Add room", exact: true }).click(); + + await page.getByRole("menuitem", { name: "New room", exact: true }).click(); + const dialog = page.locator(".tc_TchapCreateRoomDialog"); + + // Fill name + await dialog.getByRole("textbox").fill(name); + + // Select public room option + await dialog + .locator(".tc_TchapRoomTypeSelector_RadioButton_title") + .getByText("Private room") + .click(); + + // Submit + await dialog.getByRole("button", { name: "Create New Room" }).click(); + + // In local test An error dialog should appear first complaining about wss socket and SSL certificate error + // So not really working locally + if (env == "local") { + await page.getByRole("button").getByText("OK").click(); + } else { + // Takes some time + await page.waitForSelector(".mx_NewRoomIntro", { timeout: 10000 }); + + await expect(page).toHaveURL( + new RegExp( + `/#/room/#test-room-private-1:${authenticatedUser.homeServer}` + ) + ); + const header = page.locator(".mx_RoomHeader"); + + await expect(header).toContainText(name); + await expect(header).toHaveClass(".mx_DecoratedRoomAvatar_icon_private"); + } + }); + + test("should allow us to create a private room with external with name", async ({ + page, + authenticatedUser + }) => { + console.log("authenticatedUser", authenticatedUser); + const name = "Test room private external 1"; + + await page.getByRole("button", { name: "Add room", exact: true }).click(); + + await page.getByRole("menuitem", { name: "New room", exact: true }).click(); + const dialog = page.locator(".tc_TchapCreateRoomDialog"); + + // Fill name + await dialog.getByRole("textbox").fill(name); + + // Select public room option + await dialog + .locator(".tc_TchapRoomTypeSelector_RadioButton_title") + .getByText("Private room open to external users") + .click(); + + // Submit + await dialog.getByRole("button", { name: "Create New Room" }).click(); + + // In local test An error dialog should appear first complaining about wss socket and SSL certificate error + // So not really working locally + if (env == "local") { + await page.getByRole("button").getByText("OK").click(); + } else { + // Takes some time + await page.waitForSelector(".mx_NewRoomIntro", { timeout: 10000 }); + + await expect(page).toHaveURL( + new RegExp( + `/#/room/#test-room-private-external-1:${authenticatedUser.homeServer}` + ) + ); + const header = page.locator(".mx_RoomHeader"); + + await expect(header).toContainText(name); + await expect(header).toHaveClass(".mx_DecoratedRoomAvatar_icon_external"); + } + }); +}); + diff --git a/playwright/utils/TchapAppPage.ts b/playwright/utils/TchapAppPage.ts new file mode 100644 index 0000000..07cf6d5 --- /dev/null +++ b/playwright/utils/TchapAppPage.ts @@ -0,0 +1,191 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2023 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type Locator, type Page, expect } from "@playwright/test"; + +/** + * A set of utility methods for interacting with the Element-Web UI. + */ +export class TchapAppPage { + public constructor(public readonly page: Page) {} + + /** + * Open room creation dialog. + */ + public async openCreateRoomDialog(): Promise { + await this.page.getByRole("button", { name: "Add room", exact: true }).click(); + await this.page.getByRole("menuitem", { name: "New room", exact: true }).click(); + return this.page.locator(".mx_CreateRoomDialog"); + } + + public async getClipboard(): Promise { + return await this.page.evaluate(() => navigator.clipboard.readText()); + } + + /** + * Opens the given room by name. The room must be visible in the + * room list, but the room list may be folded horizontally, and the + * room may contain unread messages. + * + * @param name The exact room name to find and click on/open. + */ + public async viewRoomByName(name: string): Promise { + // We look for the room inside the room list, which is a tree called Rooms. + // + // There are 3 cases: + // - the room list is folded: + // then the aria-label on the room tile is the name (with nothing extra) + // - the room list is unfolder and the room has messages: + // then the aria-label contains the unread count, but the title of the + // div inside the titleContainer equals the room name + // - the room list is unfolded and the room has no messages: + // then the aria-label is the name and so is the title of a div + // + // So by matching EITHER title=name OR aria-label=name we find this exact + // room in all three cases. + return this.page + .getByRole("tree", { name: "Rooms" }) + .locator(`[title="${name}"],[aria-label="${name}"]`) + .first() + .click(); + } + + public async viewRoomById(roomId: string): Promise { + await this.page.goto(`/#/room/${roomId}`); + } + + /** + * Get the composer element + * @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer + */ + public getComposer(isRightPanel?: boolean): Locator { + const panelClass = isRightPanel ? ".mx_RightPanel" : ".mx_RoomView_body"; + return this.page.locator(`${panelClass} .mx_MessageComposer`); + } + + /** + * Get the composer input field + * @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer + */ + public getComposerField(isRightPanel?: boolean): Locator { + return this.getComposer(isRightPanel).locator("div[contenteditable]"); + } + + /** + * Open the message composer kebab menu + * @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer + */ + public async openMessageComposerOptions(isRightPanel?: boolean): Promise { + const composer = this.getComposer(isRightPanel); + await composer.getByRole("button", { name: "More options", exact: true }).click(); + return this.page.getByRole("menu"); + } + + /** + * Returns the space panel space button based on a name. The space + * must be visible in the space panel + * @param name The space name to find + */ + public async getSpacePanelButton(name: string): Promise { + const button = this.page.getByRole("button", { name: name }); + await expect(button).toHaveClass(/mx_SpaceButton/); + return button; + } + + /** + * Opens the given space home by name. The space must be visible in + * the space list. + * @param name The space name to find and click on/open. + */ + public async viewSpaceHomeByName(name: string): Promise { + const button = await this.getSpacePanelButton(name); + return button.dblclick(); + } + + /** + * Opens the given space by name. The space must be visible in the + * space list. + * @param name The space name to find and click on/open. + */ + public async viewSpaceByName(name: string): Promise { + const button = await this.getSpacePanelButton(name); + return button.click(); + } + + /** + * Opens/closes the room info panel + * @returns locator to the right panel + */ + public async toggleRoomInfoPanel(): Promise { + await this.page.getByRole("button", { name: "Room info" }).first().click(); + return this.page.locator(".mx_RightPanel"); + } + + /** + * Opens/closes the memberlist panel + * @returns locator to the memberlist panel + */ + public async toggleMemberlistPanel(): Promise { + const locator = this.page.locator(".mx_FacePile"); + await locator.click(); + const memberlist = this.page.locator(".mx_MemberListView"); + await memberlist.waitFor(); + return memberlist; + } + + /** + * Get a locator for the tooltip associated with an element + * @param e The element with the tooltip + * @returns Locator to the tooltip + */ + public async getTooltipForElement(e: Locator): Promise { + const [labelledById, describedById] = await Promise.all([ + e.getAttribute("aria-labelledby"), + e.getAttribute("aria-describedby"), + ]); + if (!labelledById && !describedById) { + throw new Error( + "Element has no aria-labelledby or aria-describedy attributes! The tooltip should have added either one of these.", + ); + } + return this.page.locator(`id=${labelledById ?? describedById}`); + } + + /** + * Close the notification toast + */ + public closeNotificationToast(): Promise { + // Dismiss "Notification" toast + return this.page + .locator(".mx_Toast_toast", { hasText: "Notifications" }) + .getByRole("button", { name: "Dismiss" }) + .click(); + } + + /** + * Scroll an infinite list to the bottom. + * @param list The element to scroll + */ + public async scrollListToBottom(list: Locator): Promise { + // First hover the mouse over the element that we want to scroll + await list.hover(); + + const needsScroll = async () => { + // From https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled + const fullyScrolled = await list.evaluate( + (e) => Math.abs(e.scrollHeight - e.clientHeight - e.scrollTop) <= 1, + ); + return !fullyScrolled; + }; + + // Scroll the element until we detect that it is fully scrolled + do { + await this.page.mouse.wheel(0, 1000); + } while (await needsScroll()); + } +} diff --git a/playwright/utils/api.ts b/playwright/utils/api.ts new file mode 100644 index 0000000..7488428 --- /dev/null +++ b/playwright/utils/api.ts @@ -0,0 +1,121 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type APIRequestContext } from "@playwright/test"; + +export type Verb = "GET" | "POST" | "PUT" | "DELETE"; + +/** + * A generic API client. + */ +export class Api { + private _request?: APIRequestContext; + + public constructor(private readonly baseUrl: string) {} + + /** + * Set the request context to use for making requests. + * @param request - The request context to use. + */ + public setRequest(request: APIRequestContext): void { + this._request = request; + } + + /** + * Make a request to the API. + * @param verb - The HTTP verb to use. + * @param path - The path to request. + * @param token - The access token to use for the request. + * @param data - The data to send with the request. + */ + public async request(verb: "GET", path: string, token?: string, data?: never): Promise; + public async request(verb: Verb, path: string, token?: string, data?: object): Promise; + public async request(verb: Verb, path: string, token?: string, data?: object): Promise { + if (!this._request) { + throw new Error("No request context set"); + } + + const url = `${this.baseUrl}${path}`; + const res = await this._request.fetch(url, { + data, + method: verb, + headers: token + ? { + Authorization: `Bearer ${token}`, + } + : undefined, + }); + + if (!res.ok()) { + const json = await res.json(); + console.log("res.json", json); + throw new Error( + `Request to ${url} failed with status ${res.status()}: ${JSON.stringify(json)}`, + ); + } + + return res.json(); + } +} + +/** + * Credentials for a user. + */ +export interface Credentials { + /** The base URL of the homeserver's CS API. */ + homeserverBaseUrl: string; + + accessToken: string; + userId: string; + deviceId: string; + + /** The domain part of the user's matrix ID. */ + homeServer: string; + + password: string | null; // null for password-less users + username: string; // the localpart of the userId +} + +/** + * A client-server API for interacting with a Matrix homeserver. + */ +export class ClientServerApi extends Api { + public constructor(baseUrl: string, request: APIRequestContext) { + super(`${baseUrl}/_matrix/client`); + this.setRequest(request); + } + + /** + * Register a user on the homeserver. + * @param userId - The user ID to register. + * @param password - The password to use for the user. + */ + public async loginUser(userId: string, password: string): Promise> { + const json = await this.request<{ + access_token: string; + user_id: string; + device_id: string; + home_server: string; + }>("POST", "/v3/login", undefined, { + type: "m.login.password", + identifier: { + type: "m.id.user", + user: userId, + }, + password: password, + }); + + return { + password, + accessToken: json.access_token, + userId: json.user_id, + deviceId: json.device_id, + homeServer: json.home_server || json.user_id.split(":").slice(1).join(":"), + username: userId.slice(1).split(":")[0], + }; + } +} diff --git a/playwright/tests/utils/auth-helpers.ts b/playwright/utils/auth-helpers.ts similarity index 75% rename from playwright/tests/utils/auth-helpers.ts rename to playwright/utils/auth-helpers.ts index b04bfcd..8e90a56 100644 --- a/playwright/tests/utils/auth-helpers.ts +++ b/playwright/utils/auth-helpers.ts @@ -1,7 +1,8 @@ import { BrowserContext, Page } from '@playwright/test'; import { createKeycloakUser, deleteKeycloakUser } from './keycloak-admin'; import { waitForMasUser, createMasUserWithPassword, deactivateMasUser } from './mas-admin'; -import { ELEMENT_URL, generateTestUser, KEYCLOAK_URL, MAS_URL, SCREENSHOTS_DIR } from './config'; +import { ELEMENT_URL, KEYCLOAK_URL, MAS_URL, SCREENSHOTS_DIR, TEST_USER_PASSWORD, TEST_USER_PREFIX } from './config'; +import { Credentials } from './api'; /** * Test user type @@ -14,6 +15,16 @@ export interface TestUser { masId?: string; } +/** + * Different type of user can be used + */ +export enum TypeUser { + MAS_PASSWORD_USER, + MAS_OIDC_USER, + INVITED_USER, + OIDC_USER, +} + /** * Create a test user in Keycloak */ @@ -272,4 +283,87 @@ export async function extractVerificationCode(context: BrowserContext, screensho const verificationCode = codeMatch[1]; return verificationCode; - } \ No newline at end of file +} + + +/** + * Same as performPasswordLogin but without the screenshots + */ +export async function performSimplePasswordLogin( + page: Page, + user: TestUser, + screenshot_path: string +): Promise { + console.log(`[Auth] Performing password login for user: ${user.kc_username}`); + + // Navigate to the login page + await page.goto("/login"); + + // Fill in the username and password + await page.locator('input[name="username"]').fill(user.kc_username); + await page.locator('input[name="password"]').fill(user.kc_password); + + // Click the login button (submit the form) + await page.locator('button[type="submit"]').click(); + + // Wait for successful login (redirect to dashboard or home page) + await page.waitForURL((url) => !url.toString().includes("/login")); + + console.log(`[Auth] Password login successful for user: ${user.kc_username}`); +} + +// Generate a unique username and email for testing +export function generateTestUser(domain:string) { + const timestamp = new Date().getTime(); + const randomSuffix = Math.floor(Math.random() * 10000); + const kc_username = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}`; + const kc_email = `${kc_username}+1@${domain}`; + + return { + kc_username: kc_username, + kc_email: kc_email, + kc_password: TEST_USER_PASSWORD + }; +} + +// Generate a unique username and email for testing +export function generateExternTestUser() { + const timestamp = new Date().getTime(); + const randomSuffix = Math.floor(Math.random() * 10000); + const username = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}`; + const email = `${username}@tchapgouv.com`; + + return { + username, + email, + password: TEST_USER_PASSWORD + }; +} + + +// Taken from element-mmodules +/** Adds an initScript to the given page which will populate localStorage appropriately so that Element will use the given credentials. */ +export async function populateLocalStorageWithCredentials(page: Page, credentials: Credentials) { + await page.addInitScript( + ({ credentials }) => { + window.localStorage.setItem("mx_hs_url", credentials.homeserverBaseUrl); + window.localStorage.setItem("mx_user_id", credentials.userId); + window.localStorage.setItem("mx_access_token", credentials.accessToken); + window.localStorage.setItem("mx_device_id", credentials.deviceId); + window.localStorage.setItem("mx_is_guest", "false"); + window.localStorage.setItem("mx_has_pickle_key", "false"); + window.localStorage.setItem("mx_has_access_token", "true"); + + window.localStorage.setItem( + "mx_local_settings", + JSON.stringify({ + // Retain any other settings which may have already been set + ...JSON.parse(window.localStorage.getItem("mx_local_settings") ?? "{}"), + // Ensure the language is set to a consistent value + language: "en", + }), + ); + }, + { credentials }, + ); +} diff --git a/playwright/tests/utils/config.ts b/playwright/utils/config.ts similarity index 70% rename from playwright/tests/utils/config.ts rename to playwright/utils/config.ts index dde3422..6c201d1 100644 --- a/playwright/tests/utils/config.ts +++ b/playwright/utils/config.ts @@ -3,7 +3,7 @@ import path from 'path'; // Determine which environment to use -const env = process.env.TEST_ENV || 'local'; +export const env = process.env.TEST_ENV || 'local'; console.log(`Loading environment configuration for: ${env}`); // Load environment variables from the appropriate .env file @@ -16,6 +16,7 @@ dotenv.config({ path: path.resolve(__dirname, `../../.env.${env}`) }); export const MAS_URL = process.env.MAS_URL || 'https://auth.tchapgouv.com'; export const KEYCLOAK_URL = process.env.KEYCLOAK_URL || 'https://sso.tchapgouv.com'; export const ELEMENT_URL = process.env.ELEMENT_URL || 'https://element.tchapgouv.com'; +export const BASE_URL = process.env.BASE_URL || "https://matrix.tchapgouv.com"; export const TCHAP_LEGACY:boolean = Boolean(process.env.TCHAP_LEGACY); @@ -45,30 +46,10 @@ export const SCREENSHOTS_DIR = process.env.SCREENSHOTS_DIR || 'playwright-result // Browser locale export const BROWSER_LOCALE = process.env.BROWSER_LOCALE || 'fr-FR'; -// Generate a unique username and email for testing -export function generateTestUser(domain:string) { - const timestamp = new Date().getTime(); - const randomSuffix = Math.floor(Math.random() * 10); - const kc_username = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}`; - const kc_email = `${kc_username}+1@${domain}`; - - return { - kc_username: kc_username, - kc_email: kc_email, - kc_password: TEST_USER_PASSWORD - }; -} -// Generate a unique username and email for testing -export function generateExternTestUser() { - const timestamp = new Date().getTime(); - const randomSuffix = Math.floor(Math.random() * 10000); - const username = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}`; - const email = `${username}@tchapgouv.com`; - - return { - username, - email, - password: TEST_USER_PASSWORD - }; -} +// TODO Move all below to env file +// Fixed tests data, that can be used across environement +export const FIX_USER_USERNAME = "Michelle_test"; +export const FIX_USER_PASSWORD = "Michelle1313!"; +export const FIX_USER_EMAIL = "Michelle1313!"; + diff --git a/playwright/tests/utils/keycloak-admin.ts b/playwright/utils/keycloak-admin.ts similarity index 100% rename from playwright/tests/utils/keycloak-admin.ts rename to playwright/utils/keycloak-admin.ts diff --git a/playwright/tests/utils/mas-admin.ts b/playwright/utils/mas-admin.ts similarity index 100% rename from playwright/tests/utils/mas-admin.ts rename to playwright/utils/mas-admin.ts From fbb59ace26e27c4e504329e293470a5d6d02e284 Mon Sep 17 00:00:00 2001 From: olivierdelcroix Date: Mon, 20 Oct 2025 15:38:59 +0200 Subject: [PATCH 34/71] move tests to auth folder --- .../tests/{ => auth}/register-password.spec.ts | 12 ++---------- .../tests/{ => auth}/tchap-login-password.spec.ts | 6 +++--- .../tests/{ => auth}/tchap-register-password.spec.ts | 8 ++++---- 3 files changed, 9 insertions(+), 17 deletions(-) rename playwright/tests/{ => auth}/register-password.spec.ts (73%) rename playwright/tests/{ => auth}/tchap-login-password.spec.ts (93%) rename playwright/tests/{ => auth}/tchap-register-password.spec.ts (93%) diff --git a/playwright/tests/register-password.spec.ts b/playwright/tests/auth/register-password.spec.ts similarity index 73% rename from playwright/tests/register-password.spec.ts rename to playwright/tests/auth/register-password.spec.ts index b1fd2f2..7ab3eca 100644 --- a/playwright/tests/register-password.spec.ts +++ b/playwright/tests/auth/register-password.spec.ts @@ -1,13 +1,5 @@ -import { test, expect } from '../fixtures/auth-fixture'; -import { - verifyUserInMas, - performOidcLoginFromTchap, - extractVerificationCode -} from '../utils/auth-helpers'; -import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail } from '../utils/mas-admin'; -import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from '../utils/config'; -import { BrowserContext } from '@playwright/test'; -import { assert } from 'console'; +import { test, expect } from '../../fixtures/auth-fixture'; +import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from '../../utils/config'; test.describe('Register', () => { diff --git a/playwright/tests/tchap-login-password.spec.ts b/playwright/tests/auth/tchap-login-password.spec.ts similarity index 93% rename from playwright/tests/tchap-login-password.spec.ts rename to playwright/tests/auth/tchap-login-password.spec.ts index 7b42933..e267874 100644 --- a/playwright/tests/tchap-login-password.spec.ts +++ b/playwright/tests/auth/tchap-login-password.spec.ts @@ -1,6 +1,6 @@ -import { test, expect } from '../fixtures/auth-fixture'; -import { checkMasUserExistsByEmail, createMasUserWithPassword } from '../utils/mas-admin'; -import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from '../utils/config'; +import { test, expect } from '../../fixtures/auth-fixture'; +import { checkMasUserExistsByEmail, createMasUserWithPassword } from '../../utils/mas-admin'; +import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from '../../utils/config'; test.describe('Tchap : Login password', () => { diff --git a/playwright/tests/tchap-register-password.spec.ts b/playwright/tests/auth/tchap-register-password.spec.ts similarity index 93% rename from playwright/tests/tchap-register-password.spec.ts rename to playwright/tests/auth/tchap-register-password.spec.ts index 63e0437..468dab3 100644 --- a/playwright/tests/tchap-register-password.spec.ts +++ b/playwright/tests/auth/tchap-register-password.spec.ts @@ -1,9 +1,9 @@ -import { test, expect } from '../fixtures/auth-fixture'; +import { test, expect } from '../../fixtures/auth-fixture'; import { extractVerificationCode -} from '../utils/auth-helpers'; -import { getMasUserByEmail } from '../utils/mas-admin'; -import { SCREENSHOTS_DIR, ELEMENT_URL } from '../utils/config'; +} from '../../utils/auth-helpers'; +import { getMasUserByEmail } from '../../utils/mas-admin'; +import { SCREENSHOTS_DIR, ELEMENT_URL } from '../../utils/config'; test.describe('Tchap : register password', () => { From b941dc6cdec02597fb6d32fcad8cdbbbb830c1c7 Mon Sep 17 00:00:00 2001 From: olivierdelcroix Date: Mon, 20 Oct 2025 16:03:03 +0200 Subject: [PATCH 35/71] fix private room test --- playwright/tests/web/create-room.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright/tests/web/create-room.spec.ts b/playwright/tests/web/create-room.spec.ts index 217e036..12a3d98 100644 --- a/playwright/tests/web/create-room.spec.ts +++ b/playwright/tests/web/create-room.spec.ts @@ -67,7 +67,7 @@ test.describe("Create Room", () => { // Select public room option await dialog .locator(".tc_TchapRoomTypeSelector_RadioButton_title") - .getByText("Private room") + .getByText("Private room", { exact: true }) .click(); // Submit From 798ee57e5c122b2f764a195cf80137dd0cf285d5 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Wed, 5 Nov 2025 10:55:41 +0100 Subject: [PATCH 36/71] add tests for register with password (#15) * add tests for register * add check on email * add a new fixture to capture screenshot * fix fixture callable type * clean tests * test when no oauth2_authorization_grant --- playwright/fixtures/auth-fixture.ts | 30 ++++++- .../tests/auth/register-password.spec.ts | 24 ++--- .../auth/tchap-register-password.spec.ts | 89 +++++++++++-------- playwright/utils/auth-helpers.ts | 7 +- 4 files changed, 89 insertions(+), 61 deletions(-) diff --git a/playwright/fixtures/auth-fixture.ts b/playwright/fixtures/auth-fixture.ts index 3eb5437..14d4277 100644 --- a/playwright/fixtures/auth-fixture.ts +++ b/playwright/fixtures/auth-fixture.ts @@ -1,4 +1,4 @@ -import { test as base, Page } from "@playwright/test"; +import { test as base, Page, TestInfo } from "@playwright/test"; import { createKeycloakTestUser, cleanupKeycloakTestUser, @@ -9,6 +9,9 @@ import { import { disposeApiContext as disposeKeycloakApiContext } from "../utils/keycloak-admin"; import { createMasUserWithPassword, deactivateMasUser, disposeApiContext as disposeMasApiContext, waitForMasUser } from "../utils/mas-admin"; import { generateTestUser } from "../utils/auth-helpers"; +import fs from 'fs'; +import path from 'path'; +import { SCREENSHOTS_DIR } from '../utils/config'; import { STANDARD_EMAIL_DOMAIN, @@ -93,6 +96,9 @@ function createLegacyUserFixture(domain: string) { }; } + +export type ScreenCheckerFixture = (page: Page, urlFragment: string) => Promise; + /** * Extend the basic test fixtures with our authentication fixtures */ @@ -106,6 +112,7 @@ export const test = base.extend<{ userLegacyWithFallbackRules: TestUser; authenticatedUser: Credentials; typeUser: TypeUser; + screenChecker :ScreenCheckerFixture; }>({ /** * Create a test user in Keycloak before the test and clean it up after @@ -118,6 +125,27 @@ export const test = base.extend<{ userLegacy: createLegacyUserFixture(STANDARD_EMAIL_DOMAIN), userLegacyWithFallbackRules: createLegacyUserFixture(NUMERIQUE_EMAIL_DOMAIN), typeUser: TypeUser.MAS_PASSWORD_USER, + screenChecker: async ({}, use, testInfo: TestInfo) => { + //this fixture clean up the screenshot folder before the tests + //and exposes a method to capture a screenshot from an waited url + + const screenshotPath = path.join(SCREENSHOTS_DIR, testInfo.title.replace(/\s+/g, '_')); + let counter = 1; + + if (fs.existsSync(screenshotPath)) { + fs.rmSync(screenshotPath, { recursive: true, force: true }); + } + fs.mkdirSync(screenshotPath, { recursive: true }); + + const screenChecker = async (page: Page, urlFragment: string) => { + await page.waitForURL((url) => url.toString().includes(urlFragment)); + const filename = `${counter.toString().padStart(2, '0')}-${urlFragment.replace(/[^\w]/g, '_')}.png`; + await page.screenshot({ path: path.join(screenshotPath, filename), fullPage:true }); + counter++; + }; + + await use(screenChecker); + }, authenticatedUser: async ({ page, testUser: user, request }, use) => { // 1. Register user const userId = await createMasUserWithPassword( diff --git a/playwright/tests/auth/register-password.spec.ts b/playwright/tests/auth/register-password.spec.ts index 7ab3eca..9da3153 100644 --- a/playwright/tests/auth/register-password.spec.ts +++ b/playwright/tests/auth/register-password.spec.ts @@ -4,29 +4,15 @@ import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from '../../utils/config'; test.describe('Register', () => { - test('without login_hint', async ({ context, page, simpleUser: user }) => { + test('without oauth2 session', async ({ context, page, simpleUser: user, screenChecker: screen }) => { - const email = user.kc_email; - - const screenshot_path = test.info().title.replace(" ", "_"); - // Navigate to the register page await page.goto('/register'); - - //register - await page.waitForURL(url => url.toString().includes(`/register`)); - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-register.png`, fullPage: true }); - - //password await page.getByRole('button').filter({ hasText: 'Continuer avec une adresse mail' }).click(); - await page.waitForURL(url => url.toString().includes(`/register/password`)); - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-password.png`, fullPage: true }); - let password = "sdf78qsd!9090ssss"; - await page.locator('input[name="password"]').fill(password); - await page.locator('input[name="password_confirm"]').fill(password); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - //form is not submitted because email is missing - await page.waitForURL(url => url.toString().includes(`/register/password`)); + //form is not submitted because no oauth2_authorization_grant + await screen(page, '/register/password'); + await expect(page.locator("h1", {hasText:"Unexpected error"})).toBeVisible(); + await expect(page.locator("p", {hasText:"Veuillez fermer cette fenêtre et relancer la création de compte depuis votre appareil Tchap"})).toBeVisible(); }); }); diff --git a/playwright/tests/auth/tchap-register-password.spec.ts b/playwright/tests/auth/tchap-register-password.spec.ts index 468dab3..7c7f8d1 100644 --- a/playwright/tests/auth/tchap-register-password.spec.ts +++ b/playwright/tests/auth/tchap-register-password.spec.ts @@ -3,67 +3,44 @@ import { extractVerificationCode } from '../../utils/auth-helpers'; import { getMasUserByEmail } from '../../utils/mas-admin'; -import { SCREENSHOTS_DIR, ELEMENT_URL } from '../../utils/config'; +import {ELEMENT_URL } from '../../utils/config'; -test.describe('Tchap : register password', () => { +test.describe('Tchap : register with password', () => { - test('tchap register with password', async ({ context, page, simpleUser: user }) => { + test('tchap register with oidc native', async ({ context, page, simpleUser: user, screenChecker: screen }) => { const email = user.kc_email; + let password = "sdf78qsd!9090ssss"; - const screenshot_path = test.info().title.replace(" ", "_"); - await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); - - //welcome - await page.waitForURL(url => url.toString().includes(`#/welcome`)); - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01--tchap-login-page.png` }); + + await screen(page, '#/welcome'); await page.getByRole('link').filter({hasText : "Créer un compte"}).click(); - await page.waitForURL(url => url.toString().includes(`#/email-precheck-sso`)); - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-tchap-precheck-sso.png` }); + + await screen(page, '#/email-precheck-sso'); await page.locator('input').fill(email); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - //register - await page.waitForURL(url => url.toString().includes(`/register`)); - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-register.png`, fullPage: true }); - - //password + await screen(page, '/register'); await page.getByRole('button').filter({ hasText: 'Continuer avec une adresse mail' }).click(); - await page.waitForURL(url => url.toString().includes(`/register/password`)); - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-password.png`, fullPage: true }); - let password = "sdf78qsd!9090ssss"; + + await screen(page, '/register/password'); + await expect(page.locator('input[name="email"]')).toHaveValue(email); await page.locator('input[name="password"]').fill(password); await page.locator('input[name="password_confirm"]').fill(password); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - //verify-email - await page.waitForURL(url => url.toString().includes(`/verify-email`)); - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/05-verify_email.png`, fullPage: true }); - let verificationCode = await extractVerificationCode(context, screenshot_path); - console.log("verification code extracted : ", verificationCode); + await screen(page, '/verify-email'); + let verificationCode = await extractVerificationCode(context, screen); await page.locator('input[name="code"]').fill(verificationCode); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - //display-name - await page.waitForURL(url => url.toString().includes(`/display-name`)); - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/06-display-name.png`, fullPage: true }); + await screen(page, '/consent'); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - //consent - await page.waitForURL(url => url.toString().includes(`/consent`)); - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/07-consent.png`, fullPage: true }); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - - - //tchap - await page.waitForURL(url => url.toString().includes(`#/home`), { timeout: 20000 }); - //TODO this condition is hardcoded + await screen(page, '#/home'); await expect(page.locator('h1').filter({ hasText: /Bienvenue.*\[Tchapgouv\]/ })).toBeVisible({ timeout: 20000 }); - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/08-tchap-home.png`, fullPage: true }); - - const created_user = await getMasUserByEmail(email); //check created username fields @@ -72,4 +49,38 @@ test.describe('Tchap : register password', () => { //todo : check displayname? -> display name is stored in Synapse, or in the home screen of Tchap }); + test('tchap register with oidc native when login_hint is mistaken', async ({page, simpleUser: user, screenChecker: screen }) => { + + const email = user.kc_email; + const second_email = user.kc_email.replace("@","another_email@"); + + await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); + + await screen(page, '#/welcome'); + await page.getByRole('link').filter({hasText : "Créer un compte"}).click(); + await screen(page, '#/email-precheck-sso'); + await page.locator('input').fill(email); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + await screen(page, '/register'); + await page.getByRole('button').filter({ hasText: 'Continuer avec une adresse mail' }).click(); + + //click on link: wrong email + await screen(page, '/register/password'); + await expect(page.locator('input[name="email"]')).toHaveValue(email); + await page.getByRole('link').filter({ hasText: 'pas la bonne adresse' }).click(); + + //input second email + await screen(page, '#/welcome'); + await page.getByRole('link').filter({hasText : "Créer un compte"}).click(); + await screen(page, '#/email-precheck-sso'); + await page.locator('input').fill(second_email); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + await screen(page, '/register'); + await page.getByRole('button').filter({ hasText: 'Continuer avec une adresse mail' }).click(); + + //check second email + await screen(page, '/register/password'); + await expect(page.locator('input[name="email"]')).toHaveValue(second_email); + }); + }); diff --git a/playwright/utils/auth-helpers.ts b/playwright/utils/auth-helpers.ts index 8e90a56..7ac0648 100644 --- a/playwright/utils/auth-helpers.ts +++ b/playwright/utils/auth-helpers.ts @@ -3,6 +3,7 @@ import { createKeycloakUser, deleteKeycloakUser } from './keycloak-admin'; import { waitForMasUser, createMasUserWithPassword, deactivateMasUser } from './mas-admin'; import { ELEMENT_URL, KEYCLOAK_URL, MAS_URL, SCREENSHOTS_DIR, TEST_USER_PASSWORD, TEST_USER_PREFIX } from './config'; import { Credentials } from './api'; +import { ScreenCheckerFixture } from '../fixtures/auth-fixture'; /** * Test user type @@ -256,7 +257,7 @@ export async function performPasswordLogin(page: Page, user: TestUser, screensho // Add this function to extract the verification code -export async function extractVerificationCode(context: BrowserContext, screenshot_path:string): Promise { +export async function extractVerificationCode(context: BrowserContext, waitForScreen:ScreenCheckerFixture): Promise { // Create a new page for mail.tchapgouv.com const page = await context.newPage(); @@ -265,7 +266,8 @@ export async function extractVerificationCode(context: BrowserContext, screensho // Wait for the page to load and click on the first email await page.waitForSelector('.msglist-message'); - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/00-verification-code.png`, fullPage: true }); + + await waitForScreen(page, 'mail.tchapgouv.com'); const firstIncompingMail = await page.locator('.col-md-5').first(); @@ -281,6 +283,7 @@ export async function extractVerificationCode(context: BrowserContext, screensho throw new Error('Unable to extract verification code from text'); } const verificationCode = codeMatch[1]; + console.log("verification code extracted : ", verificationCode); return verificationCode; } From 50af7466be5c95c2682297900cbd6c0b202bc34e Mon Sep 17 00:00:00 2001 From: olivierdelcroix Date: Wed, 5 Nov 2025 13:55:22 +0100 Subject: [PATCH 37/71] clean tests --- playwright/fixtures/auth-fixture.ts | 47 +++++--- playwright/playwright.config.ts | 5 + .../tests/auth/element-oidc-auth.spec.ts | 50 --------- playwright/tests/auth/login-oidc.spec.ts | 78 ++++++------- playwright/tests/auth/login-password.spec.ts | 6 +- playwright/tests/auth/register-oidc.spec.ts | 24 ++-- .../tests/auth/register-password.spec.ts | 6 +- ...-auth.spec.ts => tchap-login-oidc.spec.ts} | 8 +- .../tests/auth/tchap-login-password.spec.ts | 14 +-- .../auth/tchap-register-password.spec.ts | 97 ++++++++-------- playwright/utils/auth-helpers.ts | 106 ++++-------------- 11 files changed, 178 insertions(+), 263 deletions(-) delete mode 100644 playwright/tests/auth/element-oidc-auth.spec.ts rename playwright/tests/auth/{tchap-oidc-auth.spec.ts => tchap-login-oidc.spec.ts} (92%) diff --git a/playwright/fixtures/auth-fixture.ts b/playwright/fixtures/auth-fixture.ts index 14d4277..2d88b6a 100644 --- a/playwright/fixtures/auth-fixture.ts +++ b/playwright/fixtures/auth-fixture.ts @@ -8,7 +8,7 @@ import { } from "../utils/auth-helpers"; import { disposeApiContext as disposeKeycloakApiContext } from "../utils/keycloak-admin"; import { createMasUserWithPassword, deactivateMasUser, disposeApiContext as disposeMasApiContext, waitForMasUser } from "../utils/mas-admin"; -import { generateTestUser } from "../utils/auth-helpers"; +import { generateTestUserData } from "../utils/auth-helpers"; import fs from 'fs'; import path from 'path'; import { SCREENSHOTS_DIR } from '../utils/config'; @@ -28,7 +28,7 @@ import { ClientServerApi, Credentials } from "../utils/api"; function generateSimpleUserFixture(domain: string) { return async ({}, use: (user: TestUser) => Promise) => { try { - const user = generateTestUser(domain); + const user = generateTestUserData(domain); // Use the test user in the test await use(user); @@ -47,7 +47,7 @@ function generateSimpleUserFixture(domain: string) { function createTestUserFixture(domain: string) { return async ({}, use: (user: TestUser) => Promise) => { try { - const testUser = generateTestUser(domain); + const testUser = generateTestUserData(domain); // Create a test user in Keycloak const user = await createKeycloakTestUser(testUser); @@ -57,7 +57,7 @@ function createTestUserFixture(domain: string) { // Clean up the test user after the test await cleanupKeycloakTestUser(user); - console.log(`Cleaned up test user: ${user.kc_username}`); + console.log(`Cleaned up test user: ${user.username}`); } finally { // Dispose API contexts await Promise.all([disposeKeycloakApiContext(), disposeMasApiContext()]); @@ -74,9 +74,9 @@ function createLegacyUserFixture(domain: string) { const randomSuffix = Math.floor(Math.random() * 10000); const testUser: TestUser = { - kc_username: `test.user${randomSuffix}-${domain}`, - kc_email: `test.user${randomSuffix}@${domain}`, - kc_password: "1234!", + username: `test.user${randomSuffix}-${domain}`, + email: `test.user${randomSuffix}@${domain}`, + password: "1234!", }; // Create a test user in Keycloak @@ -87,7 +87,7 @@ function createLegacyUserFixture(domain: string) { // Clean up the test user after the test await cleanupKeycloakTestUser(user); - console.log(`Cleaned up test user: ${user.kc_username}`); + console.log(`Cleaned up test user: ${user.username}`); } finally { // Dispose API contexts await Promise.all([disposeKeycloakApiContext(), disposeMasApiContext()]); @@ -98,6 +98,7 @@ function createLegacyUserFixture(domain: string) { export type ScreenCheckerFixture = (page: Page, urlFragment: string) => Promise; +export type StartTchapRegisterWithEmailFixture = (page: Page, email: string) => Promise; /** * Extend the basic test fixtures with our authentication fixtures @@ -113,6 +114,7 @@ export const test = base.extend<{ authenticatedUser: Credentials; typeUser: TypeUser; screenChecker :ScreenCheckerFixture; + startTchapRegisterWithEmail: StartTchapRegisterWithEmailFixture; }>({ /** * Create a test user in Keycloak before the test and clean it up after @@ -146,20 +148,35 @@ export const test = base.extend<{ await use(screenChecker); }, + startTchapRegisterWithEmail: async ({ screenChecker }, use) => { + const start = async (page: Page, email: string) => { + await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); + await screenChecker(page, '#/welcome'); + await page.getByRole('link').filter({ hasText: 'Créer un compte' }).click(); + + await screenChecker(page, '#/email-precheck-sso'); + await page.locator('input').fill(email); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + await screenChecker(page, '/register'); + await page.getByRole('button').filter({ hasText: 'Continuer avec une adresse mail' }).click(); + }; + await use(start); + }, authenticatedUser: async ({ page, testUser: user, request }, use) => { // 1. Register user const userId = await createMasUserWithPassword( - user.kc_username, - user.kc_email, - user.kc_password + user.username, + user.email, + user.password ); const csAPI = new ClientServerApi(BASE_URL, request); - await waitForMasUser(user.kc_email); + await waitForMasUser(user.email); const credentials = (await csAPI.loginUser( - user.kc_username, - user.kc_password + user.username, + user.password )) as Credentials; // 2. Populate localStorage @@ -174,7 +191,7 @@ export const test = base.extend<{ // Clean up, deactivate user await deactivateMasUser(userId); - console.log(`Cleaned up MAS user: ${user.kc_username}`); + console.log(`Cleaned up MAS user: ${user.username}`); }, }); diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts index 6ebbc4c..eeeb0bc 100644 --- a/playwright/playwright.config.ts +++ b/playwright/playwright.config.ts @@ -14,6 +14,11 @@ export default defineConfig({ timeout: 30 * 1000, /* Run tests in files in parallel */ fullyParallel: process.env.TEST_IN_PARALLEL === 'true' ? true : false, + + /* Define how many workers */ + // Limit the number of workers on CI, use default locally + workers: process.env.CI ? 2 : undefined, + /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ diff --git a/playwright/tests/auth/element-oidc-auth.spec.ts b/playwright/tests/auth/element-oidc-auth.spec.ts deleted file mode 100644 index 709e229..0000000 --- a/playwright/tests/auth/element-oidc-auth.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { test, expect } from '../../fixtures/auth-fixture'; -import { - verifyUserInMas, - performOidcLoginFromElement -} from '../../utils/auth-helpers'; -import { checkMasUserExistsByEmail, createMasUserWithPassword } from '../../utils/mas-admin'; -import { SCREENSHOTS_DIR, TCHAP_LEGACY } from '../../utils/config'; - - -//flaky on await expect(page.locator('text=Configuration')).toBeVisible({timeout: 20000}); -test.describe('Element : Login via OIDC', () => { - test('element match account by username', async ({ page, userLegacy: userLegacy }) => { - const screenshot_path = test.info().title.replace(" ", "_"); - - userLegacy.masId = await createMasUserWithPassword(userLegacy.kc_username, userLegacy.kc_email, userLegacy.kc_password); - - // Verify the test user doesn't exist in MAS yet - const existsBeforeLogin = await checkMasUserExistsByEmail(userLegacy.kc_email); - expect(existsBeforeLogin).toBe(true); - - // Perform the OIDC login flow - await performOidcLoginFromElement(page, userLegacy,screenshot_path, TCHAP_LEGACY); - - // Click the create account button - await page.locator('button[type="submit"]').click(); - - // Verify we're successfully logged in, confirgmation page - await expect(page.locator('text=Continuer')).toBeVisible(); - - // Take a screenshot of the authenticated state - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/05-confirmation.png` }); - - await page.locator('button[type="submit"]').filter({hasText:'Continuer'}).click(); - - //flaky condition - //await expect(page.locator('text=Configuration')).toBeVisible({timeout: 20000}); - - // Take a screenshot of the authenticated state - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/06-auth-success.png` }); - - // Verify the user was created in MAS - await verifyUserInMas(userLegacy); - - // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(userLegacy.kc_email); - expect(existsAfterLogin).toBe(true); - - console.log(`Successfully authenticated and verified user ${userLegacy.kc_username} (${userLegacy.kc_email})`); - }); -}); diff --git a/playwright/tests/auth/login-oidc.spec.ts b/playwright/tests/auth/login-oidc.spec.ts index 803f444..f40fb1b 100644 --- a/playwright/tests/auth/login-oidc.spec.ts +++ b/playwright/tests/auth/login-oidc.spec.ts @@ -11,9 +11,9 @@ test.describe('Login via OIDC', () => { const screenshot_path = test.info().title.replace(" ", "_"); // Create a user in MAS with the same email as the Keycloak user - console.log(`Creating MAS user with same username as Keycloak user: ${userLegacy.kc_username}`); + console.log(`Creating MAS user with same username as Keycloak user: ${userLegacy.username}`); - userLegacy.masId = await createMasUserWithPassword(userLegacy.kc_username, userLegacy.kc_email, userLegacy.kc_password); + userLegacy.masId = await createMasUserWithPassword(userLegacy.username, userLegacy.email, userLegacy.password); try { // Perform the OIDC login flow @@ -30,15 +30,15 @@ test.describe('Login via OIDC', () => { await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); // Verify the user in MAS is still the same (account was linked, not created new) - const userAfterLogin = await getMasUserByEmail(userLegacy.kc_email); + const userAfterLogin = await getMasUserByEmail(userLegacy.email); expect(userAfterLogin.id).toBe(userLegacy.masId); expect(await oauthLinkExistsByUserId(userLegacy.masId)).toBe(true); - console.log(`Successfully verified account linking for user with email: ${userLegacy.kc_email}`); + console.log(`Successfully verified account linking for user with email: ${userLegacy.email}`); } finally { // Clean up the MAS user await deactivateMasUser(userLegacy.masId); - console.log(`Cleaned up MAS user: ${userLegacy.kc_username}`); + console.log(`Cleaned up MAS user: ${userLegacy.username}`); } }); @@ -47,9 +47,9 @@ test.describe('Login via OIDC', () => { const screenshot_path = test.info().title.replace(" ", "_"); // Create a user in MAS with the same email as the Keycloak user - console.log(`Creating MAS user with same email as Keycloak user: ${userLegacy.kc_email}`); + console.log(`Creating MAS user with same email as Keycloak user: ${userLegacy.email}`); - userLegacy.masId = await createMasUserWithPassword(userLegacy.kc_username+"different_from_email", userLegacy.kc_email, userLegacy.kc_password); + userLegacy.masId = await createMasUserWithPassword(userLegacy.username+"different_from_email", userLegacy.email, userLegacy.password); try { @@ -67,16 +67,16 @@ test.describe('Login via OIDC', () => { await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); // Verify the user in MAS is still the same (account was linked, not created new) - const userAfterLogin = await getMasUserByEmail(userLegacy.kc_email); + const userAfterLogin = await getMasUserByEmail(userLegacy.email); expect(userAfterLogin.id).toBe(userLegacy.masId); //expect(await oauthLinkExistsByUserId(userLegacy.masId)).toBe(true); - expect(await oauthLinkExistsBySubject(userLegacy.kc_username)).toBe(true); + expect(await oauthLinkExistsBySubject(userLegacy.username)).toBe(true); - console.log(`Successfully verified account linking for user with email: ${userLegacy.kc_email}`); + console.log(`Successfully verified account linking for user with email: ${userLegacy.email}`); } finally { // Clean up the MAS user await deactivateMasUser(userLegacy.masId); - console.log(`Cleaned up MAS user: ${userLegacy.kc_username}`); + console.log(`Cleaned up MAS user: ${userLegacy.username}`); } }); @@ -85,13 +85,13 @@ test.describe('Login via OIDC', () => { const screenshot_path = test.info().title.replace(" ", "_"); const old_email_domain = "@beta.gouv.fr"; - const old_email = userLegacy.kc_email.replace(/@.*/, old_email_domain); + const old_email = userLegacy.email.replace(/@.*/, old_email_domain); // Create a user in MAS with the same email as the Keycloak user - console.log(`Creating MAS user with old email: ${old_email} whereas email in keycloak is : ${userLegacy.kc_email}`); + console.log(`Creating MAS user with old email: ${old_email} whereas email in keycloak is : ${userLegacy.email}`); - userLegacy.masId = await createMasUserWithPassword(userLegacy.kc_username+"different_from_email", old_email, userLegacy.kc_password); + userLegacy.masId = await createMasUserWithPassword(userLegacy.username+"different_from_email", old_email, userLegacy.password); try { @@ -117,7 +117,7 @@ test.describe('Login via OIDC', () => { } finally { // Clean up the MAS user await deactivateMasUser(userLegacy.masId); - console.log(`Cleaned up MAS user: ${userLegacy.kc_username}`); + console.log(`Cleaned up MAS user: ${userLegacy.username}`); } }); @@ -126,18 +126,18 @@ test.describe('Login via OIDC', () => { const screenshot_path = test.info().title.replace(" ", "_"); // Create a user in MAS with the same email as the Keycloak user - console.log(`Creating MAS user with same email as Keycloak user: ${userLegacy.kc_email}`); + console.log(`Creating MAS user with same email as Keycloak user: ${userLegacy.email}`); - const formerTchapAccountMasId = await createMasUserWithPassword(userLegacy.kc_username, userLegacy.kc_email, userLegacy.kc_password); + const formerTchapAccountMasId = await createMasUserWithPassword(userLegacy.username, userLegacy.email, userLegacy.password); await deactivateMasUser(formerTchapAccountMasId); const newTchapAccountWithIndex = { - kc_username: userLegacy.kc_username + "2", - kc_email: userLegacy.kc_email, - kc_password: userLegacy.kc_password, + username: userLegacy.username + "2", + email: userLegacy.email, + password: userLegacy.password, masId: "", } - newTchapAccountWithIndex.masId = await createMasUserWithPassword(newTchapAccountWithIndex.kc_username, newTchapAccountWithIndex.kc_email, newTchapAccountWithIndex.kc_password); + newTchapAccountWithIndex.masId = await createMasUserWithPassword(newTchapAccountWithIndex.username, newTchapAccountWithIndex.email, newTchapAccountWithIndex.password); try { @@ -155,18 +155,18 @@ test.describe('Login via OIDC', () => { await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); // Verify the user in MAS is linked to the indexed account - const userAfterLogin = await getMasUserByEmail(newTchapAccountWithIndex.kc_email); + const userAfterLogin = await getMasUserByEmail(newTchapAccountWithIndex.email); expect(userAfterLogin.id).toBe(newTchapAccountWithIndex.masId); - expect(userAfterLogin.attributes['username']).toBe(newTchapAccountWithIndex.kc_username); - await expect(page.locator(`text=${newTchapAccountWithIndex.kc_username}`)).toBeVisible(); - expect(await oauthLinkExistsBySubject(userLegacy.kc_username)).toBe(true); + expect(userAfterLogin.attributes['username']).toBe(newTchapAccountWithIndex.username); + await expect(page.locator(`text=${newTchapAccountWithIndex.username}`)).toBeVisible(); + expect(await oauthLinkExistsBySubject(userLegacy.username)).toBe(true); - console.log(`Successfully verified account linking for user with email: ${newTchapAccountWithIndex.kc_email}`); + console.log(`Successfully verified account linking for user with email: ${newTchapAccountWithIndex.email}`); } finally { // Clean up the MAS user await deactivateMasUser(newTchapAccountWithIndex.masId); - console.log(`Cleaned up MAS user: ${newTchapAccountWithIndex.kc_username}`); + console.log(`Cleaned up MAS user: ${newTchapAccountWithIndex.username}`); } }); @@ -175,9 +175,9 @@ test.describe('Login via OIDC', () => { const screenshot_path = test.info().title.replace(" ", "_"); // Create a user in MAS with the same email as the Keycloak user - console.log(`Creating MAS user with same email as Keycloak user: ${userLegacy.kc_email}`); + console.log(`Creating MAS user with same email as Keycloak user: ${userLegacy.email}`); - userLegacy.masId = await createMasUserWithPassword(userLegacy.kc_username, userLegacy.kc_email, userLegacy.kc_password); + userLegacy.masId = await createMasUserWithPassword(userLegacy.username, userLegacy.email, userLegacy.password); try { @@ -195,22 +195,22 @@ test.describe('Login via OIDC', () => { await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); // Verify the user in MAS is still the same (account was linked, not created new) - const userAfterLogin = await getMasUserByEmail(userLegacy.kc_email); + const userAfterLogin = await getMasUserByEmail(userLegacy.email); expect(userAfterLogin.id).toBe(userLegacy.masId); //expect(await oauthLinkExistsByUserId(userLegacy.masId)).toBe(true); - expect(await oauthLinkExistsBySubject(userLegacy.kc_username)).toBe(true); + expect(await oauthLinkExistsBySubject(userLegacy.username)).toBe(true); - console.log(`Successfully verified account linking for user with email: ${userLegacy.kc_email}`); + console.log(`Successfully verified account linking for user with email: ${userLegacy.email}`); // deactvate account await deactivateMasUser(userLegacy.masId); // SUPPORT PROCESS PERFORMED BY BOT ADMIN - const links = await getOauthLinkBySubject(userLegacy.kc_username); + const links = await getOauthLinkBySubject(userLegacy.username); await deleteOauthLink(links[0]['id']) await reactivateMasUser(userLegacy.masId); - await addUserEmail(userLegacy.masId, userLegacy.kc_email) + await addUserEmail(userLegacy.masId, userLegacy.email) // END OF SUPPORT PROCESS // Create a new incognito browser context @@ -242,21 +242,21 @@ test.describe('Login via OIDC', () => { await page2.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path_2}/05-connected-account.png` }); - console.log(`Successfully verified account linking for user with email: ${userLegacy.kc_email}`); + console.log(`Successfully verified account linking for user with email: ${userLegacy.email}`); // Verify the user in MAS is still the same (account was linked, not created new) - const userAfterLogin2 = await getMasUserByEmail(userLegacy.kc_email); + const userAfterLogin2 = await getMasUserByEmail(userLegacy.email); expect(userAfterLogin2.id).toBe(userLegacy.masId); //expect(await oauthLinkExistsByUserId(userLegacy.masId)).toBe(true); - expect(await oauthLinkExistsBySubject(userLegacy.kc_username)).toBe(true); + expect(await oauthLinkExistsBySubject(userLegacy.username)).toBe(true); - console.log(`Successfully verified account linking for user with email: ${userLegacy.kc_email}`); + console.log(`Successfully verified account linking for user with email: ${userLegacy.email}`); } finally { // Clean up the MAS user await deactivateMasUser(userLegacy.masId); - console.log(`Cleaned up MAS user: ${userLegacy.kc_username}`); + console.log(`Cleaned up MAS user: ${userLegacy.username}`); } }); }); diff --git a/playwright/tests/auth/login-password.spec.ts b/playwright/tests/auth/login-password.spec.ts index b9a0024..569cdeb 100644 --- a/playwright/tests/auth/login-password.spec.ts +++ b/playwright/tests/auth/login-password.spec.ts @@ -19,7 +19,7 @@ test.describe('Login with password', () => { const user = await createMasTestUser("exemple.com"); try { - console.log(`Created test user in MAS: ${user.kc_username} (${user.kc_email})`); + console.log(`Created test user in MAS: ${user.username} (${user.email})`); // Perform password login await performPasswordLogin(page, user,screenshot_path); @@ -30,11 +30,11 @@ test.describe('Login with password', () => { // Take a screenshot of the authenticated state await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-password-auth-success.png` }); - console.log(`Successfully authenticated with password for user: ${user.kc_username}`); + console.log(`Successfully authenticated with password for user: ${user.username}`); } finally { // Clean up the test user await cleanupMasTestUser(user); - console.log(`Cleaned up test user: ${user.kc_username}`); + console.log(`Cleaned up test user: ${user.username}`); } }); }); diff --git a/playwright/tests/auth/register-oidc.spec.ts b/playwright/tests/auth/register-oidc.spec.ts index 40eba42..adcc134 100644 --- a/playwright/tests/auth/register-oidc.spec.ts +++ b/playwright/tests/auth/register-oidc.spec.ts @@ -16,7 +16,7 @@ test.describe('Register', () => { const screenshot_path = test.info().title.replace(" ", "_"); // Verify the test user doesn't exist in MAS yet - const existsBeforeLogin = await checkMasUserExistsByEmail(testUser.kc_email); + const existsBeforeLogin = await checkMasUserExistsByEmail(testUser.email); expect(existsBeforeLogin).toBe(false); // Perform the OIDC login flow @@ -36,17 +36,17 @@ test.describe('Register', () => { await verifyUserInMas(testUser); // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(testUser.kc_email); + const existsAfterLogin = await checkMasUserExistsByEmail(testUser.email); expect(existsAfterLogin).toBe(true); - console.log(`Successfully authenticated and verified user ${testUser.kc_username} (${testUser.kc_email})`); + console.log(`Successfully authenticated and verified user ${testUser.username} (${testUser.email})`); }); test('register oidc with extern without invit', async ({ page, testExternalUserWitoutInvit }) => { const screenshot_path = test.info().title.replace(" ", "_"); // Verify the test user doesn't exist in MAS yet - const existsBeforeLogin = await checkMasUserExistsByEmail(testExternalUserWitoutInvit.kc_email); + const existsBeforeLogin = await checkMasUserExistsByEmail(testExternalUserWitoutInvit.email); expect(existsBeforeLogin).toBe(false); // Perform the OIDC login flow @@ -60,17 +60,17 @@ test.describe('Register', () => { // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(testExternalUserWitoutInvit.kc_email); + const existsAfterLogin = await checkMasUserExistsByEmail(testExternalUserWitoutInvit.email); expect(existsAfterLogin).toBe(false); - console.log(`Successfully authenticated and verified user ${testExternalUserWitoutInvit.kc_username} (${testExternalUserWitoutInvit.kc_email})`); + console.log(`Successfully authenticated and verified user ${testExternalUserWitoutInvit.username} (${testExternalUserWitoutInvit.email})`); }); test('register oidc with extern with invit', async ({ page, testExternalUserWithInvit: testExternalUserWithInvit }) => { const screenshot_path = test.info().title.replace(" ", "_"); // Verify the test user doesn't exist in MAS yet - const existsBeforeLogin = await checkMasUserExistsByEmail(testExternalUserWithInvit.kc_email); + const existsBeforeLogin = await checkMasUserExistsByEmail(testExternalUserWithInvit.email); expect(existsBeforeLogin).toBe(false); // Perform the OIDC login flow @@ -89,17 +89,17 @@ test.describe('Register', () => { await verifyUserInMas(testExternalUserWithInvit); // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(testExternalUserWithInvit.kc_email); + const existsAfterLogin = await checkMasUserExistsByEmail(testExternalUserWithInvit.email); expect(existsAfterLogin).toBe(true); - console.log(`Successfully authenticated and verified external user ${testExternalUserWithInvit.kc_username} (${testExternalUserWithInvit.kc_email})`); + console.log(`Successfully authenticated and verified external user ${testExternalUserWithInvit.username} (${testExternalUserWithInvit.email})`); }); test('register oidc on wrong homeserver', async ({ page, testUserOnWrongServer }) => { const screenshot_path = test.info().title.replace(" ", "_"); // Verify the test user doesn't exist in MAS yet - const existsBeforeLogin = await checkMasUserExistsByEmail(testUserOnWrongServer.kc_email); + const existsBeforeLogin = await checkMasUserExistsByEmail(testUserOnWrongServer.email); expect(existsBeforeLogin).toBe(false); // Perform the OIDC login flow @@ -112,10 +112,10 @@ test.describe('Register', () => { await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-error-wrong-server.png` }); // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(testUserOnWrongServer.kc_email); + const existsAfterLogin = await checkMasUserExistsByEmail(testUserOnWrongServer.email); expect(existsAfterLogin).toBe(false); - console.log(`Successfully authenticated and verified user ${testUserOnWrongServer.kc_username} (${testUserOnWrongServer.kc_email})`); + console.log(`Successfully authenticated and verified user ${testUserOnWrongServer.username} (${testUserOnWrongServer.email})`); }); }); diff --git a/playwright/tests/auth/register-password.spec.ts b/playwright/tests/auth/register-password.spec.ts index 9da3153..3d94c50 100644 --- a/playwright/tests/auth/register-password.spec.ts +++ b/playwright/tests/auth/register-password.spec.ts @@ -3,9 +3,11 @@ import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from '../../utils/config'; test.describe('Register', () => { + + test.skip('without oauth2 session', async ({ context, page, simpleUser: user, screenChecker: screen }) => { + // This test is intentionally ignored. + //the error page has been deactivated for the moment because the MAS unit tests must be fixed - test('without oauth2 session', async ({ context, page, simpleUser: user, screenChecker: screen }) => { - await page.goto('/register'); await page.getByRole('button').filter({ hasText: 'Continuer avec une adresse mail' }).click(); diff --git a/playwright/tests/auth/tchap-oidc-auth.spec.ts b/playwright/tests/auth/tchap-login-oidc.spec.ts similarity index 92% rename from playwright/tests/auth/tchap-oidc-auth.spec.ts rename to playwright/tests/auth/tchap-login-oidc.spec.ts index 82dfe95..322120f 100644 --- a/playwright/tests/auth/tchap-oidc-auth.spec.ts +++ b/playwright/tests/auth/tchap-login-oidc.spec.ts @@ -12,10 +12,10 @@ test.describe('Tchap : Login via OIDC', () => { test('tchap match account by username', async ({ page, userLegacy: userLegacy }) => { const screenshot_path = test.info().title.replace(" ", "_"); - userLegacy.masId = await createMasUserWithPassword(userLegacy.kc_username, userLegacy.kc_email, userLegacy.kc_password); + userLegacy.masId = await createMasUserWithPassword(userLegacy.username, userLegacy.email, userLegacy.password); // Verify the test user doesn't exist in MAS yet - const existsBeforeLogin = await checkMasUserExistsByEmail(userLegacy.kc_email); + const existsBeforeLogin = await checkMasUserExistsByEmail(userLegacy.email); expect(existsBeforeLogin).toBe(true); // Perform the OIDC login flow @@ -42,9 +42,9 @@ test.describe('Tchap : Login via OIDC', () => { await verifyUserInMas(userLegacy); // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(userLegacy.kc_email); + const existsAfterLogin = await checkMasUserExistsByEmail(userLegacy.email); expect(existsAfterLogin).toBe(true); - console.log(`Successfully authenticated and verified user ${userLegacy.kc_username} (${userLegacy.kc_email})`); + console.log(`Successfully authenticated and verified user ${userLegacy.username} (${userLegacy.email})`); }); }); diff --git a/playwright/tests/auth/tchap-login-password.spec.ts b/playwright/tests/auth/tchap-login-password.spec.ts index e267874..8b13a7d 100644 --- a/playwright/tests/auth/tchap-login-password.spec.ts +++ b/playwright/tests/auth/tchap-login-password.spec.ts @@ -8,8 +8,8 @@ test.describe('Tchap : Login password', () => { test('tchap login with password and login_hint', async ({ page, userLegacy: userLegacy }) => { const screenshot_path = test.info().title.replace(" ", "_"); - userLegacy.masId = await createMasUserWithPassword(userLegacy.kc_username, userLegacy.kc_email, userLegacy.kc_password); - const existsBeforeLogin = await checkMasUserExistsByEmail(userLegacy.kc_email); + userLegacy.masId = await createMasUserWithPassword(userLegacy.username, userLegacy.email, userLegacy.password); + const existsBeforeLogin = await checkMasUserExistsByEmail(userLegacy.email); expect(existsBeforeLogin).toBe(true); await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); @@ -19,13 +19,13 @@ test.describe('Tchap : Login password', () => { await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-tchap-login-page.png` }); await page.getByRole('link').filter({hasText : "Se connecter par email"}).click(); await page.waitForURL(url => url.toString().includes(`#/email-precheck-sso`)); - await page.locator('input').fill(userLegacy.kc_email); + await page.locator('input').fill(userLegacy.email); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); //login await page.waitForURL(url => url.toString().includes(`${MAS_URL}/login`)); - await expect(page.locator('input[name="username"]')).toHaveValue(userLegacy.kc_email); - await page.locator('input[name="password"]').fill(userLegacy.kc_password); + await expect(page.locator('input[name="username"]')).toHaveValue(userLegacy.email); + await page.locator('input[name="password"]').fill(userLegacy.password); await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-password-login-filled.png` }); await page.locator('button[type="submit"]').click(); @@ -40,9 +40,9 @@ test.describe('Tchap : Login password', () => { await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/05-auth-success.png` }); // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(userLegacy.kc_email); + const existsAfterLogin = await checkMasUserExistsByEmail(userLegacy.email); expect(existsAfterLogin).toBe(true); - console.log(`Successfully authenticated and verified user ${userLegacy.kc_username} (${userLegacy.kc_email})`); + console.log(`Successfully authenticated and verified user ${userLegacy.username} (${userLegacy.email})`); }); }); diff --git a/playwright/tests/auth/tchap-register-password.spec.ts b/playwright/tests/auth/tchap-register-password.spec.ts index 7c7f8d1..7c5eb7d 100644 --- a/playwright/tests/auth/tchap-register-password.spec.ts +++ b/playwright/tests/auth/tchap-register-password.spec.ts @@ -1,34 +1,25 @@ import { test, expect } from '../../fixtures/auth-fixture'; import { - extractVerificationCode + extractVerificationCode, + generateTestUserData } from '../../utils/auth-helpers'; import { getMasUserByEmail } from '../../utils/mas-admin'; -import {ELEMENT_URL } from '../../utils/config'; +import {ELEMENT_URL, NOT_INVITED_EMAIL_DOMAIN, WRONG_SERVER_EMAIL_DOMAIN } from '../../utils/config'; test.describe('Tchap : register with password', () => { - test('tchap register with oidc native', async ({ context, page, simpleUser: user, screenChecker: screen }) => { - - const email = user.kc_email; - let password = "sdf78qsd!9090ssss"; - await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); - - await screen(page, '#/welcome'); - await page.getByRole('link').filter({hasText : "Créer un compte"}).click(); + const PASSWORd = "sdf78qsd!9090ssss"; - await screen(page, '#/email-precheck-sso'); - await page.locator('input').fill(email); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - - await screen(page, '/register'); - await page.getByRole('button').filter({ hasText: 'Continuer avec une adresse mail' }).click(); + test('tchap register with oidc native', async ({ context, page, simpleUser: user, screenChecker: screen, startTchapRegisterWithEmail }) => { + + await startTchapRegisterWithEmail(page, user.email); await screen(page, '/register/password'); - await expect(page.locator('input[name="email"]')).toHaveValue(email); - await page.locator('input[name="password"]').fill(password); - await page.locator('input[name="password_confirm"]').fill(password); + await expect(page.locator('input[name="email"]')).toHaveValue(user.email); + await page.locator('input[name="password"]').fill(PASSWORd); + await page.locator('input[name="password_confirm"]').fill(PASSWORd); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); await screen(page, '/verify-email'); @@ -41,46 +32,54 @@ test.describe('Tchap : register with password', () => { await screen(page, '#/home'); await expect(page.locator('h1').filter({ hasText: /Bienvenue.*\[Tchapgouv\]/ })).toBeVisible({ timeout: 20000 }); - const created_user = await getMasUserByEmail(email); + const created_user = await getMasUserByEmail(user.email); //check created username fields - expect(created_user.attributes.username).toContain(user.kc_username); - - //todo : check displayname? -> display name is stored in Synapse, or in the home screen of Tchap + expect(created_user.attributes.username).toContain(user.username); }); - test('tchap register with oidc native when login_hint is mistaken', async ({page, simpleUser: user, screenChecker: screen }) => { + test('tchap register with not invited email', async ({ context, page, simpleUser: user, screenChecker: screen, startTchapRegisterWithEmail }) => { - const email = user.kc_email; - const second_email = user.kc_email.replace("@","another_email@"); - - await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); - - await screen(page, '#/welcome'); - await page.getByRole('link').filter({hasText : "Créer un compte"}).click(); - await screen(page, '#/email-precheck-sso'); - await page.locator('input').fill(email); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - await screen(page, '/register'); - await page.getByRole('button').filter({ hasText: 'Continuer avec une adresse mail' }).click(); + await startTchapRegisterWithEmail(page, user.email); + + const not_invited_user = generateTestUserData(NOT_INVITED_EMAIL_DOMAIN); - //click on link: wrong email await screen(page, '/register/password'); - await expect(page.locator('input[name="email"]')).toHaveValue(email); - await page.getByRole('link').filter({ hasText: 'pas la bonne adresse' }).click(); - - //input second email - await screen(page, '#/welcome'); - await page.getByRole('link').filter({hasText : "Créer un compte"}).click(); - await screen(page, '#/email-precheck-sso'); - await page.locator('input').fill(second_email); + await expect(page.locator('input[name="email"]')).toHaveValue(user.email); + + //change email with not invited email + await page.locator('input[name="email"]').fill(not_invited_user.email); + await page.locator('input[name="password"]').fill(PASSWORd); + await page.locator('input[name="password_confirm"]').fill(PASSWORd); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - await screen(page, '/register'); - await page.getByRole('button').filter({ hasText: 'Continuer avec une adresse mail' }).click(); + + await screen(page, '/register/password'); + await expect(page.locator('input[name="email"]')).toHaveValue(not_invited_user.email); + + //check that error message is visible + await expect(page.locator('div.cpd-form-message.cpd-form-error-message').filter({ hasText: 'Vous avez besoin d\'une invitation' })).toBeVisible(); + }); + + test('tchap register with email on wrong server', async ({ context, page, simpleUser: user, screenChecker: screen, startTchapRegisterWithEmail }) => { - //check second email + await startTchapRegisterWithEmail(page, user.email); + + const wrong_server_user = generateTestUserData(WRONG_SERVER_EMAIL_DOMAIN); + await screen(page, '/register/password'); - await expect(page.locator('input[name="email"]')).toHaveValue(second_email); + await expect(page.locator('input[name="email"]')).toHaveValue(user.email); + + //change email with not invited email + await page.locator('input[name="email"]').fill(wrong_server_user.email); + await page.locator('input[name="password"]').fill(PASSWORd); + await page.locator('input[name="password_confirm"]').fill(PASSWORd); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + await screen(page, '/register/password'); + await expect(page.locator('input[name="email"]')).toHaveValue(wrong_server_user.email); + + //check that error message is visible + await expect(page.locator('div.cpd-form-message.cpd-form-error-message').filter({ hasText: 'Votre adresse mail est associée à un autre serveur' })).toBeVisible(); }); }); diff --git a/playwright/utils/auth-helpers.ts b/playwright/utils/auth-helpers.ts index 7ac0648..a058622 100644 --- a/playwright/utils/auth-helpers.ts +++ b/playwright/utils/auth-helpers.ts @@ -9,9 +9,9 @@ import { ScreenCheckerFixture } from '../fixtures/auth-fixture'; * Test user type */ export interface TestUser { - kc_username: string; - kc_email: string; - kc_password: string; + username: string; + email: string; + password: string; keycloakId?: string; masId?: string; } @@ -30,7 +30,7 @@ export enum TypeUser { * Create a test user in Keycloak */ export async function createKeycloakTestUser(user:TestUser): Promise { - const keycloakId = await createKeycloakUser(user.kc_username, user.kc_email, user.kc_password); + const keycloakId = await createKeycloakUser(user.username, user.email, user.password); return { ...user, keycloakId }; } @@ -72,8 +72,8 @@ export async function performOidcLogin(page: Page, user: TestUser, screenshot_pa await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-keycloak-login.png` }); // Fill in the username and password - await page.locator('#username').fill(user.kc_username); - await page.locator('#password').fill(user.kc_password); + await page.locator('#username').fill(user.username); + await page.locator('#password').fill(user.password); // Click the login button await page.locator('button[type="submit"]').click(); @@ -97,7 +97,7 @@ export async function performOidcLoginFromTchap(page: Page, user: TestUser, scre await page.getByRole('link').filter({hasText : "Se connecter par email"}).click(); - await page.locator('input').fill(user.kc_email); + await page.locator('input').fill(user.email); // Click on "Continuer" button await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); @@ -121,8 +121,8 @@ export async function performOidcLoginFromTchap(page: Page, user: TestUser, scre await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-keycloak-login.png` }); // Fill in the username and password - await page.locator('#username').fill(user.kc_username); - await page.locator('#password').fill(user.kc_password); + await page.locator('#username').fill(user.username); + await page.locator('#password').fill(user.password); // Click the login button await page.locator('button[type="submit"]').click(); @@ -134,69 +134,11 @@ export async function performOidcLoginFromTchap(page: Page, user: TestUser, scre await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-after-login.png` }); } - - -/** - * Perform OIDC login starting from Element client - * This function handles the entire authentication flow: - * 1. Navigate to Element login page - * 2. Click on "Continuer" button - * 3. Get redirected to MAS login page - * 4. Continue with the standard OIDC flow - */ -export async function performOidcLoginFromElement(page: Page, user: TestUser, screenshot_path: string, tchap_legacy:boolean=false): Promise { - // Navigate to Element login page - await page.goto(`${ELEMENT_URL}/#/login`, { waitUntil: 'networkidle' }); - - // Take a screenshot of the Element login page - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-element-login-page.png` }); - - if(tchap_legacy){ - // Click on "Continuer" button - await page.getByRole('button').filter({ hasText: 'Créez un compte' }).click(); - }else{ - // Click on "Continuer" button - await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - } - - // Wait for navigation to MAS - await page.waitForURL(url => url.toString().includes(MAS_URL)); - - // Take a screenshot of the MAS login page - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-mas-login-page.png` }); - - // Find and click the OIDC provider button - //const oidcButton = page.locator('a.cpd-button[href*="/upstream/authorize/"]'); - const oidcButton = page.locator('button.proconnect-button'); - - await oidcButton.click(); - - // Wait for navigation to Keycloak - await page.waitForURL(url => url.toString().includes(KEYCLOAK_URL)); - - // Take a screenshot of the Keycloak login page - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-keycloak-login.png` }); - - // Fill in the username and password - await page.locator('#username').fill(user.kc_username); - await page.locator('#password').fill(user.kc_password); - - // Click the login button - await page.locator('button[type="submit"]').click(); - - // Wait for redirect back to MAS - await page.waitForURL(url => url.toString().includes(MAS_URL)); - - // Take a screenshot after successful login - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-after-login.png` }); -} - - /** * Verify that a user was created in MAS after OIDC authentication */ export async function verifyUserInMas(user: TestUser): Promise { - const masUser = await waitForMasUser(user.kc_email); + const masUser = await waitForMasUser(user.email); user.masId = masUser.id; } @@ -204,8 +146,8 @@ export async function verifyUserInMas(user: TestUser): Promise { * Create a test user directly in MAS with password */ export async function createMasTestUser(domain:string): Promise { - const user = generateTestUser(domain); - const masId = await createMasUserWithPassword(user.kc_username, user.kc_email, user.kc_password); + const user = generateTestUserData(domain); + const masId = await createMasUserWithPassword(user.username, user.email, user.password); return { ...user, masId }; } @@ -227,7 +169,7 @@ export async function cleanupMasTestUser(user: TestUser): Promise { * 4. Wait for successful authentication */ export async function performPasswordLogin(page: Page, user: TestUser, screenshot_path:string): Promise { - console.log(`[Auth] Performing password login for user: ${user.kc_username}`); + console.log(`[Auth] Performing password login for user: ${user.username}`); // Navigate to the login page await page.goto('/login'); @@ -236,8 +178,8 @@ export async function performPasswordLogin(page: Page, user: TestUser, screensho await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-password-login-page.png` }); // Fill in the username and password - await page.locator('input[name="username"]').fill(user.kc_username); - await page.locator('input[name="password"]').fill(user.kc_password); + await page.locator('input[name="username"]').fill(user.username); + await page.locator('input[name="password"]').fill(user.password); // Take a screenshot before submitting await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-password-login-filled.png` }); @@ -251,7 +193,7 @@ export async function performPasswordLogin(page: Page, user: TestUser, screensho // Take a screenshot after successful login await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-password-login-success.png` }); - console.log(`[Auth] Password login successful for user: ${user.kc_username}`); + console.log(`[Auth] Password login successful for user: ${user.username}`); } @@ -297,14 +239,14 @@ export async function performSimplePasswordLogin( user: TestUser, screenshot_path: string ): Promise { - console.log(`[Auth] Performing password login for user: ${user.kc_username}`); + console.log(`[Auth] Performing password login for user: ${user.username}`); // Navigate to the login page await page.goto("/login"); // Fill in the username and password - await page.locator('input[name="username"]').fill(user.kc_username); - await page.locator('input[name="password"]').fill(user.kc_password); + await page.locator('input[name="username"]').fill(user.username); + await page.locator('input[name="password"]').fill(user.password); // Click the login button (submit the form) await page.locator('button[type="submit"]').click(); @@ -312,20 +254,20 @@ export async function performSimplePasswordLogin( // Wait for successful login (redirect to dashboard or home page) await page.waitForURL((url) => !url.toString().includes("/login")); - console.log(`[Auth] Password login successful for user: ${user.kc_username}`); + console.log(`[Auth] Password login successful for user: ${user.username}`); } // Generate a unique username and email for testing -export function generateTestUser(domain:string) { +export function generateTestUserData(domain:string) { const timestamp = new Date().getTime(); const randomSuffix = Math.floor(Math.random() * 10000); const kc_username = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}`; const kc_email = `${kc_username}+1@${domain}`; return { - kc_username: kc_username, - kc_email: kc_email, - kc_password: TEST_USER_PASSWORD + username: kc_username, + email: kc_email, + password: TEST_USER_PASSWORD }; } From c6fa422b5267a540cd3936175c9f0b6c9627de43 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Thu, 13 Nov 2025 14:01:08 +0100 Subject: [PATCH 38/71] test(register): add password complexity and hints) for react password components * change password form names * add test with no javascript * use new field name * add tests for register --- playwright/fixtures/auth-fixture.ts | 16 ++++---- playwright/playwright.config.ts | 24 ++++++----- .../tests/auth/register-password.spec.ts | 41 ++++++++++++++++++- .../auth/tchap-register-password.spec.ts | 20 +++++++-- playwright/utils/auth-helpers.ts | 7 +++- tools/pre-commit-check.sh | 10 ----- 6 files changed, 85 insertions(+), 33 deletions(-) delete mode 100644 tools/pre-commit-check.sh diff --git a/playwright/fixtures/auth-fixture.ts b/playwright/fixtures/auth-fixture.ts index 2d88b6a..d058c1f 100644 --- a/playwright/fixtures/auth-fixture.ts +++ b/playwright/fixtures/auth-fixture.ts @@ -1,4 +1,4 @@ -import { test as base, Page, TestInfo } from "@playwright/test"; +import { test as base, Browser, Page, TestInfo } from "@playwright/test"; import { createKeycloakTestUser, cleanupKeycloakTestUser, @@ -71,7 +71,7 @@ function createTestUserFixture(domain: string) { function createLegacyUserFixture(domain: string) { return async ({}, use: (user: TestUser) => Promise) => { try { - const randomSuffix = Math.floor(Math.random() * 10000); + const randomSuffix = Math.floor(Math.random() * 10000000); const testUser: TestUser = { username: `test.user${randomSuffix}-${domain}`, @@ -133,15 +133,17 @@ export const test = base.extend<{ const screenshotPath = path.join(SCREENSHOTS_DIR, testInfo.title.replace(/\s+/g, '_')); let counter = 1; - + if (fs.existsSync(screenshotPath)) { fs.rmSync(screenshotPath, { recursive: true, force: true }); } fs.mkdirSync(screenshotPath, { recursive: true }); - + const screenChecker = async (page: Page, urlFragment: string) => { - await page.waitForURL((url) => url.toString().includes(urlFragment)); - const filename = `${counter.toString().padStart(2, '0')}-${urlFragment.replace(/[^\w]/g, '_')}.png`; + const browserName = page.context().browser()?.browserType().name(); + + await page.waitForURL((url) => url.toString().includes(urlFragment), {waitUntil:"load"}); + const filename = `${browserName}_${counter.toString().padStart(2, '0')}-${urlFragment.replace(/[^\w]/g, '_')}.png`; await page.screenshot({ path: path.join(screenshotPath, filename), fullPage:true }); counter++; }; @@ -150,7 +152,7 @@ export const test = base.extend<{ }, startTchapRegisterWithEmail: async ({ screenChecker }, use) => { const start = async (page: Page, email: string) => { - await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); + await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'load' }); await screenChecker(page, '#/welcome'); await page.getByRole('link').filter({ hasText: 'Créer un compte' }).click(); diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts index eeeb0bc..13a1d86 100644 --- a/playwright/playwright.config.ts +++ b/playwright/playwright.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ /* Define how many workers */ // Limit the number of workers on CI, use default locally - workers: process.env.CI ? 2 : undefined, + workers: process.env.CI ? 2 : 10, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, @@ -48,17 +48,21 @@ export default defineConfig({ /* Configure projects for major browsers */ projects: [ + /* e2e tests do not work well on firefox nor webkit (bit flaky) + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, { + name: 'webkit', + use: { ...devices['Desktop Edge'] }, + }, + */ + { name: 'chromium', use: { ...devices['Desktop Chrome'] }, - }, - // { - // name: 'firefox', - // use: { ...devices['Desktop Firefox'] }, - // }, - // { - // name: 'webkit', - // use: { ...devices['Desktop Safari'] }, - // }, + }, + + ], }); diff --git a/playwright/tests/auth/register-password.spec.ts b/playwright/tests/auth/register-password.spec.ts index 3d94c50..bac9220 100644 --- a/playwright/tests/auth/register-password.spec.ts +++ b/playwright/tests/auth/register-password.spec.ts @@ -3,7 +3,9 @@ import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from '../../utils/config'; test.describe('Register', () => { - + + const PASSWORd = "sdf78qsd!9090ssss"; + test.skip('without oauth2 session', async ({ context, page, simpleUser: user, screenChecker: screen }) => { // This test is intentionally ignored. //the error page has been deactivated for the moment because the MAS unit tests must be fixed @@ -17,4 +19,41 @@ test.describe('Register', () => { await expect(page.locator("p", {hasText:"Veuillez fermer cette fenêtre et relancer la création de compte depuis votre appareil Tchap"})).toBeVisible(); }); + + test('tchap register with JavaScript disabled', async ({ browser, simpleUser: user, screenChecker: screen }) => { + + const ctx = await browser.newContext({ javaScriptEnabled: false }); + const page = await ctx.newPage(); + + await page.goto('/register'); + await page.getByRole('button').filter({ hasText: 'Continuer avec une adresse mail' }).click(); + + await screen(page, '/register/password'); + await page.locator('input[name="email"]').fill(user.email); + await page.locator('input[name="password"]').fill(PASSWORd); + await page.locator('input[name="password_confirm"]').fill(PASSWORd); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + //form is submitted successfully + await screen(page, '/verify-email'); + }); + + test('tchap register when user already exists', async ({ browser, simpleUser: user, screenChecker: screen }) => { + + const ctx = await browser.newContext({ javaScriptEnabled: false }); + const page = await ctx.newPage(); + + await page.goto('/register'); + await page.getByRole('button').filter({ hasText: 'Continuer avec une adresse mail' }).click(); + + await screen(page, '/register/password'); + await page.locator('input[name="email"]').fill(user.email); + await page.locator('input[name="password"]').fill(PASSWORd); + await page.locator('input[name="password_confirm"]').fill(PASSWORd); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + //form is submitted successfully + await screen(page, '/verify-email'); + }); + }); diff --git a/playwright/tests/auth/tchap-register-password.spec.ts b/playwright/tests/auth/tchap-register-password.spec.ts index 7c5eb7d..562a430 100644 --- a/playwright/tests/auth/tchap-register-password.spec.ts +++ b/playwright/tests/auth/tchap-register-password.spec.ts @@ -18,8 +18,18 @@ test.describe('Tchap : register with password', () => { await screen(page, '/register/password'); await expect(page.locator('input[name="email"]')).toHaveValue(user.email); + await page.locator('input[name="password"]').fill(PASSWORd); await page.locator('input[name="password_confirm"]').fill(PASSWORd); + await page.locator("body").click({ position: { x: 0, y: 0 } }); + //await page.getByRole('generic').filter({ hasText: "Les mots de passe correspondent." }); + //await expect(page.locator('span')).toHaveValue("Les mots de passe correspondent."); + + //await page.keyboard.press('Enter'); + + + await page.getByRole('button').filter({ hasText: 'Continuer' }).click();//needs to focus out from the `password_confirm` field + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); await screen(page, '/verify-email'); @@ -30,7 +40,7 @@ test.describe('Tchap : register with password', () => { await screen(page, '/consent'); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - await screen(page, '#/home'); + //await screen(page, '#/home'); does not work with waitFor "networkidle" await expect(page.locator('h1').filter({ hasText: /Bienvenue.*\[Tchapgouv\]/ })).toBeVisible({ timeout: 20000 }); const created_user = await getMasUserByEmail(user.email); @@ -38,7 +48,7 @@ test.describe('Tchap : register with password', () => { expect(created_user.attributes.username).toContain(user.username); }); - test('tchap register with not invited email', async ({ context, page, simpleUser: user, screenChecker: screen, startTchapRegisterWithEmail }) => { + test('tchap register with not invited email', async ({page, simpleUser: user, screenChecker: screen, startTchapRegisterWithEmail }) => { await startTchapRegisterWithEmail(page, user.email); @@ -51,6 +61,7 @@ test.describe('Tchap : register with password', () => { await page.locator('input[name="email"]').fill(not_invited_user.email); await page.locator('input[name="password"]').fill(PASSWORd); await page.locator('input[name="password_confirm"]').fill(PASSWORd); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click();//needs to focus out from the `password_confirm` field await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); await screen(page, '/register/password'); @@ -60,7 +71,8 @@ test.describe('Tchap : register with password', () => { await expect(page.locator('div.cpd-form-message.cpd-form-error-message').filter({ hasText: 'Vous avez besoin d\'une invitation' })).toBeVisible(); }); - test('tchap register with email on wrong server', async ({ context, page, simpleUser: user, screenChecker: screen, startTchapRegisterWithEmail }) => { + //skip because flakky + test.skip('tchap register with email on wrong server', async ({page, simpleUser: user, screenChecker: screen, startTchapRegisterWithEmail }) => { await startTchapRegisterWithEmail(page, user.email); @@ -73,7 +85,7 @@ test.describe('Tchap : register with password', () => { await page.locator('input[name="email"]').fill(wrong_server_user.email); await page.locator('input[name="password"]').fill(PASSWORd); await page.locator('input[name="password_confirm"]').fill(PASSWORd); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click();//needs to focus out from the `password_confirm` field await screen(page, '/register/password'); await expect(page.locator('input[name="email"]')).toHaveValue(wrong_server_user.email); diff --git a/playwright/utils/auth-helpers.ts b/playwright/utils/auth-helpers.ts index a058622..e102aee 100644 --- a/playwright/utils/auth-helpers.ts +++ b/playwright/utils/auth-helpers.ts @@ -262,8 +262,10 @@ export function generateTestUserData(domain:string) { const timestamp = new Date().getTime(); const randomSuffix = Math.floor(Math.random() * 10000); const kc_username = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}`; - const kc_email = `${kc_username}+1@${domain}`; + const kc_email = `${kc_username}@${domain}`; + console.log("Using kc_email: ", kc_email); + return { username: kc_username, email: kc_email, @@ -278,6 +280,9 @@ export function generateExternTestUser() { const username = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}`; const email = `${username}@tchapgouv.com`; + console.log("Using email: ", email); + + return { username, email, diff --git a/tools/pre-commit-check.sh b/tools/pre-commit-check.sh deleted file mode 100644 index 9cf798d..0000000 --- a/tools/pre-commit-check.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh - -set -eu -# cd .. -# ./misc/update.sh -# cargo +nightly fmt -# export DATABASE_URL=postgresql://postgres:postgres@localhost/postgres -# cargo test --workspace -# unset DATABASE_URL -# cargo clippy --workspace --tests --bins --lib -- -D warnings \ No newline at end of file From 76ed26e31c33824d379f26a753f5a3ad2d95f476 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Fri, 21 Nov 2025 11:31:09 +0100 Subject: [PATCH 39/71] Chore(fixtures) cleansing fixtures and tests naming (#17) * remove unused files * harmonize tests naming * remove unused scripts * move fixtures to dedicated functions * rename userTest fixtures --- .env.sample | 20 -- .github/README.md | 1 - conf/config.template.yaml | 251 ------------------ docker-compose.yml.deprecated | 66 ----- playwright/.env.element | 33 --- playwright/.env.mas01 | 29 -- playwright/.env.mas02 | 29 -- playwright/.env.mas03 | 29 -- playwright/.env.pr1226 | 32 --- playwright/fixtures/auth-fixture.ts | 186 ++++++------- playwright/package.json | 6 +- ...in-oidc.spec.ts => mas-login-oidc.spec.ts} | 12 +- ...ord.spec.ts => mas-login-password.spec.ts} | 4 +- ...oidc.spec.ts => mas-register-oidc.spec.ts} | 46 ++-- ....spec.ts => mas-register-password.spec.ts} | 8 +- .../tests/auth/tchap-login-oidc.spec.ts | 2 +- .../tests/auth/tchap-login-password.spec.ts | 2 +- .../auth/tchap-register-password.spec.ts | 7 +- start-local-mas-hot-reload.sh.deprecated | 83 ------ start-local-mas.sh.deprecated | 39 --- start-local-stack.sh.deprecated | 33 --- tools/README_TCHAP.md | 58 ---- tools/build_conf.sh | 45 ---- tools/debug_test.sh | 68 ----- tools/keycloak/init-keycloak-db.sql | 1 - tools/keycloak/proconnect-mock-realm.json | 208 --------------- tools/migration.sh | 13 - tools/test-identity-mock.sh | 63 ----- tools/test_admin.sh | 20 -- .../mappings/invited-mapping-internal.json | 28 -- wiremock/mappings/invited-mapping.json | 26 -- .../not-invited-mapping-internal.json | 28 -- wiremock/mappings/not-invited-mapping.json | 26 -- wiremock/mappings/user-mapping-internal.json | 28 -- wiremock/mappings/user-mapping.json | 26 -- .../mappings/wrong-hs-mapping-internal.json | 28 -- wiremock/mappings/wrong-hs-mapping.json | 26 -- 37 files changed, 138 insertions(+), 1472 deletions(-) delete mode 100644 .env.sample delete mode 100644 .github/README.md delete mode 100644 conf/config.template.yaml delete mode 100644 docker-compose.yml.deprecated delete mode 100644 playwright/.env.element delete mode 100644 playwright/.env.mas01 delete mode 100644 playwright/.env.mas02 delete mode 100644 playwright/.env.mas03 delete mode 100644 playwright/.env.pr1226 rename playwright/tests/auth/{login-oidc.spec.ts => mas-login-oidc.spec.ts} (96%) rename playwright/tests/auth/{login-password.spec.ts => mas-login-password.spec.ts} (92%) rename playwright/tests/auth/{register-oidc.spec.ts => mas-register-oidc.spec.ts} (65%) rename playwright/tests/auth/{register-password.spec.ts => mas-register-password.spec.ts} (85%) delete mode 100755 start-local-mas-hot-reload.sh.deprecated delete mode 100755 start-local-mas.sh.deprecated delete mode 100755 start-local-stack.sh.deprecated delete mode 100644 tools/README_TCHAP.md delete mode 100755 tools/build_conf.sh delete mode 100755 tools/debug_test.sh delete mode 100644 tools/keycloak/init-keycloak-db.sql delete mode 100644 tools/keycloak/proconnect-mock-realm.json delete mode 100755 tools/migration.sh delete mode 100755 tools/test-identity-mock.sh delete mode 100755 tools/test_admin.sh delete mode 100644 wiremock/mappings/invited-mapping-internal.json delete mode 100644 wiremock/mappings/invited-mapping.json delete mode 100644 wiremock/mappings/not-invited-mapping-internal.json delete mode 100644 wiremock/mappings/not-invited-mapping.json delete mode 100644 wiremock/mappings/user-mapping-internal.json delete mode 100644 wiremock/mappings/user-mapping.json delete mode 100644 wiremock/mappings/wrong-hs-mapping-internal.json delete mode 100644 wiremock/mappings/wrong-hs-mapping.json diff --git a/.env.sample b/.env.sample deleted file mode 100644 index 17e4a11..0000000 --- a/.env.sample +++ /dev/null @@ -1,20 +0,0 @@ -### MAS CONFIG ### - - -### ENV DEV CONFIG ### - -# Path to the MAS project to run -MAS_HOME=path/to/matrix-authentication-service - -#wiremock port for mocking identity server (sydent) -IDENTITY_MOCK_PORT=8090 - -# postgres port -POSTGRES_PORT=5432 - -# keycloak config for mocking proconnect -KEYCLOAK_PORT=8082 -KEYCLOAK_HOSTNAME=https://sso.tchapgouv.com - -# listen to template updates -TEMPLATE_WATCH=./resources/templates \ No newline at end of file diff --git a/.github/README.md b/.github/README.md deleted file mode 100644 index e7a373c..0000000 --- a/.github/README.md +++ /dev/null @@ -1 +0,0 @@ -à reflechir \ No newline at end of file diff --git a/conf/config.template.yaml b/conf/config.template.yaml deleted file mode 100644 index d72208e..0000000 --- a/conf/config.template.yaml +++ /dev/null @@ -1,251 +0,0 @@ -http: - listeners: - - name: web - resources: - - name: discovery - - name: human - - name: oauth - - name: compat - - name: graphql - - name: assets - # for api admin calls - - name: adminapi - binds: - - address: '[::]:8080' - proxy_protocol: false - - name: internal - resources: - - name: health - # for api admin calls - - name: adminapi - binds: - - host: localhost - port: 8081 - proxy_protocol: false - trusted_proxies: - - 192.168.0.0/16 - - 172.16.0.0/12 - - 10.0.0.0/10 - - 127.0.0.1/8 - - fd00::/8 - - ::1/128 - # public_base: http://[::]:8080/ - # issuer: http://[::]:8080/ - public_base: https://auth.tchapgouv.com/ - issuer: https://auth.tchapgouv.com/ -database: - uri: postgresql://postgres:postgres@localhost/postgres - max_connections: 10 - min_connections: 0 - connect_timeout: 30 - idle_timeout: 600 - max_lifetime: 1800 -email: - from: '"Authentication Service" ' - reply_to: '"Authentication Service" ' - transport: smtp - mode: plain - hostname: 127.0.0.1 - port: 1025 - -secrets: - encryption: eb38b8b9087842b3345269f3c6ca92b2a8d6aa63e3a773d23ed1c9cb45c5ef83 - keys: - - kid: dyrZtIyXSA - key: | - -----BEGIN RSA PRIVATE KEY----- - MIIEogIBAAKCAQEAukLyWSv9KOeBsmIG4ntL/BP+wj5L4GbOyAOEzRBBO0ZbBYVn - 1L/aYQ65cQpQKK0MzH5cn7TvIY0JBh/ZsSm3e7DdhGJzoqPrW6E0/6QqXEl4gN1q - DUCMj2CwAcT9OX1Wt6cNq70+gbqp6+yT+Nn++KPylHa/V9wRxkaV3fyM+XYVeddB - amDR+fBjHgVXQ3xk2ezUBS3AmyKBgETnHufKCkxJ5mXdU9HT0ewg8J2PrgiRBwDj - XP7C5Zif/+NfYPO2mM/b0Y91pl9aXuUHX+/zlqtpcwX/WprEsCaRUa9qWHuiftMf - uelsflCgZ0KumZjzr3wPDEfu8n7WbVEObNxF+QIDAQABAoIBAEl82mNGUMbPuEMq - G+9FmDAnr27x5zvtNA6EHORPUn1Rf94IyXOOEloS1iV8XS3/QLp57I9ycpq5K2NI - M7qLbAIYQP3XXipAJD7ttpxaKABrWGj3cr0xx4NWMXsxPnttMUaaWXF14/CJNjuI - BsW7NLbi8HWU+F9wy26AMOb5mqFdQfk15H3LaM3L3hMuV5DOcA467luwHmuGGTji - VjZg3yZZF2ROtTwwVSB7UCokmW3FZys/U4SyzXSrboUxF9T3PW3Hxm9e1JfFQuLh - rn85Q4IDpRtK2O5ECmKjY0cyvQOVItQytTeVXtSEzgMfK5VpnYMefdCFvhExcsuV - JHT1c/UCgYEAziAPCJYNTXsvo3yEW1iWnAjfi6R7g9aluNDjf4xmva5DTOdZSH1d - AJkzXZtPYXXlR42RT4FzE/ICdjo81aDMrMHwoIiH9p1n7IdDTt3GONYi3VPKGvZd - Ghgdq5jgdedDhF9MximZcWdZOZLCymKME61RuCg18p/MMIJ/FRNBE38CgYEA51R6 - CjNc93qO7hL7AGu+evs3/PVrHsg5iBf8GcGeyNNGkA5TJcYTO075VBDQuWsMZbvx - d0jBjROs8U2AJzgbh6+N/rZACflk8W4LyD3Pw+1coIcckH4hmgN37vkh63qiGX8K - YVe8CrbGliBB2OccXsdJVDe0f45kte0eJh6cAocCgYAH9d7+wuTCoEZHtxBZgsNW - RVV0zCZlAg4mZBLVIzP4kVlSCAE/tm+4DTKZo9zd87KmH8aD3oj2NTt5G2isC2i8 - J0VGvd8aXBveW57y1cfI/CQejhTZE7imwFWtAdtxUjweSZvqb0LYyVf9zDgvnryw - KdplFVB4DUnSece0pai2uwKBgDzV335dQZ6nsXz0quPScfZ/qJqyo+gledPLkvXn - EG35+f2ads1hSN95BmLQRUPt3gXHJlpbXONQAFQ5MHGf9MV7KpmIrlCxMJW5fgm8 - D66T9p8UyTNKqGWLcff7tqrpxkV0PnOZEg+zP4htlUOIi9J1EFjAiYxeEygw4pPd - yuNzAoGAI0lLE32iIm+j5byFCKRuS6cQmDBzUJxZiCuLsuZEINYdz2nxKZqd9VtA - rOXzo8vJuGLF1hjf1/C66F3EnPxcclPL10vLCE8RbfQYVwBxw7MG9BLJXIlMjb2V - 7CjVDE4Gaa96tChg1pepuJcOEuWez3o3Ard9oZ4Z9sm7VJzmuoY= - -----END RSA PRIVATE KEY----- - - kid: O1hkajPW2v - key: | - -----BEGIN EC PRIVATE KEY----- - MHcCAQEEINsgMBFDIrIzqOIWuR94TCi5MTH1FS8wfgatu9BsO2jVoAoGCCqGSM49 - AwEHoUQDQgAEldEtvZaXtblpUdHpKKQiH7z9ADC55H0yrCYyQsLXbt14lI2NuseX - MWsvSLBzkbEetDxkmKh0bhOfrdwv9x5SwA== - -----END EC PRIVATE KEY----- - - kid: 2nhe3z2925 - key: | - -----BEGIN EC PRIVATE KEY----- - MIGkAgEBBDDTVngHypOwUnPOGXeskQJhdSLLPBCM+mkSvzr2SZ7Kjm3hftvs2s7J - gZBOZwXyoaKgBwYFK4EEACKhZANiAAQ3WGOQs3EqO2x4X7PBWs6Lw3qdmRLHqblc - Zplh3wYPDOoUMvD99Snxz43t5sK6kphLBL262/srx/UPT1McLUxBMlBvBUbBEKHX - a8icrL13yIwflquj0EHrE7czFJw1txs= - -----END EC PRIVATE KEY----- - - kid: 50aRR3QVqx - key: | - -----BEGIN EC PRIVATE KEY----- - MHQCAQEEIN8bErXY1sWEJ1y9KoYcpcUImIjpS/ay3pEugYPfr3Y/oAcGBSuBBAAK - oUQDQgAEMHcshHVFbMSEyyt3ptIdAhnrg+XlQskZ33hZvdtzm6I0wW8H8zslMp+I - t0KYCeIQ7HTPtgJAOsKxEPBfmVXZmA== - -----END EC PRIVATE KEY----- -passwords: - enabled: true - schemes: - - version: 1 - algorithm: bcrypt - secret: "secret01" - - version: 2 - algorithm: argon2id - minimum_complexity: 3 -matrix: - homeserver: tchapgouv.com - secret: '/DjWc4D3yyqgjYN8tum65g' - endpoint: https://matrix.tchapgouv.com/ - -clients: - - client_id: 0000000000000000000SYNAPSE - client_auth_method: client_secret_basic - client_secret: '/DjWc4D3yyqgjYN8tum65g' - - # for api admin calls - - client_id: 01J44RKQYM4G3TNVANTMTDYTX6 - client_auth_method: client_secret_basic - client_secret: phoo8ahneir3ohY2eigh4xuu6Oodaewi - - -policy: - data: - admin_clients: - # for api admin calls - - 01J44RKQYM4G3TNVANTMTDYTX6 - -account: - # Whether users are allowed to change their email addresses. - # - # Defaults to `true`. - email_change_allowed: false - - # Whether users are allowed to change their display names - # - # Defaults to `true`. - # This should be in sync with the policy in the homeserver configuration. - displayname_change_allowed: false - - # Whether to enable self-service password registration - # - # Defaults to `false`. - # This has no effect if password login is disabled. - password_registration_enabled: true - - # Whether users are allowed to change their passwords - # - # Defaults to `true`. - # This has no effect if password login is disabled. - password_change_allowed: true - - # Whether email-based password recovery is enabled - # - # Defaults to `false`. - # This has no effect if password login is disabled. - password_recovery_enabled: true - - # Whether users can log in with their email address. - # - # Defaults to `false`. - # This has no effect if password login is disabled. - login_with_email_allowed: true - -templates: - # From where to load the templates - # This is relative to the current working directory, *not* the config file - path: "WILL BE REPLACED BY build_conf.sh" - - # Path to the frontend assets manifest file - # assets_manifest: "/to/manifest.json" - - # # From where to load the translation files - # # Default in Docker distribution: `/usr/local/share/mas-cli/translations/` - # # Default in pre-built binaries: `./share/translations/` - # # Default in locally-built binaries: `./translations/` - translations_path: "WILL BE REPLACED BY build_conf.sh" - -upstream_oauth2: - providers: - - id: "01JK5MR1SD21MAQY4PWMFG283W" - human_name: Proconnect (mock) - issuer: "https://sso.tchapgouv.com/realms/proconnect-mock" - token_endpoint_auth_method: client_secret_basic - client_id: "matrix-authentication-service" - client_secret: "HrJ1NZ0AbkHuWWjyRHh7X2lzn3S8eagt" - scope: "openid profile email" - claims_imports: - localpart: - action: force - #template: "{{ user.preferred_username }}" - on_conflict: add - #action: require - template: "{{ user.email | email_to_mxid_localpart }}" - displayname: - action: require - #template: "{{ user.name }}" - template: "{{ user.email | email_to_display_name }}" - email: - action: require - template: "{{ user.email }}" - set_email_verification: always - -telemetry: - tracing: - # # List of propagators to use for extracting and injecting trace contexts - # propagators: - # # Propagate according to the W3C Trace Context specification - # - tracecontext - # # Propagate according to the W3C Baggage specification - # - baggage - # # Propagate trace context with Jaeger compatible headers - # - jaeger - - # # The default: don't export traces - exporter: none - - # Export traces to an OTLP-compatible endpoint - #exporter: otlp - #endpoint: https://localhost:4318 - metrics: - # The default: don't export metrics - exporter: none - - # Export metrics to an OTLP-compatible endpoint - #exporter: otlp - #endpoint: https://localhost:4317 - - # Export metrics by exposing a Prometheus endpoint - # This requires mounting the `prometheus` resource to an HTTP listener - #exporter: prometheus - - # sentry: - # # DSN to use for sending errors and crashes to Sentry - # dsn: https://public@host:port/1 - -tchap: - identity_server_url: "http://localhost:8083" - email_lookup_fallback_rules: - # match : the new email pattern - # search : the old email pattern - # old email in mail.numerique.gouv.fr - - match_with : '@numerique.gouv.fr' - search: '@beta.gouv.fr' \ No newline at end of file diff --git a/docker-compose.yml.deprecated b/docker-compose.yml.deprecated deleted file mode 100644 index 798a6c0..0000000 --- a/docker-compose.yml.deprecated +++ /dev/null @@ -1,66 +0,0 @@ -services: - mailcatcher: - image: sj26/mailcatcher - #network_mode: host - ports: - - "1025:1025" - - "1080:1080" - networks: - - tchap-network - - identity-mock: - image: wiremock/wiremock:2.35.0 - ports: - - "${IDENTITY_MOCK_PORT}:8080" - volumes: - - ./wiremock:/home/wiremock - command: - - --verbose - - --global-response-templating - networks: - - tchap-network - # This service mocks the Matrix Identity Server API - - postgres: - image: postgres - ports: - - "${POSTGRES_PORT}:5432" - environment: - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=postgres - - POSTGRES_DB=postgres - - PGDATA=/var/lib/postgresql/data - volumes: - - ./tmp/postgres:/var/lib/postgresql/data:rw - - ./tools/keycloak/init-keycloak-db.sql:/docker-entrypoint-initdb.d/init-keycloak-db.sql - networks: - - tchap-network - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 5s - timeout: 5s - retries: 50 - - keycloak: - image: quay.io/keycloak/keycloak:26.2 - ports: - - "${KEYCLOAK_PORT}:8080" - environment: - - KEYCLOAK_ADMIN=admin - - KEYCLOAK_ADMIN_PASSWORD=admin - - KC_DB=postgres - - KC_DB_URL=jdbc:postgresql://postgres:5432/keycloak - - KC_DB_USERNAME=postgres - - KC_DB_PASSWORD=postgres - volumes: - - ./tools/keycloak/proconnect-mock-realm.json:/opt/keycloak/data/import/proconnect-mock-realm.json - command: "start-dev --import-realm --hostname=${KEYCLOAK_HOSTNAME}" - networks: - - tchap-network - depends_on: - postgres: - condition: service_healthy - -networks: - tchap-network: - driver: bridge diff --git a/playwright/.env.element b/playwright/.env.element deleted file mode 100644 index ab83729..0000000 --- a/playwright/.env.element +++ /dev/null @@ -1,33 +0,0 @@ -# URLs -MAS_URL=https://auth.tchapgouv.com -KEYCLOAK_URL=https://sso.tchapgouv.com -ELEMENT_URL=https://app.element.io - -# Keycloak Admin Credentials -KEYCLOAK_ADMIN_USERNAME=admin -KEYCLOAK_ADMIN_PASSWORD=admin -KEYCLOAK_REALM=proconnect-mock - -# MAS Admin API Credentials -MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 -MAS_ADMIN_CLIENT_SECRET=phoo8ahneir3ohY2eigh4xuu6Oodaewi - -# Test User Credentials -TEST_USER_PREFIX=playwright_test_user -TEST_USER_PASSWORD=Test@123456 - -# Test User Credentials -TEST_USER_PREFIX=user.local -TEST_USER_PASSWORD=Test@123456 - -# Email domains for test users -STANDARD_EMAIL_DOMAIN=tchapgouv.com -INVITED_EMAIL_DOMAIN=invited.externe.com -NOT_INVITED_EMAIL_DOMAIN=not.invited.externe.com -WRONG_SERVER_EMAIL_DOMAIN=wrong.server.com - -# Browser Locale -BROWSER_LOCALE=fr-FR - -# Screenshots Directory -SCREENSHOTS_DIR=playwright-results/local diff --git a/playwright/.env.mas01 b/playwright/.env.mas01 deleted file mode 100644 index 3d890c5..0000000 --- a/playwright/.env.mas01 +++ /dev/null @@ -1,29 +0,0 @@ -# URLs -MAS_URL=https://auth.mas01.tchap.incubateur.net -KEYCLOAK_URL=https://tchap-connect.mas01.tchap.incubateur.net -ELEMENT_URL=https://www.mas01.tchap.incubateur.net - -# Keycloak Admin Credentials -KEYCLOAK_ADMIN_USERNAME=admin -KEYCLOAK_ADMIN_PASSWORD=admin -KEYCLOAK_REALM=proconnect-mock - -# MAS Admin API Credentials -MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 -MAS_ADMIN_CLIENT_SECRET=phoo8ahneir3ohY2eigh4xuu6Oodaewi - -# Test User Credentials -TEST_USER_PREFIX=user.mas -TEST_USER_PASSWORD=Test@123456 - -# Email domains for test users -STANDARD_EMAIL_DOMAIN=beta.gouv.fr -INVITED_EMAIL_DOMAIN=invited.yopmail.com -NOT_INVITED_EMAIL_DOMAIN=yopmail.com -WRONG_SERVER_EMAIL_DOMAIN=agent2.incubateur.net - -# Browser Locale -BROWSER_LOCALE=fr-FR - -# Screenshots Directory -SCREENSHOTS_DIR=playwright-results/mas01 diff --git a/playwright/.env.mas02 b/playwright/.env.mas02 deleted file mode 100644 index f82ef0c..0000000 --- a/playwright/.env.mas02 +++ /dev/null @@ -1,29 +0,0 @@ -# URLs -MAS_URL=https://auth.mas01.tchap.incubateur.net -KEYCLOAK_URL=https://tchap-connect.mas01.tchap.incubateur.net -ELEMENT_URL=https://www.mas02.tchap.incubateur.net - -# Keycloak Admin Credentials -KEYCLOAK_ADMIN_USERNAME=admin -KEYCLOAK_ADMIN_PASSWORD=admin -KEYCLOAK_REALM=proconnect-mock - -# MAS Admin API Credentials -MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 -MAS_ADMIN_CLIENT_SECRET=phoo8ahneir3ohY2eigh4xuu6Oodaewi - -# Test User Credentials -TEST_USER_PREFIX=user.mas -TEST_USER_PASSWORD=Test@123456 - -# Email domains for test users -STANDARD_EMAIL_DOMAIN=beta.gouv.fr -INVITED_EMAIL_DOMAIN=invited.yopmail.com -NOT_INVITED_EMAIL_DOMAIN=yopmail.com -WRONG_SERVER_EMAIL_DOMAIN=agent2.incubateur.net - -# Browser Locale -BROWSER_LOCALE=fr-FR - -# Screenshots Directory -SCREENSHOTS_DIR=playwright-results/mas02 diff --git a/playwright/.env.mas03 b/playwright/.env.mas03 deleted file mode 100644 index df5ecd6..0000000 --- a/playwright/.env.mas03 +++ /dev/null @@ -1,29 +0,0 @@ -# URLs -MAS_URL=https://auth.mas03.tchap.incubateur.net -KEYCLOAK_URL=https://tchap-connect.mas03.tchap.incubateur.net -ELEMENT_URL=https://www.mas03.tchap.incubateur.net - -# Keycloak Admin Credentials -KEYCLOAK_ADMIN_USERNAME=admin -KEYCLOAK_ADMIN_PASSWORD=admin -KEYCLOAK_REALM=proconnect-mock - -# MAS Admin API Credentials -MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 -MAS_ADMIN_CLIENT_SECRET=phoo8ahneir3ohY2eigh4xuu6Oodaewi - -# Test User Credentials -TEST_USER_PREFIX=user.mas -TEST_USER_PASSWORD=Test@123456 - -# Email domains for test users -STANDARD_EMAIL_DOMAIN=beta.gouv.fr -INVITED_EMAIL_DOMAIN=invited.yopmail.com -NOT_INVITED_EMAIL_DOMAIN=yopmail.com -WRONG_SERVER_EMAIL_DOMAIN=agent2.incubateur.net - -# Browser Locale -BROWSER_LOCALE=fr-FR - -# Screenshots Directory -SCREENSHOTS_DIR=playwright-results/mas03 diff --git a/playwright/.env.pr1226 b/playwright/.env.pr1226 deleted file mode 100644 index a44c480..0000000 --- a/playwright/.env.pr1226 +++ /dev/null @@ -1,32 +0,0 @@ -# URLs -MAS_URL=https://auth.mas01.tchap.incubateur.net -KEYCLOAK_URL=https://tchap-connect.mas01.tchap.incubateur.net -ELEMENT_URL=https://tchap-v4-preprod-pr1226.osc-secnum-fr1.scalingo.io/ - -# old login page flow -TCHAP_LEGACY = true - -# Keycloak Admin Credentials -KEYCLOAK_ADMIN_USERNAME=admin -KEYCLOAK_ADMIN_PASSWORD=admin -KEYCLOAK_REALM=proconnect-mock - -# MAS Admin API Credentials -MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 -MAS_ADMIN_CLIENT_SECRET=phoo8ahneir3ohY2eigh4xuu6Oodaewi - -# Test User Credentials -TEST_USER_PREFIX=user.mas -TEST_USER_PASSWORD=Test@123456 - -# Email domains for test users -STANDARD_EMAIL_DOMAIN=beta.gouv.fr -INVITED_EMAIL_DOMAIN=invited.yopmail.com -NOT_INVITED_EMAIL_DOMAIN=yopmail.com -WRONG_SERVER_EMAIL_DOMAIN=agent2.incubateur.net - -# Browser Locale -BROWSER_LOCALE=fr-FR - -# Screenshots Directory -SCREENSHOTS_DIR=playwright-results/mas01 diff --git a/playwright/fixtures/auth-fixture.ts b/playwright/fixtures/auth-fixture.ts index d058c1f..b8c44b5 100644 --- a/playwright/fixtures/auth-fixture.ts +++ b/playwright/fixtures/auth-fixture.ts @@ -24,15 +24,14 @@ import { } from "../utils/config"; import { ClientServerApi, Credentials } from "../utils/api"; - -function generateSimpleUserFixture(domain: string) { +function generateUserData(domain: string) { return async ({}, use: (user: TestUser) => Promise) => { try { const user = generateTestUserData(domain); - + // Use the test user in the test await use(user); - + } finally { // Dispose API contexts await Promise.all([ @@ -44,7 +43,7 @@ function generateSimpleUserFixture(domain: string) { /** * Function to create a test user fixture with a specific domain */ -function createTestUserFixture(domain: string) { +function createKeycloakUserFixture(domain: string) { return async ({}, use: (user: TestUser) => Promise) => { try { const testUser = generateTestUserData(domain); @@ -68,7 +67,8 @@ function createTestUserFixture(domain: string) { //legacy users have a username derived from email : //email : username@domain.com -> username : username-domain.com -function createLegacyUserFixture(domain: string) { +//todo : do we need this and createKeycloakUserFixture? +function createKeycloakLegacyUserFixture(domain: string) { return async ({}, use: (user: TestUser) => Promise) => { try { const randomSuffix = Math.floor(Math.random() * 10000000); @@ -96,105 +96,111 @@ function createLegacyUserFixture(domain: string) { }; } - export type ScreenCheckerFixture = (page: Page, urlFragment: string) => Promise; export type StartTchapRegisterWithEmailFixture = (page: Page, email: string) => Promise; +export type AuthenticatedUserFixture = (page: Page, user: TestUser, request: any) => Promise; + +async function screenCheckerFixture({}: {}, use: (screenChecker: ScreenCheckerFixture) => Promise, testInfo: TestInfo) { + //this fixture clean up the screenshot folder before the tests + //and exposes a method to capture a screenshot from an waited url + + const screenshotPath = path.join(SCREENSHOTS_DIR, testInfo.title.replace(/\s+/g, '_')); + let counter = 1; + + if (fs.existsSync(screenshotPath)) { + fs.rmSync(screenshotPath, { recursive: true, force: true }); + } + fs.mkdirSync(screenshotPath, { recursive: true }); + + const screenChecker = async (page: Page, urlFragment: string) => { + const browserName = page.context().browser()?.browserType().name(); + + await page.waitForURL((url) => url.toString().includes(urlFragment), {waitUntil:"load"}); + const filename = `${browserName}_${counter.toString().padStart(2, '0')}-${urlFragment.replace(/[^\w]/g, '_')}.png`; + await page.screenshot({ path: path.join(screenshotPath, filename), fullPage:true }); + counter++; + }; + + await use(screenChecker); +} + +async function startTchapRegisterWithEmailFixture({ screenChecker }: { screenChecker: ScreenCheckerFixture }, use: (start: StartTchapRegisterWithEmailFixture) => Promise) { + const start = async (page: Page, email: string) => { + await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'load' }); + await screenChecker(page, '#/welcome'); + await page.getByRole('link').filter({ hasText: 'Créer un compte' }).click(); + + await screenChecker(page, '#/email-precheck-sso'); + await page.locator('input').fill(email); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + await screenChecker(page, '/register'); + await page.getByRole('button').filter({ hasText: 'Continuer avec une adresse mail' }).click(); + }; + await use(start); +} + +async function authenticatedUserFixture({ page, userData: user, request }: { page: Page, userData: TestUser, request: any }, use: (credentials: Credentials) => Promise) { + // 1. Register user + const userId = await createMasUserWithPassword( + user.username, + user.email, + user.password + ); + const csAPI = new ClientServerApi(BASE_URL, request); + + await waitForMasUser(user.email); + + const credentials = (await csAPI.loginUser( + user.username, + user.password + )) as Credentials; + + // 2. Populate localStorage + await populateLocalStorageWithCredentials(page, credentials); + + // 3. Load app + await page.goto(ELEMENT_URL); + await page.waitForSelector(".mx_MatrixChat", { timeout: 20000 }); + + // 4. Pass page to test + await use(credentials); + + // Clean up, deactivate user + await deactivateMasUser(userId); + console.log(`Cleaned up MAS user: ${user.username}`); +} /** * Extend the basic test fixtures with our authentication fixtures */ export const test = base.extend<{ - simpleUser: TestUser, - testUser: TestUser; - testExternalUserWithInvit: TestUser; - testExternalUserWitoutInvit: TestUser; - testUserOnWrongServer: TestUser; - userLegacy: TestUser; - userLegacyWithFallbackRules: TestUser; + userData: TestUser, + oidcUser: TestUser; + oidcExternalUserWithInvit: TestUser; + oidcExternalUserWitoutInvit: TestUser; + oidcUserOnWrongServer: TestUser; + oidcUserLegacy: TestUser;//pas clair le oidc***Legacy + oidcUserLegacyWithFallbackRules: TestUser; authenticatedUser: Credentials; typeUser: TypeUser; - screenChecker :ScreenCheckerFixture; + screenChecker: ScreenCheckerFixture; startTchapRegisterWithEmail: StartTchapRegisterWithEmailFixture; }>({ /** * Create a test user in Keycloak before the test and clean it up after */ - simpleUser: generateSimpleUserFixture(STANDARD_EMAIL_DOMAIN), - testUser: createTestUserFixture(STANDARD_EMAIL_DOMAIN), - testExternalUserWithInvit: createTestUserFixture(INVITED_EMAIL_DOMAIN), - testExternalUserWitoutInvit: createTestUserFixture(NOT_INVITED_EMAIL_DOMAIN), - testUserOnWrongServer: createTestUserFixture(WRONG_SERVER_EMAIL_DOMAIN), - userLegacy: createLegacyUserFixture(STANDARD_EMAIL_DOMAIN), - userLegacyWithFallbackRules: createLegacyUserFixture(NUMERIQUE_EMAIL_DOMAIN), + userData: generateUserData(STANDARD_EMAIL_DOMAIN), + oidcUser: createKeycloakUserFixture(STANDARD_EMAIL_DOMAIN), + oidcExternalUserWithInvit: createKeycloakUserFixture(INVITED_EMAIL_DOMAIN), + oidcExternalUserWitoutInvit: createKeycloakUserFixture(NOT_INVITED_EMAIL_DOMAIN), + oidcUserOnWrongServer: createKeycloakUserFixture(WRONG_SERVER_EMAIL_DOMAIN), + oidcUserLegacy: createKeycloakLegacyUserFixture(STANDARD_EMAIL_DOMAIN), + oidcUserLegacyWithFallbackRules: createKeycloakLegacyUserFixture(NUMERIQUE_EMAIL_DOMAIN), + authenticatedUser: authenticatedUserFixture, typeUser: TypeUser.MAS_PASSWORD_USER, - screenChecker: async ({}, use, testInfo: TestInfo) => { - //this fixture clean up the screenshot folder before the tests - //and exposes a method to capture a screenshot from an waited url - - const screenshotPath = path.join(SCREENSHOTS_DIR, testInfo.title.replace(/\s+/g, '_')); - let counter = 1; - - if (fs.existsSync(screenshotPath)) { - fs.rmSync(screenshotPath, { recursive: true, force: true }); - } - fs.mkdirSync(screenshotPath, { recursive: true }); - - const screenChecker = async (page: Page, urlFragment: string) => { - const browserName = page.context().browser()?.browserType().name(); - - await page.waitForURL((url) => url.toString().includes(urlFragment), {waitUntil:"load"}); - const filename = `${browserName}_${counter.toString().padStart(2, '0')}-${urlFragment.replace(/[^\w]/g, '_')}.png`; - await page.screenshot({ path: path.join(screenshotPath, filename), fullPage:true }); - counter++; - }; - - await use(screenChecker); - }, - startTchapRegisterWithEmail: async ({ screenChecker }, use) => { - const start = async (page: Page, email: string) => { - await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'load' }); - await screenChecker(page, '#/welcome'); - await page.getByRole('link').filter({ hasText: 'Créer un compte' }).click(); - - await screenChecker(page, '#/email-precheck-sso'); - await page.locator('input').fill(email); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - - await screenChecker(page, '/register'); - await page.getByRole('button').filter({ hasText: 'Continuer avec une adresse mail' }).click(); - }; - await use(start); - }, - authenticatedUser: async ({ page, testUser: user, request }, use) => { - // 1. Register user - const userId = await createMasUserWithPassword( - user.username, - user.email, - user.password - ); - const csAPI = new ClientServerApi(BASE_URL, request); - - await waitForMasUser(user.email); - - const credentials = (await csAPI.loginUser( - user.username, - user.password - )) as Credentials; - - // 2. Populate localStorage - await populateLocalStorageWithCredentials(page, credentials); - - // 3. Load app - await page.goto(ELEMENT_URL); - await page.waitForSelector(".mx_MatrixChat", { timeout: 20000 }); - - // 4. Pass page to test - await use(credentials); - - // Clean up, deactivate user - await deactivateMasUser(userId); - console.log(`Cleaned up MAS user: ${user.username}`); - }, + screenChecker: screenCheckerFixture, + startTchapRegisterWithEmail: startTchapRegisterWithEmailFixture, }); export { expect } from "@playwright/test"; diff --git a/playwright/package.json b/playwright/package.json index 61a6b65..bcdcca3 100644 --- a/playwright/package.json +++ b/playwright/package.json @@ -8,11 +8,7 @@ "test:headed": "playwright test --headed", "test:debug": "playwright test --debug", "test:ui": "playwright test --ui", - "test:local": "TEST_ENV=local playwright test", - "test:mas01": "TEST_ENV=mas01 playwright test", - "test:mas02": "TEST_ENV=mas02 playwright test", - "test:mas03": "TEST_ENV=mas03 playwright test", - "test:pr1226": "TEST_ENV=pr1226 playwright test" + "test:local": "TEST_ENV=local playwright test" }, "keywords": [ "playwright", diff --git a/playwright/tests/auth/login-oidc.spec.ts b/playwright/tests/auth/mas-login-oidc.spec.ts similarity index 96% rename from playwright/tests/auth/login-oidc.spec.ts rename to playwright/tests/auth/mas-login-oidc.spec.ts index f40fb1b..d931bbf 100644 --- a/playwright/tests/auth/login-oidc.spec.ts +++ b/playwright/tests/auth/mas-login-oidc.spec.ts @@ -5,9 +5,9 @@ import { import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser,oauthLinkExistsByUserId, oauthLinkExistsBySubject, getOauthLinkBySubject, deleteOauthLink, reactivateMasUser, addUserEmail } from '../../utils/mas-admin'; import { SCREENSHOTS_DIR } from '../../utils/config'; -test.describe('Login via OIDC', () => { +test.describe('MAS Login OIDC', () => { - test('match account by username', async ({ page, userLegacy: userLegacy }) => { + test('match account by username', async ({ page, oidcUserLegacy: userLegacy }) => { const screenshot_path = test.info().title.replace(" ", "_"); // Create a user in MAS with the same email as the Keycloak user @@ -43,7 +43,7 @@ test.describe('Login via OIDC', () => { }); - test('match account by email', async ({ page, userLegacy: userLegacy }) => { + test('match account by email', async ({ page, oidcUserLegacy: userLegacy }) => { const screenshot_path = test.info().title.replace(" ", "_"); // Create a user in MAS with the same email as the Keycloak user @@ -81,7 +81,7 @@ test.describe('Login via OIDC', () => { }); - test('match account by email with fallback rules', async ({ page, userLegacyWithFallbackRules: userLegacy }) => { + test('match account by email with fallback rules', async ({ page, oidcUserLegacyWithFallbackRules: userLegacy }) => { const screenshot_path = test.info().title.replace(" ", "_"); const old_email_domain = "@beta.gouv.fr"; @@ -122,7 +122,7 @@ test.describe('Login via OIDC', () => { }); - test('match account by email when former account is deactivated but another one is valid', async ({browser, context, page, userLegacy: userLegacy }) => { + test('match account by email when former account is deactivated but another one is valid', async ({browser, context, page, oidcUserLegacy: userLegacy }) => { const screenshot_path = test.info().title.replace(" ", "_"); // Create a user in MAS with the same email as the Keycloak user @@ -171,7 +171,7 @@ test.describe('Login via OIDC', () => { }); - test('match account by email when account was deactivated but is reactivated by support', async ({browser, context, page, userLegacy: userLegacy }) => { + test('match account by email when account was deactivated but is reactivated by support', async ({browser, context, page, oidcUserLegacy: userLegacy }) => { const screenshot_path = test.info().title.replace(" ", "_"); // Create a user in MAS with the same email as the Keycloak user diff --git a/playwright/tests/auth/login-password.spec.ts b/playwright/tests/auth/mas-login-password.spec.ts similarity index 92% rename from playwright/tests/auth/login-password.spec.ts rename to playwright/tests/auth/mas-login-password.spec.ts index 569cdeb..b3faf72 100644 --- a/playwright/tests/auth/login-password.spec.ts +++ b/playwright/tests/auth/mas-login-password.spec.ts @@ -10,9 +10,9 @@ import { import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser } from '../../utils/mas-admin'; import { SCREENSHOTS_DIR } from '../../utils/config'; -test.describe('Login with password', () => { +test.describe('MAS login password', () => { - test('login with allowed account', async ({ page }) => { + test('with allowed account', async ({ page }) => { const screenshot_path = test.info().title.replace(" ", "_"); // Create a test user with a password in MAS diff --git a/playwright/tests/auth/register-oidc.spec.ts b/playwright/tests/auth/mas-register-oidc.spec.ts similarity index 65% rename from playwright/tests/auth/register-oidc.spec.ts rename to playwright/tests/auth/mas-register-oidc.spec.ts index adcc134..4d5a18d 100644 --- a/playwright/tests/auth/register-oidc.spec.ts +++ b/playwright/tests/auth/mas-register-oidc.spec.ts @@ -10,17 +10,17 @@ import { import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser } from '../../utils/mas-admin'; import { SCREENSHOTS_DIR } from '../../utils/config'; -test.describe('Register', () => { +test.describe('MAS register OIDC', () => { - test('register oidc with allowed account', async ({ page, testUser }) => { + test('with allowed account', async ({ page, oidcUser: oidcUser }) => { const screenshot_path = test.info().title.replace(" ", "_"); // Verify the test user doesn't exist in MAS yet - const existsBeforeLogin = await checkMasUserExistsByEmail(testUser.email); + const existsBeforeLogin = await checkMasUserExistsByEmail(oidcUser.email); expect(existsBeforeLogin).toBe(false); // Perform the OIDC login flow - await performOidcLogin(page, testUser,screenshot_path); + await performOidcLogin(page, oidcUser,screenshot_path); // Click the create account button await page.locator('button[type="submit"]').click(); @@ -33,24 +33,24 @@ test.describe('Register', () => { await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-authenticated.png` }); // Verify the user was created in MAS - await verifyUserInMas(testUser); + await verifyUserInMas(oidcUser); // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(testUser.email); + const existsAfterLogin = await checkMasUserExistsByEmail(oidcUser.email); expect(existsAfterLogin).toBe(true); - console.log(`Successfully authenticated and verified user ${testUser.username} (${testUser.email})`); + console.log(`Successfully authenticated and verified user ${oidcUser.username} (${oidcUser.email})`); }); - test('register oidc with extern without invit', async ({ page, testExternalUserWitoutInvit }) => { + test('with extern without invit', async ({ page, oidcExternalUserWitoutInvit: oidcExternalUserWitoutInvit }) => { const screenshot_path = test.info().title.replace(" ", "_"); // Verify the test user doesn't exist in MAS yet - const existsBeforeLogin = await checkMasUserExistsByEmail(testExternalUserWitoutInvit.email); + const existsBeforeLogin = await checkMasUserExistsByEmail(oidcExternalUserWitoutInvit.email); expect(existsBeforeLogin).toBe(false); // Perform the OIDC login flow - await performOidcLogin(page, testExternalUserWitoutInvit, screenshot_path); + await performOidcLogin(page, oidcExternalUserWitoutInvit, screenshot_path); // Get error await page.locator('text=invitation_missing'); @@ -60,21 +60,21 @@ test.describe('Register', () => { // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(testExternalUserWitoutInvit.email); + const existsAfterLogin = await checkMasUserExistsByEmail(oidcExternalUserWitoutInvit.email); expect(existsAfterLogin).toBe(false); - console.log(`Successfully authenticated and verified user ${testExternalUserWitoutInvit.username} (${testExternalUserWitoutInvit.email})`); + console.log(`Successfully authenticated and verified user ${oidcExternalUserWitoutInvit.username} (${oidcExternalUserWitoutInvit.email})`); }); - test('register oidc with extern with invit', async ({ page, testExternalUserWithInvit: testExternalUserWithInvit }) => { + test('with extern with invit', async ({ page, oidcExternalUserWithInvit: oidcExternalUserWithInvit }) => { const screenshot_path = test.info().title.replace(" ", "_"); // Verify the test user doesn't exist in MAS yet - const existsBeforeLogin = await checkMasUserExistsByEmail(testExternalUserWithInvit.email); + const existsBeforeLogin = await checkMasUserExistsByEmail(oidcExternalUserWithInvit.email); expect(existsBeforeLogin).toBe(false); // Perform the OIDC login flow - await performOidcLogin(page, testExternalUserWithInvit, screenshot_path); + await performOidcLogin(page, oidcExternalUserWithInvit, screenshot_path); // Click the create account button await page.locator('button[type="submit"]').click(); @@ -86,24 +86,24 @@ test.describe('Register', () => { await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-authenticated-external.png` }); // Verify the user was created in MAS - await verifyUserInMas(testExternalUserWithInvit); + await verifyUserInMas(oidcExternalUserWithInvit); // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(testExternalUserWithInvit.email); + const existsAfterLogin = await checkMasUserExistsByEmail(oidcExternalUserWithInvit.email); expect(existsAfterLogin).toBe(true); - console.log(`Successfully authenticated and verified external user ${testExternalUserWithInvit.username} (${testExternalUserWithInvit.email})`); + console.log(`Successfully authenticated and verified external user ${oidcExternalUserWithInvit.username} (${oidcExternalUserWithInvit.email})`); }); - test('register oidc on wrong homeserver', async ({ page, testUserOnWrongServer }) => { + test('on wrong homeserver', async ({ page, oidcUserOnWrongServer: oidcUserOnWrongServer }) => { const screenshot_path = test.info().title.replace(" ", "_"); // Verify the test user doesn't exist in MAS yet - const existsBeforeLogin = await checkMasUserExistsByEmail(testUserOnWrongServer.email); + const existsBeforeLogin = await checkMasUserExistsByEmail(oidcUserOnWrongServer.email); expect(existsBeforeLogin).toBe(false); // Perform the OIDC login flow - await performOidcLogin(page, testUserOnWrongServer, screenshot_path); + await performOidcLogin(page, oidcUserOnWrongServer, screenshot_path); // Get error await page.locator('text=wrong_server'); @@ -112,10 +112,10 @@ test.describe('Register', () => { await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-error-wrong-server.png` }); // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(testUserOnWrongServer.email); + const existsAfterLogin = await checkMasUserExistsByEmail(oidcUserOnWrongServer.email); expect(existsAfterLogin).toBe(false); - console.log(`Successfully authenticated and verified user ${testUserOnWrongServer.username} (${testUserOnWrongServer.email})`); + console.log(`Successfully authenticated and verified user ${oidcUserOnWrongServer.username} (${oidcUserOnWrongServer.email})`); }); }); diff --git a/playwright/tests/auth/register-password.spec.ts b/playwright/tests/auth/mas-register-password.spec.ts similarity index 85% rename from playwright/tests/auth/register-password.spec.ts rename to playwright/tests/auth/mas-register-password.spec.ts index bac9220..7c854f7 100644 --- a/playwright/tests/auth/register-password.spec.ts +++ b/playwright/tests/auth/mas-register-password.spec.ts @@ -2,11 +2,11 @@ import { test, expect } from '../../fixtures/auth-fixture'; import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from '../../utils/config'; -test.describe('Register', () => { +test.describe('MAS Register password', () => { const PASSWORd = "sdf78qsd!9090ssss"; - test.skip('without oauth2 session', async ({ context, page, simpleUser: user, screenChecker: screen }) => { + test.skip('without oauth2 session', async ({ context, page, userData: user, screenChecker: screen }) => { // This test is intentionally ignored. //the error page has been deactivated for the moment because the MAS unit tests must be fixed @@ -20,7 +20,7 @@ test.describe('Register', () => { }); - test('tchap register with JavaScript disabled', async ({ browser, simpleUser: user, screenChecker: screen }) => { + test('with JavaScript disabled', async ({ browser, userData: user, screenChecker: screen }) => { const ctx = await browser.newContext({ javaScriptEnabled: false }); const page = await ctx.newPage(); @@ -38,7 +38,7 @@ test.describe('Register', () => { await screen(page, '/verify-email'); }); - test('tchap register when user already exists', async ({ browser, simpleUser: user, screenChecker: screen }) => { + test('when user already exists', async ({ browser, userData: user, screenChecker: screen }) => { const ctx = await browser.newContext({ javaScriptEnabled: false }); const page = await ctx.newPage(); diff --git a/playwright/tests/auth/tchap-login-oidc.spec.ts b/playwright/tests/auth/tchap-login-oidc.spec.ts index 322120f..3ea54ce 100644 --- a/playwright/tests/auth/tchap-login-oidc.spec.ts +++ b/playwright/tests/auth/tchap-login-oidc.spec.ts @@ -9,7 +9,7 @@ import { SCREENSHOTS_DIR, TCHAP_LEGACY } from '../../utils/config'; //flaky on await expect(page.locator('text=Configuration')).toBeVisible({timeout: 20000}); test.describe('Tchap : Login via OIDC', () => { - test('tchap match account by username', async ({ page, userLegacy: userLegacy }) => { + test('tchap match account by username', async ({ page, oidcUserLegacy: userLegacy }) => { const screenshot_path = test.info().title.replace(" ", "_"); userLegacy.masId = await createMasUserWithPassword(userLegacy.username, userLegacy.email, userLegacy.password); diff --git a/playwright/tests/auth/tchap-login-password.spec.ts b/playwright/tests/auth/tchap-login-password.spec.ts index 8b13a7d..f54646a 100644 --- a/playwright/tests/auth/tchap-login-password.spec.ts +++ b/playwright/tests/auth/tchap-login-password.spec.ts @@ -5,7 +5,7 @@ import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from '../../utils/config'; test.describe('Tchap : Login password', () => { - test('tchap login with password and login_hint', async ({ page, userLegacy: userLegacy }) => { + test('tchap login with password and login_hint', async ({ page, oidcUserLegacy: userLegacy }) => { const screenshot_path = test.info().title.replace(" ", "_"); userLegacy.masId = await createMasUserWithPassword(userLegacy.username, userLegacy.email, userLegacy.password); diff --git a/playwright/tests/auth/tchap-register-password.spec.ts b/playwright/tests/auth/tchap-register-password.spec.ts index 562a430..ef82a05 100644 --- a/playwright/tests/auth/tchap-register-password.spec.ts +++ b/playwright/tests/auth/tchap-register-password.spec.ts @@ -12,7 +12,7 @@ test.describe('Tchap : register with password', () => { const PASSWORd = "sdf78qsd!9090ssss"; - test('tchap register with oidc native', async ({ context, page, simpleUser: user, screenChecker: screen, startTchapRegisterWithEmail }) => { + test('tchap register with oidc native', async ({ context, page, userData: user, screenChecker: screen, startTchapRegisterWithEmail }) => { await startTchapRegisterWithEmail(page, user.email); @@ -48,7 +48,8 @@ test.describe('Tchap : register with password', () => { expect(created_user.attributes.username).toContain(user.username); }); - test('tchap register with not invited email', async ({page, simpleUser: user, screenChecker: screen, startTchapRegisterWithEmail }) => { + //skip because flakky + test.skip('tchap register with not invited email', async ({page, userData: user, screenChecker: screen, startTchapRegisterWithEmail }) => { await startTchapRegisterWithEmail(page, user.email); @@ -72,7 +73,7 @@ test.describe('Tchap : register with password', () => { }); //skip because flakky - test.skip('tchap register with email on wrong server', async ({page, simpleUser: user, screenChecker: screen, startTchapRegisterWithEmail }) => { + test.skip('tchap register with email on wrong server', async ({page, userData: user, screenChecker: screen, startTchapRegisterWithEmail }) => { await startTchapRegisterWithEmail(page, user.email); diff --git a/start-local-mas-hot-reload.sh.deprecated b/start-local-mas-hot-reload.sh.deprecated deleted file mode 100755 index acba56e..0000000 --- a/start-local-mas-hot-reload.sh.deprecated +++ /dev/null @@ -1,83 +0,0 @@ -#!/bin/bash - -# runs the MAS with a hot reload when a template is modified - - -set -e - -# Source the .env file to load environment variables -if [ -f .env ]; then - source .env -else - echo "Error: .env file not found. Please create a .env file with the required environment variables." - exit 1 -fi - -# Check if MAS_HOME is defined -if [ -z "$MAS_HOME" ]; then - echo "Error: MAS_HOME environment variable is not defined" - echo "Please set MAS_HOME to the path of your matrix-authentication-service directory" - exit 1 -fi - -# Check if TEMPLATE_WATCH is defined -if [ -z "$TEMPLATE_WATCH" ]; then - echo "Error: TEMPLATE_WATCH environment variable is not defined" - echo "Please set TEMPLATE_WATCH to the path of your templates directory" - exit 1 -fi - -# Function to send SIGHUP to the server process -send_sighup() { - # Find the server process - SERVER_PID=$(pgrep -f "mas-cli server -c") - if [ -n "$SERVER_PID" ]; then - echo "Sending SIGHUP to server process $SERVER_PID" - kill -HUP $SERVER_PID - else - echo "Server process not found" - fi -} - -# Function to watch for template changes -watch_templates() { - echo "Watching for changes in $TEMPLATE_WATCH..." - - # Use fswatch to monitor the templates directory - fswatch -o "$TEMPLATE_WATCH" | while read; do - echo "Template change detected..." - $MAS_TCHAP_HOME/tools/build_conf.sh - send_sighup - done -} - -# Check if fswatch is installed -if ! command -v fswatch &> /dev/null; then - echo "fswatch is not installed. Please install it first:" - echo "brew install fswatch" - exit 1 -fi - -export MAS_HOME=$MAS_HOME -export MAS_TCHAP_HOME=$PWD - -# Build conf from conf.template.yaml -# $MAS_TCHAP_HOME/tools/build_conf.sh - -# Start watching for changes in the background -watch_templates & - -# Store the watcher's PID -WATCHER_PID=$! - -# Function to clean up on exit -cleanup() { - echo "Stopping template watcher..." - kill $WATCHER_PID 2>/dev/null - exit 0 -} - -# Set up trap for cleanup -trap cleanup EXIT INT TERM - -./start-local-mas.sh diff --git a/start-local-mas.sh.deprecated b/start-local-mas.sh.deprecated deleted file mode 100755 index 9987c89..0000000 --- a/start-local-mas.sh.deprecated +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash -# runs the MAS at the path : $MAS_HOME -# before running the server : -# - build the config -# - build the template -# - runs sanity check on the templates - -set -e - -# Source the .env file to load environment variables -if [ -f .env ]; then - source .env -else - echo "Error: .env file not found. Please create a .env file with the required environment variables." - exit 1 -fi - -# Check if MAS_HOME is defined -if [ -z "$MAS_HOME" ]; then - echo "Error: MAS_HOME environment variable is not defined" - echo "Please set MAS_HOME to the path of your matrix-authentication-service directory" - exit 1 -fi - -export MAS_HOME=$MAS_HOME -export MAS_TCHAP_HOME=$PWD -cd $MAS_HOME - -# Build conf from conf.template.yaml -$MAS_TCHAP_HOME/tools/build_conf.sh - -export RUST_LOG=info - -# Start the server -echo "Checking templates..." -cargo run -- templates check -c $MAS_TCHAP_HOME/tmp/config.local.dev.yaml - -cargo run -- server -c $MAS_TCHAP_HOME/tmp/config.local.dev.yaml - diff --git a/start-local-stack.sh.deprecated b/start-local-stack.sh.deprecated deleted file mode 100755 index f5cbd00..0000000 --- a/start-local-stack.sh.deprecated +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash - -#set -e - -# Run mail service -#docker rm -f mailcatcher -#docker run -d --name mailcatcher -p 1080:1080 -p 1025:1025 sj26/mailcatcher -# Run postgres service -#createdb -U postgres postgres -# dropdb postgres -#docker rm -f postgres -#docker run -d --name postgres -p 5432:5432 -v ./tmp/postgres:/var/lib/postgresql/data:rw -e 'POSTGRES_USER=postgres' -e 'POSTGRES_PASSWORD=postgres' -e 'POSTGRES_DATABASE=postgres' -e 'PGDATA=/var/lib/postgresql/data' postgres -# Wait for postgres to be ready -#sleep 3 - -#docker exec -it postgres createdb -U postgres -O postgres keycloak - - -# Run Keycloak service with proconnect-mock realm import -# frontend url : https://sso.tchapgouv.com -#docker rm -f keycloak -#docker run -d --name keycloak -p 8082:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin -v $(pwd)/tchap/keycloak/proconnect-mock-realm.json:/opt/keycloak/data/import/proconnect-mock-realm.json quay.io/keycloak/keycloak:latest start-dev --import-realm --hostname=https://sso.tchapgouv.com - -mkdir tmp - -# Stops containers and removes containers, networks, volumes, and images -docker compose down - -# (re)creates, starts, and attaches to containers in the background and leaves them running. -docker compose up -d - -# View output from containers -docker compose logs -f diff --git a/tools/README_TCHAP.md b/tools/README_TCHAP.md deleted file mode 100644 index e7cf1c1..0000000 --- a/tools/README_TCHAP.md +++ /dev/null @@ -1,58 +0,0 @@ -## Build - -https://element-hq.github.io/matrix-authentication-service/setup/installation.html#building-from-the-source - -frontend - -``` -cd frontend -npm ci -npm run build -cd .. -``` - - -policies -``` -cd policies -make DOCKER=1 -cd .. -``` - - -## To run - -``` -./start-local-stack.sh -./start-local-mas.sh -``` - - -## Command helper -``` -#createdb -U postgres postgres -# dropdb postgres -#createdb -U postgres -O postgres keycloak -#docker run -d -p 5432:5432 -e 'POSTGRES_USER=keycloak' -e 'POSTGRES_PASSWORD=keycloak' -e 'POSTGRES_DATABASE=keycloak' postgres-keycloak -# cargo test --package mas-handlers upstream_oauth2::link::tests -``` - -## database - -create the db -``` -cd crates/storage-pg -cargo sqlx database setup -``` - -drop the db -``` -cd crates/storage-pg -cargo sqlx database drop -``` - -generate an offline db -``` -cd crates/storage-pg -cargo sqlx database prepare -``` \ No newline at end of file diff --git a/tools/build_conf.sh b/tools/build_conf.sh deleted file mode 100755 index 1571b1f..0000000 --- a/tools/build_conf.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/sh - -set -e - -echo "Install tchap @vector-im/compound-design-tokens ..." - -cd "$MAS_HOME/frontend" -npm install - -# uncomment if needed : -# echo "Building frontend and static resources with yarn build-tchap ..." -npm run build-tchap - - -echo "Building templates..." - -# New template directory -MAS_TCHAP_DATA="$MAS_TCHAP_HOME/tmp" -# Create data directory -if [ ! -d "$MAS_TCHAP_DATA" ]; then - mkdir -p "$MAS_TCHAP_DATA" -fi - -# Copy all MAS templates to a new template directory -cp -r "$MAS_HOME/templates" "$MAS_TCHAP_DATA" - -# Override MAS template with custom tchap template -cp -r "$MAS_HOME/tchap/resources/templates" "$MAS_TCHAP_DATA" - -echo "Building MAS config..." - -# Create MAS conf file -template_yaml_file="$MAS_TCHAP_HOME/conf/config.template.yaml" -yaml_file="$MAS_TCHAP_DATA/config.local.dev.yaml" -cp $template_yaml_file $yaml_file - -MAS_TCHAP_TEMPLATES="$MAS_TCHAP_DATA/templates" -sed -i '' -E "/^templates:/,/^[^[:space:]]/ s|^[[:space:]]*path:.*| path: \"$MAS_TCHAP_TEMPLATES\"|" "$yaml_file" - -echo "Updating translations..." -MAS_TCHAP_TRANSLATIONS="$MAS_HOME/tchap/resources/translations" - -cargo run -p mas-i18n-scan -- --update "${MAS_TCHAP_TEMPLATES}" "${MAS_TCHAP_TRANSLATIONS}/en.json" - -sed -i '' -E "/^templates:/,/^[^[:space:]]/ s|^[[:space:]]*translations_path:.*| translations_path: \"$MAS_TCHAP_TRANSLATIONS\"|" "$yaml_file" diff --git a/tools/debug_test.sh b/tools/debug_test.sh deleted file mode 100755 index fb2d133..0000000 --- a/tools/debug_test.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/bin/bash -# Usage: ./debug_test.sh crates/handlers upstream_oauth2::link::tests::test_link_existing_account -set -e - -cd $MAS_TCHAP_HOME - -if [[ $# -lt 2 ]]; then - echo "Usage: $0 " - echo "Example: $0 crates/mas-handlers test_valid_token_flow" - exit 1 -fi - -CRATE_DIR="$1" -TEST_NAME="$2" - -# Compile the tests for the crate -echo "🔧 Building tests for crate: $CRATE_DIR" -cd "$CRATE_DIR" -cargo test --no-run - -# Get crate name from Cargo.toml -CRATE_NAME=$(grep '^name' Cargo.toml | head -n1 | cut -d '"' -f2) -# Derive expected test binary name (Cargo replaces '-' with '_') -BINARY_NAME="${CRATE_NAME//-/_}" - -# Get the correct test binary path -BIN_PATH=$(cargo test --no-run --message-format=json \ - | jq -r --arg name "$BINARY_NAME" ' - select(.profile.test == true and .target.name == $name) - | .executable - ') - -if [[ ! -x "$BIN_PATH" ]]; then - echo "❌ Could not find test binary." - exit 2 -fi - -echo "✅ Test binary: $BIN_PATH" - -# Go back to workspace root -cd - > /dev/null - -# Ensure .vscode exists -mkdir -p .vscode - -# Write launch.json -cat > .vscode/launch.json < /dev/null; then - echo "Error: Mock server is not running at $MOCK_URL" - echo "Please start the mock server with: docker-compose up identity-mock" - exit 1 -fi - -# Display the available mappings -echo "Available mappings:" -curl -s "$MOCK_URL/__admin/mappings" | grep -o '"name":"[^"]*"' | cut -d'"' -f4 || echo "No named mappings found" - -echo "Mock server is running at $MOCK_URL" -echo "----------------------------------------" - -# Test different email addresses -test_email "user@tchapgouv.com" "Agent" -test_email "user@invited.externe.com" "Externe avec invit" -test_email "user@not.invited.externe.com" "Externe sans invit" -test_email "user@wrong.server.com" "User on wrong server" - -# Test with a custom email to demonstrate the template functionality -echo -e "${YELLOW}Enter a custom email address to test (or press Enter to skip):${NC}" -read custom_email - -if [ ! -z "$custom_email" ]; then - test_email "$custom_email" "Custom email test" -fi - -echo "All tests completed!" diff --git a/tools/test_admin.sh b/tools/test_admin.sh deleted file mode 100755 index f2ab367..0000000 --- a/tools/test_admin.sh +++ /dev/null @@ -1,20 +0,0 @@ - -CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 -CLIENT_SECRET=phoo8ahneir3ohY2eigh4xuu6Oodaewi -#MAS_HOST=localhost:8080 -MAS_HOST=auth.tchapgouv.com - -ACCESS_TOKEN=$(curl \ - -u "$CLIENT_ID:$CLIENT_SECRET" \ - -d "grant_type=client_credentials&scope=urn:mas:admin" \ - https://$MAS_HOST/oauth2/token \ - | jq -r '.access_token') - -echo $ACCESS_TOKEN - -# List users (The -g flag prevents curl from interpreting the brackets in the URL) -curl \ - -g \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - "https://$MAS_HOST/api/admin/v1/users?filter[can_request_admin]=true&filter[status]=active&page[first]=100" \ - | jq diff --git a/wiremock/mappings/invited-mapping-internal.json b/wiremock/mappings/invited-mapping-internal.json deleted file mode 100644 index b62c405..0000000 --- a/wiremock/mappings/invited-mapping-internal.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "Invited User Mapping", - "request": { - "method": "GET", - "urlPathPattern": "/_matrix/identity/api/v1/internal-info", - "queryParameters": { - "medium": { - "equalTo": "email" - }, - "address": { - "contains": "@invited.externe.com" - } - } - }, - "response": { - "status": 200, - "jsonBody": { - "hs": "tchapgouv.com", - "requires_invite": true, - "invited": true - }, - "headers": { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*" - } - }, - "priority": 100 -} diff --git a/wiremock/mappings/invited-mapping.json b/wiremock/mappings/invited-mapping.json deleted file mode 100644 index 9906d58..0000000 --- a/wiremock/mappings/invited-mapping.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "Invited User Mapping", - "request": { - "method": "GET", - "urlPathPattern": "/_matrix/identity/api/v1/info", - "queryParameters": { - "medium": { - "equalTo": "email" - }, - "address": { - "contains": "@invited.externe.com" - } - } - }, - "response": { - "status": 200, - "jsonBody": { - "hs": "tchapgouv.com" - }, - "headers": { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*" - } - }, - "priority": 100 -} diff --git a/wiremock/mappings/not-invited-mapping-internal.json b/wiremock/mappings/not-invited-mapping-internal.json deleted file mode 100644 index 3ee1ad9..0000000 --- a/wiremock/mappings/not-invited-mapping-internal.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "Not Invited User Mapping", - "request": { - "method": "GET", - "urlPathPattern": "/_matrix/identity/api/v1/internal-info", - "queryParameters": { - "medium": { - "equalTo": "email" - }, - "address": { - "contains": "@not.invited.externe.com" - } - } - }, - "response": { - "status": 200, - "jsonBody": { - "hs": "tchapgouv.com", - "requires_invite": true, - "invited": false - }, - "headers": { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*" - } - }, - "priority": 100 -} diff --git a/wiremock/mappings/not-invited-mapping.json b/wiremock/mappings/not-invited-mapping.json deleted file mode 100644 index 7788c80..0000000 --- a/wiremock/mappings/not-invited-mapping.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "Not Invited User Mapping", - "request": { - "method": "GET", - "urlPathPattern": "/_matrix/identity/api/v1/info", - "queryParameters": { - "medium": { - "equalTo": "email" - }, - "address": { - "contains": "@not.invited.externe.com" - } - } - }, - "response": { - "status": 200, - "jsonBody": { - "hs": "tchapgouv.com" - }, - "headers": { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*" - } - }, - "priority": 100 -} diff --git a/wiremock/mappings/user-mapping-internal.json b/wiremock/mappings/user-mapping-internal.json deleted file mode 100644 index df123dc..0000000 --- a/wiremock/mappings/user-mapping-internal.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "User Standard Mapping", - "request": { - "method": "GET", - "urlPathPattern": "/_matrix/identity/api/v1/internal-info", - "queryParameters": { - "medium": { - "equalTo": "email" - }, - "address": { - "matches" : ".*@(numerique\\.gouv\\.fr|tchapgouv\\.com|beta\\.gouv\\.fr)$" - } - } - }, - "response": { - "status": 200, - "jsonBody": { - "hs": "tchapgouv.com", - "requires_invite": false, - "invited": false - }, - "headers": { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*" - } - }, - "priority": 100 -} diff --git a/wiremock/mappings/user-mapping.json b/wiremock/mappings/user-mapping.json deleted file mode 100644 index f0a7bba..0000000 --- a/wiremock/mappings/user-mapping.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "User Standard Mapping", - "request": { - "method": "GET", - "urlPathPattern": "/_matrix/identity/api/v1/info", - "queryParameters": { - "medium": { - "equalTo": "email" - }, - "address": { - "matches" : ".*@(numerique\\.gouv\\.fr|tchapgouv\\.com|beta\\.gouv\\.fr)$" - } - } - }, - "response": { - "status": 200, - "jsonBody": { - "hs": "tchapgouv.com" - }, - "headers": { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*" - } - }, - "priority": 100 -} diff --git a/wiremock/mappings/wrong-hs-mapping-internal.json b/wiremock/mappings/wrong-hs-mapping-internal.json deleted file mode 100644 index 5f8713f..0000000 --- a/wiremock/mappings/wrong-hs-mapping-internal.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "User Standard Mapping", - "request": { - "method": "GET", - "urlPathPattern": "/_matrix/identity/api/v1/internal-info", - "queryParameters": { - "medium": { - "equalTo": "email" - }, - "address": { - "contains": "wrong.server.com" - } - } - }, - "response": { - "status": 200, - "jsonBody": { - "hs": "wrong.server.com", - "requires_invite": false, - "invited": false - }, - "headers": { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*" - } - }, - "priority": 100 -} diff --git a/wiremock/mappings/wrong-hs-mapping.json b/wiremock/mappings/wrong-hs-mapping.json deleted file mode 100644 index add20a3..0000000 --- a/wiremock/mappings/wrong-hs-mapping.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "User Standard Mapping", - "request": { - "method": "GET", - "urlPathPattern": "/_matrix/identity/api/v1/info", - "queryParameters": { - "medium": { - "equalTo": "email" - }, - "address": { - "contains": "wrong.server.com" - } - } - }, - "response": { - "status": 200, - "jsonBody": { - "hs": "wrong.server.com" - }, - "headers": { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*" - } - }, - "priority": 100 -} From 5085f959aca727ec4033c438e0c8c826289fcb5f Mon Sep 17 00:00:00 2001 From: Olivier D Date: Tue, 25 Nov 2025 17:11:04 +0100 Subject: [PATCH 40/71] Add test when username and email data are not bind(#18) * remove tests that match with username * add tests for invalid data * fix test condition --- playwright/fixtures/auth-fixture.ts | 45 ++--------- playwright/tests/auth/mas-login-oidc.spec.ts | 84 ++++++++++---------- 2 files changed, 51 insertions(+), 78 deletions(-) diff --git a/playwright/fixtures/auth-fixture.ts b/playwright/fixtures/auth-fixture.ts index b8c44b5..b2af20e 100644 --- a/playwright/fixtures/auth-fixture.ts +++ b/playwright/fixtures/auth-fixture.ts @@ -24,7 +24,7 @@ import { } from "../utils/config"; import { ClientServerApi, Credentials } from "../utils/api"; -function generateUserData(domain: string) { +function generateUserDataFixture(domain: string) { return async ({}, use: (user: TestUser) => Promise) => { try { const user = generateTestUserData(domain); @@ -46,41 +46,10 @@ function generateUserData(domain: string) { function createKeycloakUserFixture(domain: string) { return async ({}, use: (user: TestUser) => Promise) => { try { - const testUser = generateTestUserData(domain); + const testUserData = generateTestUserData(domain); // Create a test user in Keycloak - const user = await createKeycloakTestUser(testUser); - - // Use the test user in the test - await use(user); - - // Clean up the test user after the test - await cleanupKeycloakTestUser(user); - console.log(`Cleaned up test user: ${user.username}`); - } finally { - // Dispose API contexts - await Promise.all([disposeKeycloakApiContext(), disposeMasApiContext()]); - console.log("API contexts disposed"); - } - }; -} - -//legacy users have a username derived from email : -//email : username@domain.com -> username : username-domain.com -//todo : do we need this and createKeycloakUserFixture? -function createKeycloakLegacyUserFixture(domain: string) { - return async ({}, use: (user: TestUser) => Promise) => { - try { - const randomSuffix = Math.floor(Math.random() * 10000000); - - const testUser: TestUser = { - username: `test.user${randomSuffix}-${domain}`, - email: `test.user${randomSuffix}@${domain}`, - password: "1234!", - }; - - // Create a test user in Keycloak - const user = await createKeycloakTestUser(testUser); + const user = await createKeycloakTestUser(testUserData); // Use the test user in the test await use(user); @@ -190,17 +159,17 @@ export const test = base.extend<{ /** * Create a test user in Keycloak before the test and clean it up after */ - userData: generateUserData(STANDARD_EMAIL_DOMAIN), + userData: generateUserDataFixture(STANDARD_EMAIL_DOMAIN), oidcUser: createKeycloakUserFixture(STANDARD_EMAIL_DOMAIN), oidcExternalUserWithInvit: createKeycloakUserFixture(INVITED_EMAIL_DOMAIN), oidcExternalUserWitoutInvit: createKeycloakUserFixture(NOT_INVITED_EMAIL_DOMAIN), oidcUserOnWrongServer: createKeycloakUserFixture(WRONG_SERVER_EMAIL_DOMAIN), - oidcUserLegacy: createKeycloakLegacyUserFixture(STANDARD_EMAIL_DOMAIN), - oidcUserLegacyWithFallbackRules: createKeycloakLegacyUserFixture(NUMERIQUE_EMAIL_DOMAIN), + oidcUserLegacy: createKeycloakUserFixture(STANDARD_EMAIL_DOMAIN), + oidcUserLegacyWithFallbackRules: createKeycloakUserFixture(NUMERIQUE_EMAIL_DOMAIN), authenticatedUser: authenticatedUserFixture, typeUser: TypeUser.MAS_PASSWORD_USER, screenChecker: screenCheckerFixture, startTchapRegisterWithEmail: startTchapRegisterWithEmailFixture, }); -export { expect } from "@playwright/test"; +export { expect } from "@playwright/test"; \ No newline at end of file diff --git a/playwright/tests/auth/mas-login-oidc.spec.ts b/playwright/tests/auth/mas-login-oidc.spec.ts index d931bbf..95b2262 100644 --- a/playwright/tests/auth/mas-login-oidc.spec.ts +++ b/playwright/tests/auth/mas-login-oidc.spec.ts @@ -1,48 +1,14 @@ import { test, expect } from '../../fixtures/auth-fixture'; import { + createKeycloakTestUser, performOidcLogin, + TestUser, } from '../../utils/auth-helpers'; -import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser,oauthLinkExistsByUserId, oauthLinkExistsBySubject, getOauthLinkBySubject, deleteOauthLink, reactivateMasUser, addUserEmail } from '../../utils/mas-admin'; -import { SCREENSHOTS_DIR } from '../../utils/config'; +import { createMasUserWithPassword, getMasUserByEmail, deactivateMasUser,oauthLinkExistsByUserId, oauthLinkExistsBySubject, getOauthLinkBySubject, deleteOauthLink, reactivateMasUser, addUserEmail } from '../../utils/mas-admin'; +import { SCREENSHOTS_DIR, STANDARD_EMAIL_DOMAIN } from '../../utils/config'; test.describe('MAS Login OIDC', () => { - test('match account by username', async ({ page, oidcUserLegacy: userLegacy }) => { - const screenshot_path = test.info().title.replace(" ", "_"); - - // Create a user in MAS with the same email as the Keycloak user - console.log(`Creating MAS user with same username as Keycloak user: ${userLegacy.username}`); - - userLegacy.masId = await createMasUserWithPassword(userLegacy.username, userLegacy.email, userLegacy.password); - - try { - // Perform the OIDC login flow - await performOidcLogin(page, userLegacy, screenshot_path); - - // Click the link account button - await page.locator('button[type="submit"]').click(); - - // Since the account already exists, we should be automatically logged in - // Verify we're successfully logged in - await expect(page.locator('text=Mon compte')).toBeVisible(); - - // Take a screenshot of the authenticated state - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); - - // Verify the user in MAS is still the same (account was linked, not created new) - const userAfterLogin = await getMasUserByEmail(userLegacy.email); - expect(userAfterLogin.id).toBe(userLegacy.masId); - expect(await oauthLinkExistsByUserId(userLegacy.masId)).toBe(true); - - console.log(`Successfully verified account linking for user with email: ${userLegacy.email}`); - } finally { - // Clean up the MAS user - await deactivateMasUser(userLegacy.masId); - console.log(`Cleaned up MAS user: ${userLegacy.username}`); - } - }); - - test('match account by email', async ({ page, oidcUserLegacy: userLegacy }) => { const screenshot_path = test.info().title.replace(" ", "_"); @@ -80,7 +46,6 @@ test.describe('MAS Login OIDC', () => { } }); - test('match account by email with fallback rules', async ({ page, oidcUserLegacyWithFallbackRules: userLegacy }) => { const screenshot_path = test.info().title.replace(" ", "_"); @@ -170,7 +135,6 @@ test.describe('MAS Login OIDC', () => { } }); - test('match account by email when account was deactivated but is reactivated by support', async ({browser, context, page, oidcUserLegacy: userLegacy }) => { const screenshot_path = test.info().title.replace(" ", "_"); @@ -259,4 +223,44 @@ test.describe('MAS Login OIDC', () => { console.log(`Cleaned up MAS user: ${userLegacy.username}`); } }); + + test('match account by username throw error when email does not match', async ({ page}) => { + const screenshot_path = test.info().title.replace(" ", "_"); + + //create a user in keycloak with an `email` that matches a `localpart` in MAS + //while the email in MAS is different + //then linking will not be on the `email` but on the `localpart` + //which is a failing edge case + //we expect an error page + const domain = STANDARD_EMAIL_DOMAIN; + + const randomSuffix = Math.floor(Math.random() * 10000000); + const mas_user_email = `any-email-${randomSuffix}@${domain}`; + + const testUser: TestUser = { + username: `test.user${randomSuffix}-${domain}`, + email: `test.user${randomSuffix}@${domain}`, + password: "1234!", + }; + + const user = await createKeycloakTestUser(testUser); + + user.masId = await createMasUserWithPassword(user.username, mas_user_email, user.password); + + try { + // Perform the OIDC login flow + await performOidcLogin(page, user, screenshot_path); + + // Get error + //await expect(page.locator('text=unknown_error')); + await expect(page.locator('text="Invalid Data"')).toBeVisible(); +; + + console.log(`Successfully verified account linking for user with email: ${user.email}`); + } finally { + // Clean up the MAS user + await deactivateMasUser(user.masId); + console.log(`Cleaned up MAS user: ${user.username}`); + } + }); }); From 4f5b4f0ff4d772f23e2d6630a62480d1ede205e4 Mon Sep 17 00:00:00 2001 From: olivierdelcroix Date: Tue, 25 Nov 2025 17:12:18 +0100 Subject: [PATCH 41/71] add dev01 env --- playwright/.env.dev01 | 38 ++++++++++++++++++++++++++++++++++++++ playwright/.env.local | 3 ++- playwright/package.json | 3 ++- playwright/utils/config.ts | 2 +- 4 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 playwright/.env.dev01 diff --git a/playwright/.env.dev01 b/playwright/.env.dev01 new file mode 100644 index 0000000..f354703 --- /dev/null +++ b/playwright/.env.dev01 @@ -0,0 +1,38 @@ +# URLs +MAS_URL=https://auth.dev01.tchap.incubateur.net +KEYCLOAK_URL=https://identite-sandbox.proconnect.gouv.fr/users/start-sign-in +ELEMENT_URL=https://tchap.incubateur.net +BASE_URL=https://matrix.dev01.tchap.incubateur.net + +# Keycloak Admin Credentials +#KEYCLOAK_ADMIN_USERNAME=admin +#KEYCLOAK_ADMIN_PASSWORD=admin +#KEYCLOAK_REALM=proconnect-mock + +# MAS Admin API Credentials +MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 +MAS_ADMIN_CLIENT_SECRET= + +# Test User Credentials +TEST_USER_PREFIX=playwright_test_user +TEST_USER_PASSWORD=Test@123456 + +# Test User Credentials +TEST_USER_PREFIX=user.local +TEST_USER_PASSWORD=Test@123456 + +# Email domains for test users +STANDARD_EMAIL_DOMAIN=agent1.tchap.incubateur.net + +INVITED_EMAIL_DOMAIN=invited.externe.com +NOT_INVITED_EMAIL_DOMAIN=gmail.com +WRONG_SERVER_EMAIL_DOMAIN=wrong.server.com + +# Browser Locale +BROWSER_LOCALE=fr-FR + +# Screenshots Directory +SCREENSHOTS_DIR=playwright-results/dev01 + +#Run tests in parallel +TEST_IN_PARALLEL=false \ No newline at end of file diff --git a/playwright/.env.local b/playwright/.env.local index b2cc3c9..48ae443 100644 --- a/playwright/.env.local +++ b/playwright/.env.local @@ -2,6 +2,7 @@ MAS_URL=https://auth.tchapgouv.com KEYCLOAK_URL=https://sso.tchapgouv.com ELEMENT_URL=https://element.tchapgouv.com +BASE_URL=https://matrix.dev01.tchap.incubateur.net # Keycloak Admin Credentials KEYCLOAK_ADMIN_USERNAME=admin @@ -10,7 +11,7 @@ KEYCLOAK_REALM=proconnect-mock # MAS Admin API Credentials MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 -MAS_ADMIN_CLIENT_SECRET=phoo8ahneir3ohY2eigh4xuu6Oodaewi +MAS_ADMIN_CLIENT_SECRET= # Test User Credentials TEST_USER_PREFIX=playwright_test_user diff --git a/playwright/package.json b/playwright/package.json index bcdcca3..153f3ba 100644 --- a/playwright/package.json +++ b/playwright/package.json @@ -8,7 +8,8 @@ "test:headed": "playwright test --headed", "test:debug": "playwright test --debug", "test:ui": "playwright test --ui", - "test:local": "TEST_ENV=local playwright test" + "test:local": "TEST_ENV=local playwright test", + "test:dev01": "TEST_ENV=dev01 playwright test" }, "keywords": [ "playwright", diff --git a/playwright/utils/config.ts b/playwright/utils/config.ts index 6c201d1..bc9aa85 100644 --- a/playwright/utils/config.ts +++ b/playwright/utils/config.ts @@ -7,7 +7,7 @@ export const env = process.env.TEST_ENV || 'local'; console.log(`Loading environment configuration for: ${env}`); // Load environment variables from the appropriate .env file -dotenv.config({ path: path.resolve(__dirname, `../../.env.${env}`) }); +dotenv.config({ path: path.resolve(__dirname, `../.env.${env}`) }); // Load environment variables from .env file //dotenv.config(); From 9b0e9c8ffa1d6cc1d0623416fe8021d7b0919a4e Mon Sep 17 00:00:00 2001 From: olivierdelcroix Date: Tue, 25 Nov 2025 17:12:30 +0100 Subject: [PATCH 42/71] remove userlegacyfixture --- playwright/fixtures/auth-fixture.ts | 6 +-- playwright/tests/auth/mas-login-oidc.spec.ts | 43 +++++++++---------- .../tests/auth/mas-register-password.spec.ts | 2 +- .../tests/auth/tchap-login-oidc.spec.ts | 14 +++--- .../tests/auth/tchap-login-password.spec.ts | 16 +++---- 5 files changed, 39 insertions(+), 42 deletions(-) diff --git a/playwright/fixtures/auth-fixture.ts b/playwright/fixtures/auth-fixture.ts index b2af20e..2c7518d 100644 --- a/playwright/fixtures/auth-fixture.ts +++ b/playwright/fixtures/auth-fixture.ts @@ -149,8 +149,7 @@ export const test = base.extend<{ oidcExternalUserWithInvit: TestUser; oidcExternalUserWitoutInvit: TestUser; oidcUserOnWrongServer: TestUser; - oidcUserLegacy: TestUser;//pas clair le oidc***Legacy - oidcUserLegacyWithFallbackRules: TestUser; + oidcUserWithFallbackRules: TestUser; authenticatedUser: Credentials; typeUser: TypeUser; screenChecker: ScreenCheckerFixture; @@ -164,8 +163,7 @@ export const test = base.extend<{ oidcExternalUserWithInvit: createKeycloakUserFixture(INVITED_EMAIL_DOMAIN), oidcExternalUserWitoutInvit: createKeycloakUserFixture(NOT_INVITED_EMAIL_DOMAIN), oidcUserOnWrongServer: createKeycloakUserFixture(WRONG_SERVER_EMAIL_DOMAIN), - oidcUserLegacy: createKeycloakUserFixture(STANDARD_EMAIL_DOMAIN), - oidcUserLegacyWithFallbackRules: createKeycloakUserFixture(NUMERIQUE_EMAIL_DOMAIN), + oidcUserWithFallbackRules: createKeycloakUserFixture(NUMERIQUE_EMAIL_DOMAIN), authenticatedUser: authenticatedUserFixture, typeUser: TypeUser.MAS_PASSWORD_USER, screenChecker: screenCheckerFixture, diff --git a/playwright/tests/auth/mas-login-oidc.spec.ts b/playwright/tests/auth/mas-login-oidc.spec.ts index 95b2262..f7245a2 100644 --- a/playwright/tests/auth/mas-login-oidc.spec.ts +++ b/playwright/tests/auth/mas-login-oidc.spec.ts @@ -9,18 +9,18 @@ import { SCREENSHOTS_DIR, STANDARD_EMAIL_DOMAIN } from '../../utils/config'; test.describe('MAS Login OIDC', () => { - test('match account by email', async ({ page, oidcUserLegacy: userLegacy }) => { + test('match account by email', async ({ page, oidcUser: oidcUser }) => { const screenshot_path = test.info().title.replace(" ", "_"); // Create a user in MAS with the same email as the Keycloak user - console.log(`Creating MAS user with same email as Keycloak user: ${userLegacy.email}`); + console.log(`Creating MAS user with same email as Keycloak user: ${oidcUser.email}`); - userLegacy.masId = await createMasUserWithPassword(userLegacy.username+"different_from_email", userLegacy.email, userLegacy.password); + oidcUser.masId = await createMasUserWithPassword(oidcUser.username+"different_from_email", oidcUser.email, oidcUser.password); try { // Perform the OIDC login flow - await performOidcLogin(page, userLegacy, screenshot_path); + await performOidcLogin(page, oidcUser, screenshot_path); // Click the link account button await page.locator('button[type="submit"]').click(); @@ -33,35 +33,35 @@ test.describe('MAS Login OIDC', () => { await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); // Verify the user in MAS is still the same (account was linked, not created new) - const userAfterLogin = await getMasUserByEmail(userLegacy.email); - expect(userAfterLogin.id).toBe(userLegacy.masId); + const userAfterLogin = await getMasUserByEmail(oidcUser.email); + expect(userAfterLogin.id).toBe(oidcUser.masId); //expect(await oauthLinkExistsByUserId(userLegacy.masId)).toBe(true); - expect(await oauthLinkExistsBySubject(userLegacy.username)).toBe(true); + expect(await oauthLinkExistsBySubject(oidcUser.username)).toBe(true); - console.log(`Successfully verified account linking for user with email: ${userLegacy.email}`); + console.log(`Successfully verified account linking for user with email: ${oidcUser.email}`); } finally { // Clean up the MAS user - await deactivateMasUser(userLegacy.masId); - console.log(`Cleaned up MAS user: ${userLegacy.username}`); + await deactivateMasUser(oidcUser.masId); + console.log(`Cleaned up MAS user: ${oidcUser.username}`); } }); - test('match account by email with fallback rules', async ({ page, oidcUserLegacyWithFallbackRules: userLegacy }) => { + test('match account by email with fallback rules', async ({ page, oidcUserWithFallbackRules: oidcUser }) => { const screenshot_path = test.info().title.replace(" ", "_"); const old_email_domain = "@beta.gouv.fr"; - const old_email = userLegacy.email.replace(/@.*/, old_email_domain); + const old_email = oidcUser.email.replace(/@.*/, old_email_domain); // Create a user in MAS with the same email as the Keycloak user - console.log(`Creating MAS user with old email: ${old_email} whereas email in keycloak is : ${userLegacy.email}`); + console.log(`Creating MAS user with old email: ${old_email} whereas email in keycloak is : ${oidcUser.email}`); - userLegacy.masId = await createMasUserWithPassword(userLegacy.username+"different_from_email", old_email, userLegacy.password); + oidcUser.masId = await createMasUserWithPassword(oidcUser.username+"different_from_email", old_email, oidcUser.password); try { // Perform the OIDC login flow - await performOidcLogin(page, userLegacy, screenshot_path); + await performOidcLogin(page, oidcUser, screenshot_path); // Click the link account button await page.locator('button[type="submit"]').click(); @@ -75,19 +75,18 @@ test.describe('MAS Login OIDC', () => { // Verify the user in MAS is still the same (account was linked, not created new) const userAfterLogin = await getMasUserByEmail(old_email); - expect(userAfterLogin.id).toBe(userLegacy.masId); - expect(await oauthLinkExistsByUserId(userLegacy.masId)).toBe(true); + expect(userAfterLogin.id).toBe(oidcUser.masId); + expect(await oauthLinkExistsByUserId(oidcUser.masId)).toBe(true); console.log(`Successfully verified account linking for user with email: ${old_email}`); } finally { // Clean up the MAS user - await deactivateMasUser(userLegacy.masId); - console.log(`Cleaned up MAS user: ${userLegacy.username}`); + await deactivateMasUser(oidcUser.masId); + console.log(`Cleaned up MAS user: ${oidcUser.username}`); } }); - - test('match account by email when former account is deactivated but another one is valid', async ({browser, context, page, oidcUserLegacy: userLegacy }) => { + test('match account by email when former account is deactivated but another one is valid', async ({browser, context, page, oidcUserWithFallbackRules: userLegacy }) => { const screenshot_path = test.info().title.replace(" ", "_"); // Create a user in MAS with the same email as the Keycloak user @@ -135,7 +134,7 @@ test.describe('MAS Login OIDC', () => { } }); - test('match account by email when account was deactivated but is reactivated by support', async ({browser, context, page, oidcUserLegacy: userLegacy }) => { + test('match account by email when account was deactivated but is reactivated by support', async ({browser, context, page, oidcUserWithFallbackRules: userLegacy }) => { const screenshot_path = test.info().title.replace(" ", "_"); // Create a user in MAS with the same email as the Keycloak user diff --git a/playwright/tests/auth/mas-register-password.spec.ts b/playwright/tests/auth/mas-register-password.spec.ts index 7c854f7..1ff7484 100644 --- a/playwright/tests/auth/mas-register-password.spec.ts +++ b/playwright/tests/auth/mas-register-password.spec.ts @@ -6,7 +6,7 @@ test.describe('MAS Register password', () => { const PASSWORd = "sdf78qsd!9090ssss"; - test.skip('without oauth2 session', async ({ context, page, userData: user, screenChecker: screen }) => { + test.skip('without oauth2 session', async ({page, screenChecker: screen }) => { // This test is intentionally ignored. //the error page has been deactivated for the moment because the MAS unit tests must be fixed diff --git a/playwright/tests/auth/tchap-login-oidc.spec.ts b/playwright/tests/auth/tchap-login-oidc.spec.ts index 3ea54ce..d47a879 100644 --- a/playwright/tests/auth/tchap-login-oidc.spec.ts +++ b/playwright/tests/auth/tchap-login-oidc.spec.ts @@ -9,17 +9,17 @@ import { SCREENSHOTS_DIR, TCHAP_LEGACY } from '../../utils/config'; //flaky on await expect(page.locator('text=Configuration')).toBeVisible({timeout: 20000}); test.describe('Tchap : Login via OIDC', () => { - test('tchap match account by username', async ({ page, oidcUserLegacy: userLegacy }) => { + test('tchap match account by username', async ({ page, oidcUser: existingOidcUser }) => { const screenshot_path = test.info().title.replace(" ", "_"); - userLegacy.masId = await createMasUserWithPassword(userLegacy.username, userLegacy.email, userLegacy.password); + existingOidcUser.masId = await createMasUserWithPassword(existingOidcUser.username, existingOidcUser.email, existingOidcUser.password); // Verify the test user doesn't exist in MAS yet - const existsBeforeLogin = await checkMasUserExistsByEmail(userLegacy.email); + const existsBeforeLogin = await checkMasUserExistsByEmail(existingOidcUser.email); expect(existsBeforeLogin).toBe(true); // Perform the OIDC login flow - await performOidcLoginFromTchap(page, userLegacy,screenshot_path, TCHAP_LEGACY); + await performOidcLoginFromTchap(page, existingOidcUser,screenshot_path, TCHAP_LEGACY); // Click the create account button await page.locator('button[type="submit"]').click(); @@ -39,12 +39,12 @@ test.describe('Tchap : Login via OIDC', () => { await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/06-auth-success.png` }); // Verify the user was created in MAS - await verifyUserInMas(userLegacy); + await verifyUserInMas(existingOidcUser); // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(userLegacy.email); + const existsAfterLogin = await checkMasUserExistsByEmail(existingOidcUser.email); expect(existsAfterLogin).toBe(true); - console.log(`Successfully authenticated and verified user ${userLegacy.username} (${userLegacy.email})`); + console.log(`Successfully authenticated and verified user ${existingOidcUser.username} (${existingOidcUser.email})`); }); }); diff --git a/playwright/tests/auth/tchap-login-password.spec.ts b/playwright/tests/auth/tchap-login-password.spec.ts index f54646a..e7aebed 100644 --- a/playwright/tests/auth/tchap-login-password.spec.ts +++ b/playwright/tests/auth/tchap-login-password.spec.ts @@ -5,11 +5,11 @@ import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from '../../utils/config'; test.describe('Tchap : Login password', () => { - test('tchap login with password and login_hint', async ({ page, oidcUserLegacy: userLegacy }) => { + test('tchap login with password and login_hint', async ({ page, userData: userData }) => { const screenshot_path = test.info().title.replace(" ", "_"); - userLegacy.masId = await createMasUserWithPassword(userLegacy.username, userLegacy.email, userLegacy.password); - const existsBeforeLogin = await checkMasUserExistsByEmail(userLegacy.email); + userData.masId = await createMasUserWithPassword(userData.username, userData.email, userData.password); + const existsBeforeLogin = await checkMasUserExistsByEmail(userData.email); expect(existsBeforeLogin).toBe(true); await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); @@ -19,13 +19,13 @@ test.describe('Tchap : Login password', () => { await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-tchap-login-page.png` }); await page.getByRole('link').filter({hasText : "Se connecter par email"}).click(); await page.waitForURL(url => url.toString().includes(`#/email-precheck-sso`)); - await page.locator('input').fill(userLegacy.email); + await page.locator('input').fill(userData.email); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); //login await page.waitForURL(url => url.toString().includes(`${MAS_URL}/login`)); - await expect(page.locator('input[name="username"]')).toHaveValue(userLegacy.email); - await page.locator('input[name="password"]').fill(userLegacy.password); + await expect(page.locator('input[name="username"]')).toHaveValue(userData.email); + await page.locator('input[name="password"]').fill(userData.password); await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-password-login-filled.png` }); await page.locator('button[type="submit"]').click(); @@ -40,9 +40,9 @@ test.describe('Tchap : Login password', () => { await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/05-auth-success.png` }); // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(userLegacy.email); + const existsAfterLogin = await checkMasUserExistsByEmail(userData.email); expect(existsAfterLogin).toBe(true); - console.log(`Successfully authenticated and verified user ${userLegacy.username} (${userLegacy.email})`); + console.log(`Successfully authenticated and verified user ${userData.username} (${userData.email})`); }); }); From 4ff6dafa6332a00e836afaa50bb03c5118201943 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Thu, 4 Dec 2025 10:51:40 +0100 Subject: [PATCH 43/71] Update README.md --- README.md | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/README.md b/README.md index ab311d0..b022a48 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,7 @@ # folder structure - -- /conf : configuration du MAS special tchap (deprecated) - /playwright : test E2E -- /tools : for local dev (deprecated) -- /wiremock : mock http response for identity server sydent (deprecated) - - -export $MAS_HOME : path of /matrix-authentication-service, fork https://github.com/tchapgouv/matrix-authentication-service -export $MAS_TCHAP_HOME : path of /matrix-authentication-service-tchap - # playwright tests @@ -37,26 +28,6 @@ créer un compte à https://element.tchapgouv.com/ c'est normal le MAS n'est pas démarré -## setup MAS (deprecated) - - -- launch postres services - -Create .env and personalize it if needed - -```bash -cp .env.sample .env -matrix-authentication-service-tchap % ./start-local-stack.sh -``` - -keycloak container might take some time to start up (deprecated) - -```bash -export MAS_HOME=/Users/olivier/workspace/beta/Tchap/matrix-authentication-service -./start-local-mas.sh -``` - -Recharger la page https://element.tchapgouv.com/, le MAS est accessible ## launch tests From b17db9802e6cebd9df8bb0f4e504a033d2a404d2 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Thu, 4 Dec 2025 10:52:02 +0100 Subject: [PATCH 44/71] Update README.md --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index b022a48..590b91a 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,4 @@ cd playwright playwright % npm test ``` -to launch a custom config run (see config.json) - -```bash -npm run test:mas01 -``` - From 5bf5ac8e1f51267b47b95c5689392cff3cbd9e98 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Thu, 4 Dec 2025 11:14:50 +0100 Subject: [PATCH 45/71] add test for match external account by email (#19) --- playwright/tests/auth/mas-login-oidc.spec.ts | 39 ++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/playwright/tests/auth/mas-login-oidc.spec.ts b/playwright/tests/auth/mas-login-oidc.spec.ts index f7245a2..37d8de4 100644 --- a/playwright/tests/auth/mas-login-oidc.spec.ts +++ b/playwright/tests/auth/mas-login-oidc.spec.ts @@ -46,6 +46,45 @@ test.describe('MAS Login OIDC', () => { } }); + test('match external account by email', async ({ page, oidcExternalUserWitoutInvit: externalUser }) => { + // we use the fixture oidcExternalUserWitoutInvit because as long as the account is created, + // there is no invitation pending in the identity server. + + const screenshot_path = test.info().title.replace(" ", "_"); + + // Create a user in MAS with the same email as the Keycloak user + console.log(`Creating MAS user with same email as Keycloak user: ${externalUser.email}`); + + externalUser.masId = await createMasUserWithPassword(externalUser.username, externalUser.email, externalUser.password); + + try { + // Perform the OIDC login flow + await performOidcLogin(page, externalUser, screenshot_path); + + // Click the link account button + await page.locator('button[type="submit"]').click(); + + // Since the account already exists, we should be automatically logged in + // Verify we're successfully logged in + await expect(page.locator('text=Mon compte')).toBeVisible(); + + // Take a screenshot of the authenticated state + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); + + // Verify the user in MAS is still the same (account was linked, not created new) + const userAfterLogin = await getMasUserByEmail(externalUser.email); + expect(userAfterLogin.id).toBe(externalUser.masId); + //expect(await oauthLinkExistsByUserId(userLegacy.masId)).toBe(true); + expect(await oauthLinkExistsBySubject(externalUser.username)).toBe(true); + + console.log(`Successfully verified account linking for user with email: ${externalUser.email}`); + } finally { + // Clean up the MAS user + await deactivateMasUser(externalUser.masId); + console.log(`Cleaned up MAS user: ${externalUser.username}`); + } + }); + test('match account by email with fallback rules', async ({ page, oidcUserWithFallbackRules: oidcUser }) => { const screenshot_path = test.info().title.replace(" ", "_"); From 278a91a65b0462df1f038ea91996d5f2ebf09527 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Fri, 5 Dec 2025 08:57:40 +0100 Subject: [PATCH 46/71] Fix(ui)_user_already_exists (#20) * add tests * lower concurrent execution and add retries to lower flaky tests --- playwright/fixtures/auth-fixture.ts | 6 +- playwright/playwright.config.ts | 8 +-- .../tests/auth/mas-register-password.spec.ts | 24 ++----- .../auth/tchap-register-password.spec.ts | 64 ++++++++++++++----- playwright/utils/auth-helpers.ts | 10 +-- 5 files changed, 64 insertions(+), 48 deletions(-) diff --git a/playwright/fixtures/auth-fixture.ts b/playwright/fixtures/auth-fixture.ts index 2c7518d..4bba4a8 100644 --- a/playwright/fixtures/auth-fixture.ts +++ b/playwright/fixtures/auth-fixture.ts @@ -83,8 +83,8 @@ async function screenCheckerFixture({}: {}, use: (screenChecker: ScreenCheckerFi const screenChecker = async (page: Page, urlFragment: string) => { const browserName = page.context().browser()?.browserType().name(); - - await page.waitForURL((url) => url.toString().includes(urlFragment), {waitUntil:"load"}); + + await page.waitForURL((url) => { console.log("current page url : ", url.pathname); return url.toString().includes(urlFragment)}, {waitUntil:"load"}); const filename = `${browserName}_${counter.toString().padStart(2, '0')}-${urlFragment.replace(/[^\w]/g, '_')}.png`; await page.screenshot({ path: path.join(screenshotPath, filename), fullPage:true }); counter++; @@ -104,7 +104,7 @@ async function startTchapRegisterWithEmailFixture({ screenChecker }: { screenChe await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); await screenChecker(page, '/register'); - await page.getByRole('button').filter({ hasText: 'Continuer avec une adresse mail' }).click(); + await page.getByRole('button').filter({ hasText: 'Continuer avec mon adresse mail' }).click(); }; await use(start); } diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts index 13a1d86..a122e22 100644 --- a/playwright/playwright.config.ts +++ b/playwright/playwright.config.ts @@ -11,18 +11,18 @@ dotenv.config(); export default defineConfig({ testDir: './tests', /* Maximum time one test can run for */ - timeout: 30 * 1000, + timeout: 10 * 1000, /* Run tests in files in parallel */ fullyParallel: process.env.TEST_IN_PARALLEL === 'true' ? true : false, /* Define how many workers */ // Limit the number of workers on CI, use default locally - workers: process.env.CI ? 2 : 10, + workers: process.env.CI ? 2 : 1, /* 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, + + retries: process.env.CI ? 2 : 2, /* Reporter to use */ reporter: 'html', /* Shared settings for all the projects below */ diff --git a/playwright/tests/auth/mas-register-password.spec.ts b/playwright/tests/auth/mas-register-password.spec.ts index 1ff7484..c4518da 100644 --- a/playwright/tests/auth/mas-register-password.spec.ts +++ b/playwright/tests/auth/mas-register-password.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '../../fixtures/auth-fixture'; -import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from '../../utils/config'; +import { createMasTestUser, extractVerificationCode } from '../../utils/auth-helpers'; +import {STANDARD_EMAIL_DOMAIN } from '../../utils/config'; test.describe('MAS Register password', () => { @@ -11,7 +12,7 @@ test.describe('MAS Register password', () => { //the error page has been deactivated for the moment because the MAS unit tests must be fixed await page.goto('/register'); - await page.getByRole('button').filter({ hasText: 'Continuer avec une adresse mail' }).click(); + await page.getByRole('button').filter({ hasText: 'Continuer avec mon adresse mail' }).click(); //form is not submitted because no oauth2_authorization_grant await screen(page, '/register/password'); @@ -26,7 +27,7 @@ test.describe('MAS Register password', () => { const page = await ctx.newPage(); await page.goto('/register'); - await page.getByRole('button').filter({ hasText: 'Continuer avec une adresse mail' }).click(); + await page.getByRole('button').filter({ hasText: 'Continuer avec mon adresse mail' }).click(); await screen(page, '/register/password'); await page.locator('input[name="email"]').fill(user.email); @@ -38,22 +39,5 @@ test.describe('MAS Register password', () => { await screen(page, '/verify-email'); }); - test('when user already exists', async ({ browser, userData: user, screenChecker: screen }) => { - - const ctx = await browser.newContext({ javaScriptEnabled: false }); - const page = await ctx.newPage(); - - await page.goto('/register'); - await page.getByRole('button').filter({ hasText: 'Continuer avec une adresse mail' }).click(); - - await screen(page, '/register/password'); - await page.locator('input[name="email"]').fill(user.email); - await page.locator('input[name="password"]').fill(PASSWORd); - await page.locator('input[name="password_confirm"]').fill(PASSWORd); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - - //form is submitted successfully - await screen(page, '/verify-email'); - }); }); diff --git a/playwright/tests/auth/tchap-register-password.spec.ts b/playwright/tests/auth/tchap-register-password.spec.ts index ef82a05..d18fde2 100644 --- a/playwright/tests/auth/tchap-register-password.spec.ts +++ b/playwright/tests/auth/tchap-register-password.spec.ts @@ -1,10 +1,11 @@ import { test, expect } from '../../fixtures/auth-fixture'; import { + createMasTestUser, extractVerificationCode, generateTestUserData } from '../../utils/auth-helpers'; import { getMasUserByEmail } from '../../utils/mas-admin'; -import {ELEMENT_URL, NOT_INVITED_EMAIL_DOMAIN, WRONG_SERVER_EMAIL_DOMAIN } from '../../utils/config'; +import {ELEMENT_URL, NOT_INVITED_EMAIL_DOMAIN, STANDARD_EMAIL_DOMAIN, WRONG_SERVER_EMAIL_DOMAIN } from '../../utils/config'; test.describe('Tchap : register with password', () => { @@ -21,15 +22,10 @@ test.describe('Tchap : register with password', () => { await page.locator('input[name="password"]').fill(PASSWORd); await page.locator('input[name="password_confirm"]').fill(PASSWORd); - await page.locator("body").click({ position: { x: 0, y: 0 } }); - //await page.getByRole('generic').filter({ hasText: "Les mots de passe correspondent." }); - //await expect(page.locator('span')).toHaveValue("Les mots de passe correspondent."); - //await page.keyboard.press('Enter'); - - - await page.getByRole('button').filter({ hasText: 'Continuer' }).click();//needs to focus out from the `password_confirm` field - await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + //wait for password-confirm matching confirmation + await page.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field + await expect(page.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); await screen(page, '/verify-email'); @@ -48,8 +44,7 @@ test.describe('Tchap : register with password', () => { expect(created_user.attributes.username).toContain(user.username); }); - //skip because flakky - test.skip('tchap register with not invited email', async ({page, userData: user, screenChecker: screen, startTchapRegisterWithEmail }) => { + test('with not invited email', async ({page, userData: user, screenChecker: screen, startTchapRegisterWithEmail }) => { await startTchapRegisterWithEmail(page, user.email); @@ -62,7 +57,10 @@ test.describe('Tchap : register with password', () => { await page.locator('input[name="email"]').fill(not_invited_user.email); await page.locator('input[name="password"]').fill(PASSWORd); await page.locator('input[name="password_confirm"]').fill(PASSWORd); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click();//needs to focus out from the `password_confirm` field + + //wait for password-confirm matching confirmation + await page.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field + await expect(page.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); await screen(page, '/register/password'); @@ -72,8 +70,7 @@ test.describe('Tchap : register with password', () => { await expect(page.locator('div.cpd-form-message.cpd-form-error-message').filter({ hasText: 'Vous avez besoin d\'une invitation' })).toBeVisible(); }); - //skip because flakky - test.skip('tchap register with email on wrong server', async ({page, userData: user, screenChecker: screen, startTchapRegisterWithEmail }) => { + test('with email on wrong server', async ({page, userData: user, screenChecker: screen, startTchapRegisterWithEmail }) => { await startTchapRegisterWithEmail(page, user.email); @@ -86,13 +83,48 @@ test.describe('Tchap : register with password', () => { await page.locator('input[name="email"]').fill(wrong_server_user.email); await page.locator('input[name="password"]').fill(PASSWORd); await page.locator('input[name="password_confirm"]').fill(PASSWORd); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click();//needs to focus out from the `password_confirm` field + + //wait for password-confirm matching confirmation + await page.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field + await expect(page.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); await screen(page, '/register/password'); - await expect(page.locator('input[name="email"]')).toHaveValue(wrong_server_user.email); //check that error message is visible await expect(page.locator('div.cpd-form-message.cpd-form-error-message').filter({ hasText: 'Votre adresse mail est associée à un autre serveur' })).toBeVisible(); }); + test('when user already exists', async ({page, context, browser, screenChecker: screen, startTchapRegisterWithEmail }) => { + + // Create a test user with a password in MAS + const user = await createMasTestUser(STANDARD_EMAIL_DOMAIN); + + await startTchapRegisterWithEmail(page, user.email); + + await screen(page, '/register/password'); + await expect(page.locator('input[name="email"]')).toHaveValue(user.email); + + await page.locator('input[name="password"]').fill(PASSWORd); + await page.locator('input[name="password_confirm"]').fill(PASSWORd); + + //wait for password-confirm matching confirmation + await page.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field + await expect(page.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click();//needs to focus out from the `password_confirm` field + + //form is submitted successfully + await screen(page, '/verify-email'); + + let verificationCode = await extractVerificationCode(context, screen); + await page.locator('input[name="code"]').fill(verificationCode); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + await screen(page, '/finish'); + await expect(page.locator('text=le compte Tchap existe déjà')).toBeVisible(); + await page.getByRole('link').filter({ hasText: 'Continuer' }).click(); + + await screen(page, '/login'); + }); + }); diff --git a/playwright/utils/auth-helpers.ts b/playwright/utils/auth-helpers.ts index e102aee..790cfe5 100644 --- a/playwright/utils/auth-helpers.ts +++ b/playwright/utils/auth-helpers.ts @@ -261,14 +261,14 @@ export async function performSimplePasswordLogin( export function generateTestUserData(domain:string) { const timestamp = new Date().getTime(); const randomSuffix = Math.floor(Math.random() * 10000); - const kc_username = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}`; - const kc_email = `${kc_username}@${domain}`; + const username = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}`; + const email = `${username}@${domain}`; - console.log("Using kc_email: ", kc_email); + console.log("Using email: ", email); return { - username: kc_username, - email: kc_email, + username: username, + email: email, password: TEST_USER_PASSWORD }; } From 6a85a11a588f6a2c02ce09722e2682766ab85b29 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Tue, 9 Dec 2025 17:52:08 +0100 Subject: [PATCH 47/71] Upgrade/mas v1.8.0 rc.0 (#21) * upgrade/mas-v1.8.0 * update conf --- playwright/playwright.config.ts | 2 +- playwright/tests/auth/mas-login-oidc.spec.ts | 21 +------------------ .../tests/auth/mas-login-password.spec.ts | 3 --- .../tests/auth/mas-register-oidc.spec.ts | 18 ++++++---------- .../tests/auth/tchap-login-oidc.spec.ts | 21 +++++++------------ playwright/utils/auth-helpers.ts | 14 ++++++------- 6 files changed, 23 insertions(+), 56 deletions(-) diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts index a122e22..f8aeebe 100644 --- a/playwright/playwright.config.ts +++ b/playwright/playwright.config.ts @@ -11,7 +11,7 @@ dotenv.config(); export default defineConfig({ testDir: './tests', /* Maximum time one test can run for */ - timeout: 10 * 1000, + timeout: 15 * 1000, /* Run tests in files in parallel */ fullyParallel: process.env.TEST_IN_PARALLEL === 'true' ? true : false, diff --git a/playwright/tests/auth/mas-login-oidc.spec.ts b/playwright/tests/auth/mas-login-oidc.spec.ts index 37d8de4..5762639 100644 --- a/playwright/tests/auth/mas-login-oidc.spec.ts +++ b/playwright/tests/auth/mas-login-oidc.spec.ts @@ -15,16 +15,13 @@ test.describe('MAS Login OIDC', () => { // Create a user in MAS with the same email as the Keycloak user console.log(`Creating MAS user with same email as Keycloak user: ${oidcUser.email}`); - oidcUser.masId = await createMasUserWithPassword(oidcUser.username+"different_from_email", oidcUser.email, oidcUser.password); + oidcUser.masId = await createMasUserWithPassword(oidcUser.username, oidcUser.email, "any"); try { // Perform the OIDC login flow await performOidcLogin(page, oidcUser, screenshot_path); - // Click the link account button - await page.locator('button[type="submit"]').click(); - // Since the account already exists, we should be automatically logged in // Verify we're successfully logged in await expect(page.locator('text=Mon compte')).toBeVisible(); @@ -61,9 +58,6 @@ test.describe('MAS Login OIDC', () => { // Perform the OIDC login flow await performOidcLogin(page, externalUser, screenshot_path); - // Click the link account button - await page.locator('button[type="submit"]').click(); - // Since the account already exists, we should be automatically logged in // Verify we're successfully logged in await expect(page.locator('text=Mon compte')).toBeVisible(); @@ -102,9 +96,6 @@ test.describe('MAS Login OIDC', () => { // Perform the OIDC login flow await performOidcLogin(page, oidcUser, screenshot_path); - // Click the link account button - await page.locator('button[type="submit"]').click(); - // Since the account already exists, we should be automatically logged in // Verify we're successfully logged in await expect(page.locator('text=Mon compte')).toBeVisible(); @@ -146,9 +137,6 @@ test.describe('MAS Login OIDC', () => { // Perform the OIDC login flow with KC Account(=userLegacy) await performOidcLogin(page, userLegacy, screenshot_path); - - // Click the link account button - await page.locator('button[type="submit"]').click(); // Since the account already exists, we should be automatically logged in // Verify we're successfully logged in @@ -185,9 +173,6 @@ test.describe('MAS Login OIDC', () => { // Perform the OIDC login flow await performOidcLogin(page, userLegacy, screenshot_path); - - // Click the link account button - await page.locator('button[type="submit"]').click(); // Since the account already exists, we should be automatically logged in // Verify we're successfully logged in @@ -207,7 +192,6 @@ test.describe('MAS Login OIDC', () => { // deactvate account await deactivateMasUser(userLegacy.masId); - // SUPPORT PROCESS PERFORMED BY BOT ADMIN const links = await getOauthLinkBySubject(userLegacy.username); await deleteOauthLink(links[0]['id']) @@ -231,9 +215,6 @@ test.describe('MAS Login OIDC', () => { // Perform the OIDC login flow await performOidcLogin(page2, userLegacy, screenshot_path_2); - // Click the link account button - await page2.locator('button[type="submit"]').click(); - await page2.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path_2}/04-linked-account.png` }); // Since the account already exists, we should be automatically logged in diff --git a/playwright/tests/auth/mas-login-password.spec.ts b/playwright/tests/auth/mas-login-password.spec.ts index b3faf72..b38a636 100644 --- a/playwright/tests/auth/mas-login-password.spec.ts +++ b/playwright/tests/auth/mas-login-password.spec.ts @@ -1,13 +1,10 @@ import { test, expect } from '../../fixtures/auth-fixture'; import { - performOidcLogin, - verifyUserInMas, createMasTestUser, cleanupMasTestUser, performPasswordLogin, TestUser } from '../../utils/auth-helpers'; -import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser } from '../../utils/mas-admin'; import { SCREENSHOTS_DIR } from '../../utils/config'; test.describe('MAS login password', () => { diff --git a/playwright/tests/auth/mas-register-oidc.spec.ts b/playwright/tests/auth/mas-register-oidc.spec.ts index 4d5a18d..ccf2793 100644 --- a/playwright/tests/auth/mas-register-oidc.spec.ts +++ b/playwright/tests/auth/mas-register-oidc.spec.ts @@ -12,7 +12,7 @@ import { SCREENSHOTS_DIR } from '../../utils/config'; test.describe('MAS register OIDC', () => { - test('with allowed account', async ({ page, oidcUser: oidcUser }) => { + test('mas register oidc - with allowed account', async ({ page, oidcUser: oidcUser }) => { const screenshot_path = test.info().title.replace(" ", "_"); // Verify the test user doesn't exist in MAS yet @@ -21,9 +21,6 @@ test.describe('MAS register OIDC', () => { // Perform the OIDC login flow await performOidcLogin(page, oidcUser,screenshot_path); - - // Click the create account button - await page.locator('button[type="submit"]').click(); // Verify we're successfully logged in // This could be checking for a specific element that's only visible when logged in @@ -42,7 +39,7 @@ test.describe('MAS register OIDC', () => { console.log(`Successfully authenticated and verified user ${oidcUser.username} (${oidcUser.email})`); }); - test('with extern without invit', async ({ page, oidcExternalUserWitoutInvit: oidcExternalUserWitoutInvit }) => { + test('mas register oidc - with extern without invit', async ({ page, oidcExternalUserWitoutInvit: oidcExternalUserWitoutInvit }) => { const screenshot_path = test.info().title.replace(" ", "_"); // Verify the test user doesn't exist in MAS yet @@ -53,7 +50,7 @@ test.describe('MAS register OIDC', () => { await performOidcLogin(page, oidcExternalUserWitoutInvit, screenshot_path); // Get error - await page.locator('text=invitation_missing'); + await expect(page.locator('text=invitation_missing')).toBeVisible();; // Take a screenshot of the authenticated state await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-error-no-invit.png` }); @@ -66,7 +63,7 @@ test.describe('MAS register OIDC', () => { console.log(`Successfully authenticated and verified user ${oidcExternalUserWitoutInvit.username} (${oidcExternalUserWitoutInvit.email})`); }); - test('with extern with invit', async ({ page, oidcExternalUserWithInvit: oidcExternalUserWithInvit }) => { + test('mas register oidc - with extern with invit', async ({ page, oidcExternalUserWithInvit: oidcExternalUserWithInvit }) => { const screenshot_path = test.info().title.replace(" ", "_"); // Verify the test user doesn't exist in MAS yet @@ -75,9 +72,6 @@ test.describe('MAS register OIDC', () => { // Perform the OIDC login flow await performOidcLogin(page, oidcExternalUserWithInvit, screenshot_path); - - // Click the create account button - await page.locator('button[type="submit"]').click(); // Verify we're successfully logged in await expect(page.locator('text=Mon compte')).toBeVisible(); @@ -95,7 +89,7 @@ test.describe('MAS register OIDC', () => { console.log(`Successfully authenticated and verified external user ${oidcExternalUserWithInvit.username} (${oidcExternalUserWithInvit.email})`); }); - test('on wrong homeserver', async ({ page, oidcUserOnWrongServer: oidcUserOnWrongServer }) => { + test('mas register oidc - on wrong homeserver', async ({ page, oidcUserOnWrongServer: oidcUserOnWrongServer }) => { const screenshot_path = test.info().title.replace(" ", "_"); // Verify the test user doesn't exist in MAS yet @@ -106,7 +100,7 @@ test.describe('MAS register OIDC', () => { await performOidcLogin(page, oidcUserOnWrongServer, screenshot_path); // Get error - await page.locator('text=wrong_server'); + await expect(page.locator('text=wrong_server')).toBeVisible(); // Take a screenshot of the authenticated state await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-error-wrong-server.png` }); diff --git a/playwright/tests/auth/tchap-login-oidc.spec.ts b/playwright/tests/auth/tchap-login-oidc.spec.ts index d47a879..8d54459 100644 --- a/playwright/tests/auth/tchap-login-oidc.spec.ts +++ b/playwright/tests/auth/tchap-login-oidc.spec.ts @@ -9,24 +9,19 @@ import { SCREENSHOTS_DIR, TCHAP_LEGACY } from '../../utils/config'; //flaky on await expect(page.locator('text=Configuration')).toBeVisible({timeout: 20000}); test.describe('Tchap : Login via OIDC', () => { - test('tchap match account by username', async ({ page, oidcUser: existingOidcUser }) => { + + test('tchap match account by email', async ({ page, oidcUser: oidcUser }) => { const screenshot_path = test.info().title.replace(" ", "_"); - existingOidcUser.masId = await createMasUserWithPassword(existingOidcUser.username, existingOidcUser.email, existingOidcUser.password); + oidcUser.masId = await createMasUserWithPassword(oidcUser.username, oidcUser.email, oidcUser.password); // Verify the test user doesn't exist in MAS yet - const existsBeforeLogin = await checkMasUserExistsByEmail(existingOidcUser.email); + const existsBeforeLogin = await checkMasUserExistsByEmail(oidcUser.email); expect(existsBeforeLogin).toBe(true); // Perform the OIDC login flow - await performOidcLoginFromTchap(page, existingOidcUser,screenshot_path, TCHAP_LEGACY); - - // Click the create account button - await page.locator('button[type="submit"]').click(); + await performOidcLoginFromTchap(page, oidcUser,screenshot_path, TCHAP_LEGACY); - // Verify we're successfully logged in, confirgmation page - await expect(page.locator('text=Continuer')).toBeVisible(); - // Take a screenshot of the authenticated state await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/05-confirmation.png` }); @@ -39,12 +34,12 @@ test.describe('Tchap : Login via OIDC', () => { await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/06-auth-success.png` }); // Verify the user was created in MAS - await verifyUserInMas(existingOidcUser); + await verifyUserInMas(oidcUser); // Double-check with the API - const existsAfterLogin = await checkMasUserExistsByEmail(existingOidcUser.email); + const existsAfterLogin = await checkMasUserExistsByEmail(oidcUser.email); expect(existsAfterLogin).toBe(true); - console.log(`Successfully authenticated and verified user ${existingOidcUser.username} (${existingOidcUser.email})`); + console.log(`Successfully authenticated and verified user ${oidcUser.username} (${oidcUser.email})`); }); }); diff --git a/playwright/utils/auth-helpers.ts b/playwright/utils/auth-helpers.ts index 790cfe5..64010d6 100644 --- a/playwright/utils/auth-helpers.ts +++ b/playwright/utils/auth-helpers.ts @@ -56,7 +56,7 @@ export async function performOidcLogin(page: Page, user: TestUser, screenshot_pa await page.goto('/login'); // Take a screenshot of the login page - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-login-page.png` }); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-login-page.png`, fullPage:true }); // Find and click the OIDC provider button (adjust the selector as needed) // This is based on the login.html template which shows provider buttons @@ -69,7 +69,7 @@ export async function performOidcLogin(page: Page, user: TestUser, screenshot_pa await page.waitForURL(url => url.toString().includes(KEYCLOAK_URL)); // Take a screenshot of the Keycloak login page - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-keycloak-login.png` }); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-keycloak-login.png`, fullPage:true }); // Fill in the username and password await page.locator('#username').fill(user.username); @@ -82,7 +82,7 @@ export async function performOidcLogin(page: Page, user: TestUser, screenshot_pa await page.waitForURL(url => url.toString().includes(MAS_URL)); // Take a screenshot after successful login - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-after-login.png` }); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-after-keycloak-login.png`, fullPage:true }); } /** @@ -93,7 +93,7 @@ export async function performOidcLoginFromTchap(page: Page, user: TestUser, scre //we go to the welcome and then to the login page because sometimes the email field disapears await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-tchap-login-page.png` }); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-tchap-login-page.png`, fullPage:true }); await page.getByRole('link').filter({hasText : "Se connecter par email"}).click(); @@ -106,7 +106,7 @@ export async function performOidcLoginFromTchap(page: Page, user: TestUser, scre await page.waitForURL(url => url.toString().includes(MAS_URL)); // Take a screenshot of the MAS login page - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-mas-login-page.png` }); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-mas-login-page.png`, fullPage:true }); // Find and click the OIDC provider button //const oidcButton = page.locator('a.cpd-button[href*="/upstream/authorize/"]'); @@ -118,7 +118,7 @@ export async function performOidcLoginFromTchap(page: Page, user: TestUser, scre await page.waitForURL(url => url.toString().includes(KEYCLOAK_URL)); // Take a screenshot of the Keycloak login page - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-keycloak-login.png` }); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-keycloak-login.png`, fullPage:true }); // Fill in the username and password await page.locator('#username').fill(user.username); @@ -131,7 +131,7 @@ export async function performOidcLoginFromTchap(page: Page, user: TestUser, scre await page.waitForURL(url => url.toString().includes(MAS_URL)); // Take a screenshot after successful login - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-after-login.png` }); + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-after-login.png` , fullPage:true }); } /** From 366e7af627cff2cbb3353debb8a12450271afa2f Mon Sep 17 00:00:00 2001 From: Olivier D Date: Tue, 16 Dec 2025 11:56:28 +0100 Subject: [PATCH 48/71] add tests : reset password (#23) * update text * add register pwd test * update name * update test --- playwright/tests/auth/mas-login-oidc.spec.ts | 12 ++--- .../tests/auth/mas-login-password.spec.ts | 2 +- .../tests/auth/mas-register-oidc.spec.ts | 4 +- .../auth/tchap-register-password.spec.ts | 8 +-- .../tests/auth/tchap-reset-password.spec.ts | 51 +++++++++++++++++++ playwright/utils/auth-helpers.ts | 43 +++++++++++++++- 6 files changed, 105 insertions(+), 15 deletions(-) create mode 100644 playwright/tests/auth/tchap-reset-password.spec.ts diff --git a/playwright/tests/auth/mas-login-oidc.spec.ts b/playwright/tests/auth/mas-login-oidc.spec.ts index 5762639..025072d 100644 --- a/playwright/tests/auth/mas-login-oidc.spec.ts +++ b/playwright/tests/auth/mas-login-oidc.spec.ts @@ -24,7 +24,7 @@ test.describe('MAS Login OIDC', () => { // Since the account already exists, we should be automatically logged in // Verify we're successfully logged in - await expect(page.locator('text=Mon compte')).toBeVisible(); + await expect(page.locator('text=Connecté')).toBeVisible(); // Take a screenshot of the authenticated state await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); @@ -60,7 +60,7 @@ test.describe('MAS Login OIDC', () => { // Since the account already exists, we should be automatically logged in // Verify we're successfully logged in - await expect(page.locator('text=Mon compte')).toBeVisible(); + await expect(page.locator('text=Connecté')).toBeVisible(); // Take a screenshot of the authenticated state await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); @@ -98,7 +98,7 @@ test.describe('MAS Login OIDC', () => { // Since the account already exists, we should be automatically logged in // Verify we're successfully logged in - await expect(page.locator('text=Mon compte')).toBeVisible(); + await expect(page.locator('text=Connecté')).toBeVisible(); // Take a screenshot of the authenticated state await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); @@ -140,7 +140,7 @@ test.describe('MAS Login OIDC', () => { // Since the account already exists, we should be automatically logged in // Verify we're successfully logged in - await expect(page.locator('text=Mon compte')).toBeVisible(); + await expect(page.locator('text=Connecté')).toBeVisible(); // Take a screenshot of the authenticated state await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); @@ -176,7 +176,7 @@ test.describe('MAS Login OIDC', () => { // Since the account already exists, we should be automatically logged in // Verify we're successfully logged in - await expect(page.locator('text=Mon compte')).toBeVisible(); + await expect(page.locator('text=Connecté')).toBeVisible(); // Take a screenshot of the authenticated state await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); @@ -219,7 +219,7 @@ test.describe('MAS Login OIDC', () => { // Since the account already exists, we should be automatically logged in // Verify we're successfully logged in - await expect(page2.locator('text=Mon compte')).toBeVisible(); + await expect(page2.locator('text=Connecté')).toBeVisible(); // Take a screenshot of the authenticated state await page2.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path_2}/05-connected-account.png` }); diff --git a/playwright/tests/auth/mas-login-password.spec.ts b/playwright/tests/auth/mas-login-password.spec.ts index b38a636..159f90b 100644 --- a/playwright/tests/auth/mas-login-password.spec.ts +++ b/playwright/tests/auth/mas-login-password.spec.ts @@ -22,7 +22,7 @@ test.describe('MAS login password', () => { await performPasswordLogin(page, user,screenshot_path); // Verify we're successfully logged in - await expect(page.locator('text=Mon compte')).toBeVisible(); + await expect(page.locator('text=Connecté')).toBeVisible(); // Take a screenshot of the authenticated state await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-password-auth-success.png` }); diff --git a/playwright/tests/auth/mas-register-oidc.spec.ts b/playwright/tests/auth/mas-register-oidc.spec.ts index ccf2793..bebf1b1 100644 --- a/playwright/tests/auth/mas-register-oidc.spec.ts +++ b/playwright/tests/auth/mas-register-oidc.spec.ts @@ -24,7 +24,7 @@ test.describe('MAS register OIDC', () => { // Verify we're successfully logged in // This could be checking for a specific element that's only visible when logged in - await expect(page.locator('text=Mon compte')).toBeVisible(); + await expect(page.locator('text=Connecté')).toBeVisible(); // Take a screenshot of the authenticated state await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-authenticated.png` }); @@ -74,7 +74,7 @@ test.describe('MAS register OIDC', () => { await performOidcLogin(page, oidcExternalUserWithInvit, screenshot_path); // Verify we're successfully logged in - await expect(page.locator('text=Mon compte')).toBeVisible(); + await expect(page.locator('text=Connecté')).toBeVisible(); // Take a screenshot of the authenticated state await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-authenticated-external.png` }); diff --git a/playwright/tests/auth/tchap-register-password.spec.ts b/playwright/tests/auth/tchap-register-password.spec.ts index d18fde2..8914b07 100644 --- a/playwright/tests/auth/tchap-register-password.spec.ts +++ b/playwright/tests/auth/tchap-register-password.spec.ts @@ -26,7 +26,7 @@ test.describe('Tchap : register with password', () => { //wait for password-confirm matching confirmation await page.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field await expect(page.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click({clickCount:2}); //2 clicks works better than one await screen(page, '/verify-email'); let verificationCode = await extractVerificationCode(context, screen); @@ -61,7 +61,7 @@ test.describe('Tchap : register with password', () => { //wait for password-confirm matching confirmation await page.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field await expect(page.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click({clickCount:2}); await screen(page, '/register/password'); await expect(page.locator('input[name="email"]')).toHaveValue(not_invited_user.email); @@ -87,7 +87,7 @@ test.describe('Tchap : register with password', () => { //wait for password-confirm matching confirmation await page.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field await expect(page.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click({clickCount:2}); await screen(page, '/register/password'); @@ -111,7 +111,7 @@ test.describe('Tchap : register with password', () => { //wait for password-confirm matching confirmation await page.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field await expect(page.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click();//needs to focus out from the `password_confirm` field + await page.getByRole('button').filter({ hasText: 'Continuer' }).click({clickCount:2}); //form is submitted successfully await screen(page, '/verify-email'); diff --git a/playwright/tests/auth/tchap-reset-password.spec.ts b/playwright/tests/auth/tchap-reset-password.spec.ts new file mode 100644 index 0000000..143477e --- /dev/null +++ b/playwright/tests/auth/tchap-reset-password.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '../../fixtures/auth-fixture'; +import { checkMasUserExistsByEmail, createMasUserWithPassword } from '../../utils/mas-admin'; +import { ELEMENT_URL } from '../../utils/config'; +import { openResetPasswordEmail } from '../../utils/auth-helpers'; + + +test.describe('Tchap : reset password', () => { + + test('tchap reset password', async ({ page, userData: userData,screenChecker }) => { + + userData.masId = await createMasUserWithPassword(userData.username, userData.email, userData.password); + const existsBeforeLogin = await checkMasUserExistsByEmail(userData.email); + expect(existsBeforeLogin).toBe(true); + + await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); + + await screenChecker(page, '/welcome') + await page.getByRole('link').filter({hasText : "Se connecter par email"}).click(); + await page.locator('input').fill(userData.email); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + + //MAS login + await screenChecker(page, '/login') + await page.getByRole('link').filter({ hasText: "Mot de passe oublié" }).click(); + + //MAS reset password + await page.locator('input[name="email"]').fill(userData.email); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + //MAS reset password - verify + await screenChecker(page, '/recover/progress') + + const resetPwdPage = await openResetPasswordEmail(page.context(), screenChecker); + + const newPassword = "monchienmangemapantoufle" + await resetPwdPage.locator('input[name="new_password"]').fill(newPassword); + await resetPwdPage.locator('input[name="new_password_again"]').fill(newPassword); + await resetPwdPage.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field + await expect(resetPwdPage.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); + await resetPwdPage.getByRole('button').filter({ hasText: 'Sauvegarder et continuer' }).click({clickCount:2}); + + //new tab is redirected back to welcome page + await expect(resetPwdPage.getByRole('link').filter({ hasText: 'Continuer dans Tchap' })).toBeVisible(); + await screenChecker(resetPwdPage, '/') + + //first tab is stuck back to recovery page + await screenChecker(page, '/recover/progress') + + }); +}); diff --git a/playwright/utils/auth-helpers.ts b/playwright/utils/auth-helpers.ts index 64010d6..bec44e4 100644 --- a/playwright/utils/auth-helpers.ts +++ b/playwright/utils/auth-helpers.ts @@ -1,4 +1,4 @@ -import { BrowserContext, Page } from '@playwright/test'; +import { BrowserContext, Frame, Page } from '@playwright/test'; import { createKeycloakUser, deleteKeycloakUser } from './keycloak-admin'; import { waitForMasUser, createMasUserWithPassword, deactivateMasUser } from './mas-admin'; import { ELEMENT_URL, KEYCLOAK_URL, MAS_URL, SCREENSHOTS_DIR, TEST_USER_PASSWORD, TEST_USER_PREFIX } from './config'; @@ -198,7 +198,7 @@ export async function performPasswordLogin(page: Page, user: TestUser, screensho - // Add this function to extract the verification code +// Add this function to extract the verification code export async function extractVerificationCode(context: BrowserContext, waitForScreen:ScreenCheckerFixture): Promise { // Create a new page for mail.tchapgouv.com const page = await context.newPage(); @@ -230,6 +230,45 @@ export async function extractVerificationCode(context: BrowserContext, waitForSc return verificationCode; } +// open reset password screen from email +export async function openResetPasswordEmail(context: BrowserContext, screenChecker:ScreenCheckerFixture): Promise { + // Create a new page for mail.tchapgouv.com + const page = await context.newPage(); + + // Navigate to mail.tchapgouv.com + await page.goto('https://mail.tchapgouv.com'); + + // Wait for the page to load and click on the first email + await page.waitForSelector('.msglist-message'); + + await screenChecker(page, 'mail.tchapgouv.com'); + + //open first email + await page.locator('.col-md-5').first().click(); + + dumpFrameTree(page.mainFrame(), ''); + + + function dumpFrameTree(frame:Frame, indent:string) { + console.log(indent + frame.url()); + for (const child of frame.childFrames()) + dumpFrameTree(child, indent + ' '); + } + + + + const [resetPasswordPage] = await Promise.all([ + context.waitForEvent('page'), + await page.frameLocator('#preview-html').getByRole('link').filter({ hasText: "Réinitialiser mon mot de passe" }).click() + //await page.getByRole('link').filter({ hasText: "Réinitialiser mon mot de passe" }).click() + ]) + + await screenChecker(resetPasswordPage, 'account/password/recovery'); + + return resetPasswordPage; +} + + /** * Same as performPasswordLogin but without the screenshots From f362cf164985c0c5e3156f5234d79c72bbee65a1 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Tue, 16 Dec 2025 15:45:40 +0100 Subject: [PATCH 49/71] add logout test (#22) * add logout test * update logout with new user menu in tchap web --- .../tests/auth/tchap-login-password.spec.ts | 21 ++++---- playwright/tests/auth/tchap-logout.spec.ts | 48 +++++++++++++++++++ playwright/utils/auth-helpers.ts | 29 ++++++++++- 3 files changed, 86 insertions(+), 12 deletions(-) create mode 100644 playwright/tests/auth/tchap-logout.spec.ts diff --git a/playwright/tests/auth/tchap-login-password.spec.ts b/playwright/tests/auth/tchap-login-password.spec.ts index e7aebed..d864878 100644 --- a/playwright/tests/auth/tchap-login-password.spec.ts +++ b/playwright/tests/auth/tchap-login-password.spec.ts @@ -1,11 +1,13 @@ import { test, expect } from '../../fixtures/auth-fixture'; import { checkMasUserExistsByEmail, createMasUserWithPassword } from '../../utils/mas-admin'; import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from '../../utils/config'; +import { Page } from '@playwright/test'; +import { loginWithPassword } from '../../utils/auth-helpers'; test.describe('Tchap : Login password', () => { - test('tchap login with password and login_hint', async ({ page, userData: userData }) => { + test('tchap login with password and login_hint', async ({ page, userData: userData, screenChecker }) => { const screenshot_path = test.info().title.replace(" ", "_"); userData.masId = await createMasUserWithPassword(userData.username, userData.email, userData.password); @@ -14,27 +16,24 @@ test.describe('Tchap : Login password', () => { await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); - //welcome - await page.waitForURL(url => url.toString().includes(`#/welcome`)); - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-tchap-login-page.png` }); + await screenChecker(page, `#/welcome`) await page.getByRole('link').filter({hasText : "Se connecter par email"}).click(); - await page.waitForURL(url => url.toString().includes(`#/email-precheck-sso`)); + + await screenChecker(page, `#/email-precheck-sso`) await page.locator('input').fill(userData.email); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); //login - await page.waitForURL(url => url.toString().includes(`${MAS_URL}/login`)); + await screenChecker(page, `/login`) await expect(page.locator('input[name="username"]')).toHaveValue(userData.email); await page.locator('input[name="password"]').fill(userData.password); - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-password-login-filled.png` }); await page.locator('button[type="submit"]').click(); - + + //consent - await page.waitForURL(url => url.toString().includes('/consent')); - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-consent_page.png` }); + await screenChecker(page, `/consent`) await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - //tchap await expect(page.locator('text=Bienvenue')).toBeVisible({timeout: 20000}); await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/05-auth-success.png` }); diff --git a/playwright/tests/auth/tchap-logout.spec.ts b/playwright/tests/auth/tchap-logout.spec.ts new file mode 100644 index 0000000..0d88f86 --- /dev/null +++ b/playwright/tests/auth/tchap-logout.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from '../../fixtures/auth-fixture'; +import { checkMasUserExistsByEmail, createMasUserWithPassword } from '../../utils/mas-admin'; +import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from '../../utils/config'; +import { Page } from '@playwright/test'; +import { loginWithPassword } from '../../utils/auth-helpers'; + + +test.describe('Tchap : logout', () => { + + test('tchap login-logout-login', async ({ page, userData: userData, screenChecker }) => { + userData.masId = await createMasUserWithPassword(userData.username, userData.email, userData.password); + + // First login + await loginWithPassword(page, userData, screenChecker); + + // Success - Tchap home + await expect(page.locator('text=Bienvenue')).toBeVisible({timeout: 20000}); + + //logout + await page.getByLabel('Avatar').click(); + //await screenChecker(page, `/`) + await page.getByRole('button', { name: 'Se déconnecter' }).click(); + await screenChecker(page, `/welcome`) + + // Second login + await page.getByRole('link').filter({hasText : "Se connecter par email"}).click(); + + // Email precheck + await screenChecker(page, `#/email-precheck-sso`); + await page.locator('input').fill(userData.email); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + // Login page + await screenChecker(page, `/login`); + await expect(page.locator('input[name="username"]')).toHaveValue(userData.email); + await page.locator('input[name="password"]').fill(userData.password); + await page.locator('button[type="submit"]').click(); + + // Consent page + await screenChecker(page, `/consent`); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + // Success - Confirm identity + await expect(page.locator('text=Confirmez votre identité')).toBeVisible({timeout: 20000}); + await screenChecker(page, `/`); + }); + +}); diff --git a/playwright/utils/auth-helpers.ts b/playwright/utils/auth-helpers.ts index bec44e4..ce4e6af 100644 --- a/playwright/utils/auth-helpers.ts +++ b/playwright/utils/auth-helpers.ts @@ -1,4 +1,4 @@ -import { BrowserContext, Frame, Page } from '@playwright/test'; +import { BrowserContext, expect, Frame, Page } from '@playwright/test'; import { createKeycloakUser, deleteKeycloakUser } from './keycloak-admin'; import { waitForMasUser, createMasUserWithPassword, deactivateMasUser } from './mas-admin'; import { ELEMENT_URL, KEYCLOAK_URL, MAS_URL, SCREENSHOTS_DIR, TEST_USER_PASSWORD, TEST_USER_PREFIX } from './config'; @@ -269,6 +269,33 @@ export async function openResetPasswordEmail(context: BrowserContext, screenChec } +export async function loginWithPassword( + page: Page, + userData: { email: string; password: string }, + screenChecker: Function + ) { + await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); + + // Welcome page + await screenChecker(page, `#/welcome`); + await page.getByRole('link').filter({hasText : "Se connecter par email"}).click(); + + // Email precheck + await screenChecker(page, `#/email-precheck-sso`); + await page.locator('input').fill(userData.email); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + // Login page + await screenChecker(page, `/login`); + await expect(page.locator('input[name="username"]')).toHaveValue(userData.email); + await page.locator('input[name="password"]').fill(userData.password); + await page.locator('button[type="submit"]').click(); + + // Consent page + await screenChecker(page, `/consent`); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + } /** * Same as performPasswordLogin but without the screenshots From 616e5f4066b71f936bbd6ae5a2f7644f57126662 Mon Sep 17 00:00:00 2001 From: olivierdelcroix Date: Wed, 7 Jan 2026 14:43:59 +0100 Subject: [PATCH 50/71] update Button Se connecter --- playwright/tests/auth/tchap-login-password.spec.ts | 2 +- playwright/tests/auth/tchap-logout.spec.ts | 2 +- playwright/tests/auth/tchap-reset-password.spec.ts | 2 +- playwright/utils/auth-helpers.ts | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/playwright/tests/auth/tchap-login-password.spec.ts b/playwright/tests/auth/tchap-login-password.spec.ts index d864878..3d875b8 100644 --- a/playwright/tests/auth/tchap-login-password.spec.ts +++ b/playwright/tests/auth/tchap-login-password.spec.ts @@ -17,7 +17,7 @@ test.describe('Tchap : Login password', () => { await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); await screenChecker(page, `#/welcome`) - await page.getByRole('link').filter({hasText : "Se connecter par email"}).click(); + await page.getByRole('link').filter({hasText : "Se connecter"}).click(); await screenChecker(page, `#/email-precheck-sso`) await page.locator('input').fill(userData.email); diff --git a/playwright/tests/auth/tchap-logout.spec.ts b/playwright/tests/auth/tchap-logout.spec.ts index 0d88f86..bee8489 100644 --- a/playwright/tests/auth/tchap-logout.spec.ts +++ b/playwright/tests/auth/tchap-logout.spec.ts @@ -23,7 +23,7 @@ test.describe('Tchap : logout', () => { await screenChecker(page, `/welcome`) // Second login - await page.getByRole('link').filter({hasText : "Se connecter par email"}).click(); + await page.getByRole('link').filter({hasText : "Se connecter"}).click(); // Email precheck await screenChecker(page, `#/email-precheck-sso`); diff --git a/playwright/tests/auth/tchap-reset-password.spec.ts b/playwright/tests/auth/tchap-reset-password.spec.ts index 143477e..aaa7b51 100644 --- a/playwright/tests/auth/tchap-reset-password.spec.ts +++ b/playwright/tests/auth/tchap-reset-password.spec.ts @@ -15,7 +15,7 @@ test.describe('Tchap : reset password', () => { await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); await screenChecker(page, '/welcome') - await page.getByRole('link').filter({hasText : "Se connecter par email"}).click(); + await page.getByRole('link').filter({hasText : "Se connecter"}).click(); await page.locator('input').fill(userData.email); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); diff --git a/playwright/utils/auth-helpers.ts b/playwright/utils/auth-helpers.ts index ce4e6af..f45bf72 100644 --- a/playwright/utils/auth-helpers.ts +++ b/playwright/utils/auth-helpers.ts @@ -95,7 +95,7 @@ export async function performOidcLoginFromTchap(page: Page, user: TestUser, scre await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-tchap-login-page.png`, fullPage:true }); - await page.getByRole('link').filter({hasText : "Se connecter par email"}).click(); + await page.getByRole('link').filter({hasText : "Se connecter"}).click(); await page.locator('input').fill(user.email); @@ -278,7 +278,7 @@ export async function loginWithPassword( // Welcome page await screenChecker(page, `#/welcome`); - await page.getByRole('link').filter({hasText : "Se connecter par email"}).click(); + await page.getByRole('link').filter({hasText : "Se connecter"}).click(); // Email precheck await screenChecker(page, `#/email-precheck-sso`); From bd784518c94db8e11722f4f50b2bd254c00302c8 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Wed, 14 Jan 2026 10:01:26 +0100 Subject: [PATCH 51/71] use mailpit for verification code (#25) * use mailpit for verification code * use API to get reset password email --- playwright/.env.dev01 | 1 + playwright/.env.local | 6 +- playwright/.env.sample | 5 + playwright/package-lock.json | 292 ++++++++++++++++++ playwright/package.json | 3 +- .../tests/auth/mas-register-password.spec.ts | 2 - .../auth/tchap-register-password.spec.ts | 6 +- .../tests/auth/tchap-reset-password.spec.ts | 2 +- playwright/utils/auth-helpers.ts | 72 +---- playwright/utils/config.ts | 2 + playwright/utils/mailpit.ts | 124 ++++++++ 11 files changed, 442 insertions(+), 73 deletions(-) create mode 100644 playwright/utils/mailpit.ts diff --git a/playwright/.env.dev01 b/playwright/.env.dev01 index f354703..1a1e5bd 100644 --- a/playwright/.env.dev01 +++ b/playwright/.env.dev01 @@ -3,6 +3,7 @@ MAS_URL=https://auth.dev01.tchap.incubateur.net KEYCLOAK_URL=https://identite-sandbox.proconnect.gouv.fr/users/start-sign-in ELEMENT_URL=https://tchap.incubateur.net BASE_URL=https://matrix.dev01.tchap.incubateur.net +MAIL_URL=https://mail.tchapgouv.com # Keycloak Admin Credentials #KEYCLOAK_ADMIN_USERNAME=admin diff --git a/playwright/.env.local b/playwright/.env.local index 48ae443..3668079 100644 --- a/playwright/.env.local +++ b/playwright/.env.local @@ -3,6 +3,7 @@ MAS_URL=https://auth.tchapgouv.com KEYCLOAK_URL=https://sso.tchapgouv.com ELEMENT_URL=https://element.tchapgouv.com BASE_URL=https://matrix.dev01.tchap.incubateur.net +MAIL_URL=https://mail.tchapgouv.com # Keycloak Admin Credentials KEYCLOAK_ADMIN_USERNAME=admin @@ -34,4 +35,7 @@ BROWSER_LOCALE=fr-FR SCREENSHOTS_DIR=playwright-results/local #Run tests in parallel -TEST_IN_PARALLEL=false \ No newline at end of file +TEST_IN_PARALLEL=false + +#disable tls verification in nodeJS (required for axios to work with self signed certificate) +NODE_TLS_REJECT_UNAUTHORIZED = '0' \ No newline at end of file diff --git a/playwright/.env.sample b/playwright/.env.sample index d1f7e40..2d15b81 100644 --- a/playwright/.env.sample +++ b/playwright/.env.sample @@ -2,6 +2,8 @@ MAS_URL=https://auth.tchapgouv.com KEYCLOAK_URL=https://sso.tchapgouv.com ELEMENT_URL=https://element.tchapgouv.com +BASE_URL=https://matrix.tchapgouv.com +MAIL_URL=https://mail.tchapgouv.com # old login page flow TCHAP_LEGACY = false @@ -31,3 +33,6 @@ SCREENSHOTS_DIR=playwright-results # Browser Locale BROWSER_LOCALE=fr-FR + +#disable tls verification in nodeJS (required for axios to work with self signed certificate) +NODE_TLS_REJECT_UNAUTHORIZED = '0' \ No newline at end of file diff --git a/playwright/package-lock.json b/playwright/package-lock.json index d73d87c..2a91404 100644 --- a/playwright/package-lock.json +++ b/playwright/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { + "mailpit-api": "^1.5.4", "@playwright/test": "^1.40.0", "@types/node": "^20.10.0", "dotenv": "^16.3.1", @@ -39,6 +40,57 @@ "undici-types": "~6.19.2" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dotenv": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", @@ -51,6 +103,101 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -65,6 +212,145 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mailpit-api": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/mailpit-api/-/mailpit-api-1.5.4.tgz", + "integrity": "sha512-Zz7qrNHF7p67sDn7VzBmP3Djk7eJNcBRiwCKeiWl5ADRou14++wvP1Mq9XD6sLyU5sQ/tgo3rXbmwTbNx1uY0A==", + "license": "MIT", + "dependencies": { + "axios": "^1.12.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/playwright": { "version": "1.51.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.1.tgz", @@ -95,6 +381,12 @@ "node": ">=18" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", diff --git a/playwright/package.json b/playwright/package.json index 153f3ba..3c140b3 100644 --- a/playwright/package.json +++ b/playwright/package.json @@ -23,6 +23,7 @@ "@playwright/test": "^1.40.0", "@types/node": "^20.10.0", "dotenv": "^16.3.1", - "typescript": "^5.3.2" + "typescript": "^5.3.2", + "mailpit-api": "^1.5.4" } } diff --git a/playwright/tests/auth/mas-register-password.spec.ts b/playwright/tests/auth/mas-register-password.spec.ts index c4518da..f695055 100644 --- a/playwright/tests/auth/mas-register-password.spec.ts +++ b/playwright/tests/auth/mas-register-password.spec.ts @@ -1,6 +1,4 @@ import { test, expect } from '../../fixtures/auth-fixture'; -import { createMasTestUser, extractVerificationCode } from '../../utils/auth-helpers'; -import {STANDARD_EMAIL_DOMAIN } from '../../utils/config'; test.describe('MAS Register password', () => { diff --git a/playwright/tests/auth/tchap-register-password.spec.ts b/playwright/tests/auth/tchap-register-password.spec.ts index 8914b07..3c770c8 100644 --- a/playwright/tests/auth/tchap-register-password.spec.ts +++ b/playwright/tests/auth/tchap-register-password.spec.ts @@ -1,11 +1,11 @@ import { test, expect } from '../../fixtures/auth-fixture'; import { createMasTestUser, - extractVerificationCode, generateTestUserData } from '../../utils/auth-helpers'; import { getMasUserByEmail } from '../../utils/mas-admin'; import {ELEMENT_URL, NOT_INVITED_EMAIL_DOMAIN, STANDARD_EMAIL_DOMAIN, WRONG_SERVER_EMAIL_DOMAIN } from '../../utils/config'; +import { getLatestVerificationCode } from '../../utils/mailpit'; test.describe('Tchap : register with password', () => { @@ -29,7 +29,7 @@ test.describe('Tchap : register with password', () => { await page.getByRole('button').filter({ hasText: 'Continuer' }).click({clickCount:2}); //2 clicks works better than one await screen(page, '/verify-email'); - let verificationCode = await extractVerificationCode(context, screen); + let verificationCode = await getLatestVerificationCode(user.email); await page.locator('input[name="code"]').fill(verificationCode); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); @@ -116,7 +116,7 @@ test.describe('Tchap : register with password', () => { //form is submitted successfully await screen(page, '/verify-email'); - let verificationCode = await extractVerificationCode(context, screen); + let verificationCode = await getLatestVerificationCode(user.email); await page.locator('input[name="code"]').fill(verificationCode); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); diff --git a/playwright/tests/auth/tchap-reset-password.spec.ts b/playwright/tests/auth/tchap-reset-password.spec.ts index aaa7b51..33f807b 100644 --- a/playwright/tests/auth/tchap-reset-password.spec.ts +++ b/playwright/tests/auth/tchap-reset-password.spec.ts @@ -31,7 +31,7 @@ test.describe('Tchap : reset password', () => { //MAS reset password - verify await screenChecker(page, '/recover/progress') - const resetPwdPage = await openResetPasswordEmail(page.context(), screenChecker); + const resetPwdPage = await openResetPasswordEmail(page.context(), screenChecker, userData.email); const newPassword = "monchienmangemapantoufle" await resetPwdPage.locator('input[name="new_password"]').fill(newPassword); diff --git a/playwright/utils/auth-helpers.ts b/playwright/utils/auth-helpers.ts index f45bf72..bb35ee8 100644 --- a/playwright/utils/auth-helpers.ts +++ b/playwright/utils/auth-helpers.ts @@ -4,7 +4,7 @@ import { waitForMasUser, createMasUserWithPassword, deactivateMasUser } from './ import { ELEMENT_URL, KEYCLOAK_URL, MAS_URL, SCREENSHOTS_DIR, TEST_USER_PASSWORD, TEST_USER_PREFIX } from './config'; import { Credentials } from './api'; import { ScreenCheckerFixture } from '../fixtures/auth-fixture'; - +import { getPasswordResetLink } from './mailpit.js'; /** * Test user type */ @@ -196,72 +196,14 @@ export async function performPasswordLogin(page: Page, user: TestUser, screensho console.log(`[Auth] Password login successful for user: ${user.username}`); } - - -// Add this function to extract the verification code -export async function extractVerificationCode(context: BrowserContext, waitForScreen:ScreenCheckerFixture): Promise { - // Create a new page for mail.tchapgouv.com - const page = await context.newPage(); - - // Navigate to mail.tchapgouv.com - await page.goto('https://mail.tchapgouv.com'); - - // Wait for the page to load and click on the first email - await page.waitForSelector('.msglist-message'); - - await waitForScreen(page, 'mail.tchapgouv.com'); - - const firstIncompingMail = await page.locator('.col-md-5').first(); - - let codeText = await firstIncompingMail.textContent(); - if (!codeText) { - throw new Error('Unable to extract verification code'); - } - console.log(codeText); - codeText = codeText.trim(); - console.log(codeText); - const codeMatch = codeText.match(/.*: (\d+)/); - if (!codeMatch) { - throw new Error('Unable to extract verification code from text'); - } - const verificationCode = codeMatch[1]; - console.log("verification code extracted : ", verificationCode); - - return verificationCode; -} - // open reset password screen from email -export async function openResetPasswordEmail(context: BrowserContext, screenChecker:ScreenCheckerFixture): Promise { - // Create a new page for mail.tchapgouv.com - const page = await context.newPage(); - - // Navigate to mail.tchapgouv.com - await page.goto('https://mail.tchapgouv.com'); - - // Wait for the page to load and click on the first email - await page.waitForSelector('.msglist-message'); - - await screenChecker(page, 'mail.tchapgouv.com'); - - //open first email - await page.locator('.col-md-5').first().click(); - - dumpFrameTree(page.mainFrame(), ''); - +export async function openResetPasswordEmail(context: BrowserContext, screenChecker:ScreenCheckerFixture, userEmail: string): Promise { + // Use Mailpit API to get password reset link instead of browser automation + const resetLink = await getPasswordResetLink(userEmail); - function dumpFrameTree(frame:Frame, indent:string) { - console.log(indent + frame.url()); - for (const child of frame.childFrames()) - dumpFrameTree(child, indent + ' '); - } - - - - const [resetPasswordPage] = await Promise.all([ - context.waitForEvent('page'), - await page.frameLocator('#preview-html').getByRole('link').filter({ hasText: "Réinitialiser mon mot de passe" }).click() - //await page.getByRole('link').filter({ hasText: "Réinitialiser mon mot de passe" }).click() - ]) + // Create a new page and navigate directly to the reset link + const resetPasswordPage = await context.newPage(); + await resetPasswordPage.goto(resetLink); await screenChecker(resetPasswordPage, 'account/password/recovery'); diff --git a/playwright/utils/config.ts b/playwright/utils/config.ts index bc9aa85..9711db9 100644 --- a/playwright/utils/config.ts +++ b/playwright/utils/config.ts @@ -17,6 +17,8 @@ export const MAS_URL = process.env.MAS_URL || 'https://auth.tchapgouv.com'; export const KEYCLOAK_URL = process.env.KEYCLOAK_URL || 'https://sso.tchapgouv.com'; export const ELEMENT_URL = process.env.ELEMENT_URL || 'https://element.tchapgouv.com'; export const BASE_URL = process.env.BASE_URL || "https://matrix.tchapgouv.com"; +export const MAIL_URL = process.env.MAIL_URL || "https://mail.tchapgouv.com"; + export const TCHAP_LEGACY:boolean = Boolean(process.env.TCHAP_LEGACY); diff --git a/playwright/utils/mailpit.ts b/playwright/utils/mailpit.ts new file mode 100644 index 0000000..d4751bd --- /dev/null +++ b/playwright/utils/mailpit.ts @@ -0,0 +1,124 @@ +import { MailpitClient } from 'mailpit-api'; +import { MAIL_URL } from './config'; + + +/** + * Extract verification code from email content + * @param content - The email content (text or HTML) + * @returns The extracted verification code + */ +function extractCodeFromContent(content: string): string { + // Match pattern like "Code: 123456" or similar + const codeMatch = content.match(/.*:\s*(\d+)/); + if (!codeMatch) { + throw new Error('Unable to extract verification code from email content'); + } + return codeMatch[1]; +} + +/** + * Get the most recent verification code from Mailpit for a specific recipient + * @param toEmail - The recipient email address to filter messages + * @returns The verification code from the most recent email + */ +export async function getLatestVerificationCode(toEmail: string): Promise { + try { + const mailpit = new MailpitClient(MAIL_URL); + await mailpit.getInfo(); + + // Search for messages sent to the specific email address (most recent first) + const messages = await mailpit.searchMessages({ + query: `to:${toEmail}`, + start: 0, + limit: 1, + }); + + if (!messages.messages || messages.messages.length === 0) { + throw new Error(`No emails found for recipient: ${toEmail}`); + } + + const latestMessage = messages.messages[0]; + console.log(`[Mailpit] Found email for ${toEmail}: ${latestMessage.Subject} (ID: ${latestMessage.ID})`); + + // Get the full message content + const message = await mailpit.getMessageSummary(latestMessage.ID); + + // Try to extract code from text content first, fallback to HTML + let content = message.Text || message.HTML || ''; + + if (!content) { + throw new Error('Email content is empty'); + } + + console.log('[Mailpit] Email content preview:', content.substring(0, 200)); + + const verificationCode = extractCodeFromContent(content); + console.log(`[Mailpit] Extracted verification code: ${verificationCode}`); + + return verificationCode; + } catch (error) { + console.error('[Mailpit] Error fetching verification code:', error); + throw error; + } +} + +/** + * Extract password reset link from email content + * @param content - The email content (text or HTML) + * @returns The extracted password reset URL + */ +function extractResetLinkFromContent(content: string): string { + // Match URLs that contain password/recovery or similar patterns + // Look for full URLs in the email + console.log(content); + + const urlMatch = content.match(/(https?:\/\/[^\s<>"]+(?:account\/password\/recovery)[^\s<>"]*)/i); + if (!urlMatch) { + throw new Error('Unable to extract password reset link from email content'); + } + return urlMatch[1]; +} + +/** + * Get the password reset link from the most recent email for a specific recipient + * @param toEmail - The recipient email address to filter messages + * @returns The password reset URL from the most recent email + */ +export async function getPasswordResetLink(toEmail: string): Promise { + try { + const mailpit = new MailpitClient(MAIL_URL); + await mailpit.getInfo(); + + // Search for messages sent to the specific email address (most recent first) + const messages = await mailpit.searchMessages({ + query: `to:${toEmail}`, + start: 0, + limit: 1, + }); + + if (!messages.messages || messages.messages.length === 0) { + throw new Error(`No emails found for recipient: ${toEmail}`); + } + + const latestMessage = messages.messages[0]; + console.log(`[Mailpit] Found password reset email for ${toEmail}: ${latestMessage.Subject} (ID: ${latestMessage.ID})`); + + // Get the full message content + const message = await mailpit.getMessageSummary(latestMessage.ID); + let content = message.Text; + + if (!content) { + throw new Error('Email content is empty'); + } + + console.log('[Mailpit] Email content preview:', content.substring(0, 300)); + + const resetLink = extractResetLinkFromContent(content); + console.log(`[Mailpit] Extracted password reset link: ${resetLink}`); + + return resetLink; + } catch (error) { + console.error('[Mailpit] Error fetching password reset link:', error); + throw error; + } +} From 6c33d4801450e4586ead558e04372284862a4797 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Tue, 20 Jan 2026 10:00:34 +0100 Subject: [PATCH 52/71] add test for org.matrix.msc3824.action=REGISTER (#26) * add test for org.matrix.msc3824.action=REGISTER * fix BASE URL * rename file --- playwright/.env.local | 2 +- playwright/tests/auth/mas-legacy.spec.ts | 51 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 playwright/tests/auth/mas-legacy.spec.ts diff --git a/playwright/.env.local b/playwright/.env.local index 3668079..ac624d0 100644 --- a/playwright/.env.local +++ b/playwright/.env.local @@ -2,7 +2,7 @@ MAS_URL=https://auth.tchapgouv.com KEYCLOAK_URL=https://sso.tchapgouv.com ELEMENT_URL=https://element.tchapgouv.com -BASE_URL=https://matrix.dev01.tchap.incubateur.net +BASE_URL=https://matrix.tchapgouv.com MAIL_URL=https://mail.tchapgouv.com # Keycloak Admin Credentials diff --git a/playwright/tests/auth/mas-legacy.spec.ts b/playwright/tests/auth/mas-legacy.spec.ts new file mode 100644 index 0000000..72b9c19 --- /dev/null +++ b/playwright/tests/auth/mas-legacy.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/test'; +import { BASE_URL, MAS_URL } from '../../utils/config'; + +test.describe('Tchap : Legacy SSO Flow', () => { + + test('verify legacy SSO redirect chain for registration', async ({ request }) => { + // The initial legacy SSO URL + const email = 'user@exemple.com'; + const initialUrl = `${BASE_URL}/_matrix/client/r0/login/sso/redirect?redirectUrl=tchap%3A%2F%2Fconnect&org.matrix.msc3824.action=REGISTER&login_hint=${encodeURIComponent(email)}`; + //login_hint is not taken into account in MAS for compat-sso flow + + console.log(`[Legacy SSO] Step 1: Calling initial URL: ${initialUrl}`); + + // Step 1: Call the initial URL without following redirects + const response1 = await request.get(initialUrl, { + maxRedirects: 0, + }); + + console.log(`[Legacy SSO] Response 1 status: ${response1.status()}`); + expect(response1.status()).toEqual(303); + + // Get the first redirect location (should be complete-compat-sso) + const completeCompatSsoUrl = response1.headers()['location']; + expect(completeCompatSsoUrl).toBeTruthy(); + console.log(`[Legacy SSO] First redirect to: ${completeCompatSsoUrl}`); + + // Verify this is the complete-compat-sso URL with correct parameters + expect(completeCompatSsoUrl).toContain(`${MAS_URL}/complete-compat-sso/`); + expect(completeCompatSsoUrl).toContain('action=register'); + expect(completeCompatSsoUrl).toContain('org.matrix.msc3824.action=register'); + + // Step 2: Follow the first redirect to complete-compat-sso + console.log(`[Legacy SSO] Step 2: Following redirect to complete-compat-sso`); + const response2 = await request.get(completeCompatSsoUrl!, { + maxRedirects: 0, + }); + console.log(`[Legacy SSO] Response 2 status: ${response2.status()}`); + expect(response2.status()).toEqual(303); + + // Get the second redirect location (should be register page) + const registerUrl = response2.headers()['location']; + expect(registerUrl).toBeTruthy(); + console.log(`[Legacy SSO] Second redirect to: ${registerUrl}`); + + // Verify this is the register URL with correct parameters + expect(registerUrl).toContain(`/register`); + expect(registerUrl).toContain('kind=continue_compat_sso_login'); + + console.log(`[Legacy SSO] Successfully verified the complete redirect chain`); + }); +}); From 005df42a12304c75324200d2c6f10421dbf0d624 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Mon, 26 Jan 2026 11:03:21 +0100 Subject: [PATCH 53/71] fix test : use webflow to create user (#27) * fix test : use webflow to create user * create test user data with tchap-like localpart * add domain in TestUser --- playwright/playwright.config.ts | 2 +- .../auth/tchap-register-password.spec.ts | 56 ++++++++++++++----- playwright/utils/auth-helpers.ts | 26 ++------- 3 files changed, 50 insertions(+), 34 deletions(-) diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts index f8aeebe..a0c38a7 100644 --- a/playwright/playwright.config.ts +++ b/playwright/playwright.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ /* Define how many workers */ // Limit the number of workers on CI, use default locally - workers: process.env.CI ? 2 : 1, + workers: process.env.CI ? 2 : 2, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, diff --git a/playwright/tests/auth/tchap-register-password.spec.ts b/playwright/tests/auth/tchap-register-password.spec.ts index 3c770c8..d9cf65b 100644 --- a/playwright/tests/auth/tchap-register-password.spec.ts +++ b/playwright/tests/auth/tchap-register-password.spec.ts @@ -95,11 +95,15 @@ test.describe('Tchap : register with password', () => { await expect(page.locator('div.cpd-form-message.cpd-form-error-message').filter({ hasText: 'Votre adresse mail est associée à un autre serveur' })).toBeVisible(); }); - test('when user already exists', async ({page, context, browser, screenChecker: screen, startTchapRegisterWithEmail }) => { + test('when user already exists', async ({page, context, browser, screenChecker: screen, startTchapRegisterWithEmail }) => { + + // Create a test user with a password in MAS with API + const user = await createMasTestUser(STANDARD_EMAIL_DOMAIN); + + // Create a test user with a password in MAS with web flow + /* + const user = generateTestUserData(STANDARD_EMAIL_DOMAIN); - // Create a test user with a password in MAS - const user = await createMasTestUser(STANDARD_EMAIL_DOMAIN); - await startTchapRegisterWithEmail(page, user.email); await screen(page, '/register/password'); @@ -108,23 +112,49 @@ test.describe('Tchap : register with password', () => { await page.locator('input[name="password"]').fill(PASSWORd); await page.locator('input[name="password_confirm"]').fill(PASSWORd); - //wait for password-confirm matching confirmation - await page.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field + //wait for password-confirm matching confirmation + await page.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field await expect(page.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click({clickCount:2}); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click({clickCount:2}); //2 clicks works better than one - //form is submitted successfully await screen(page, '/verify-email'); - let verificationCode = await getLatestVerificationCode(user.email); await page.locator('input[name="code"]').fill(verificationCode); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - await screen(page, '/finish'); - await expect(page.locator('text=le compte Tchap existe déjà')).toBeVisible(); - await page.getByRole('link').filter({ hasText: 'Continuer' }).click(); + await screen(page, '/consent'); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + await expect(page.locator('h1').filter({ hasText: /Bienvenue./ })).toBeVisible({ timeout: 20000 }); + */ + + //new browser to start from a clean browser history + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + await startTchapRegisterWithEmail(page2, user.email); + + await screen(page2, '/register/password'); + await expect(page2.locator('input[name="email"]')).toHaveValue(user.email); + + await page2.locator('input[name="password"]').fill(PASSWORd); + await page2.locator('input[name="password_confirm"]').fill(PASSWORd); + + //wait for password-confirm matching confirmation + await page2.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field + await expect(page2.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); + await page2.getByRole('button').filter({ hasText: 'Continuer' }).click({clickCount:2}); + + //form is submitted successfully + await screen(page2, '/verify-email'); + + let verificationCode2 = await getLatestVerificationCode(user.email); + await page2.locator('input[name="code"]').fill(verificationCode2); + await page2.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + await screen(page2, '/finish'); + await expect(page2.locator('text=le compte Tchap existe déjà')).toBeVisible(); + await page2.getByRole('link').filter({ hasText: 'Continuer' }).click(); - await screen(page, '/login'); + await screen(page2, '/login'); }); }); diff --git a/playwright/utils/auth-helpers.ts b/playwright/utils/auth-helpers.ts index bb35ee8..60dce3e 100644 --- a/playwright/utils/auth-helpers.ts +++ b/playwright/utils/auth-helpers.ts @@ -14,6 +14,7 @@ export interface TestUser { password: string; keycloakId?: string; masId?: string; + domain: string; } /** @@ -266,35 +267,20 @@ export async function performSimplePasswordLogin( } // Generate a unique username and email for testing -export function generateTestUserData(domain:string) { +export function generateTestUserData(domain:string):TestUser { const timestamp = new Date().getTime(); const randomSuffix = Math.floor(Math.random() * 10000); const username = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}`; + const localpart = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}-${domain}`; const email = `${username}@${domain}`; console.log("Using email: ", email); return { - username: username, + username: localpart, email: email, - password: TEST_USER_PASSWORD - }; -} - -// Generate a unique username and email for testing -export function generateExternTestUser() { - const timestamp = new Date().getTime(); - const randomSuffix = Math.floor(Math.random() * 10000); - const username = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}`; - const email = `${username}@tchapgouv.com`; - - console.log("Using email: ", email); - - - return { - username, - email, - password: TEST_USER_PASSWORD + password: TEST_USER_PASSWORD, + domain: domain, }; } From f4f4eb128adc9b097ec55c4a14609c2476873912 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Mon, 9 Mar 2026 17:42:46 +0100 Subject: [PATCH 54/71] minimal tests : all in one test (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add all in one test * add image * add public room * add disconnect and reset password * docs * fix button "se deconecter“ * use french * ajouter envoi de message * add use_mas var --- playwright/.env.dev01 | 23 +- playwright/.env.dev02 | 42 +++ playwright/.env.local | 16 +- playwright/.env.preprod | 42 +++ playwright/.env.sample | 6 +- playwright/README.md | 7 +- playwright/package.json | 6 +- playwright/sample-files/eicar.com | 1 + playwright/sample-files/element.png | Bin 0 -> 17083 bytes .../tests/minimal/minimal-scenario.spec.ts | 318 ++++++++++++++++++ playwright/tests/web/create-room.spec.ts | 24 +- playwright/utils/auth-helpers.ts | 43 ++- playwright/utils/config.ts | 25 +- playwright/utils/mailpit.ts | 178 +++++++--- 14 files changed, 632 insertions(+), 99 deletions(-) create mode 100644 playwright/.env.dev02 create mode 100644 playwright/.env.preprod create mode 100644 playwright/sample-files/eicar.com create mode 100644 playwright/sample-files/element.png create mode 100644 playwright/tests/minimal/minimal-scenario.spec.ts diff --git a/playwright/.env.dev01 b/playwright/.env.dev01 index 1a1e5bd..758c1da 100644 --- a/playwright/.env.dev01 +++ b/playwright/.env.dev01 @@ -3,7 +3,11 @@ MAS_URL=https://auth.dev01.tchap.incubateur.net KEYCLOAK_URL=https://identite-sandbox.proconnect.gouv.fr/users/start-sign-in ELEMENT_URL=https://tchap.incubateur.net BASE_URL=https://matrix.dev01.tchap.incubateur.net -MAIL_URL=https://mail.tchapgouv.com +MAIL_URL=https://yop.tchap.incubateur.net + +# mailpit user credentials +MAILPIT_USER=tchap +MAILPIT_PWD= # Keycloak Admin Credentials #KEYCLOAK_ADMIN_USERNAME=admin @@ -14,20 +18,16 @@ MAIL_URL=https://mail.tchapgouv.com MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 MAS_ADMIN_CLIENT_SECRET= -# Test User Credentials -TEST_USER_PREFIX=playwright_test_user -TEST_USER_PASSWORD=Test@123456 - # Test User Credentials TEST_USER_PREFIX=user.local -TEST_USER_PASSWORD=Test@123456 +TEST_USER_PASSWORD=Test123456sdksdfkljfs222! # Email domains for test users -STANDARD_EMAIL_DOMAIN=agent1.tchap.incubateur.net - -INVITED_EMAIL_DOMAIN=invited.externe.com +STANDARD_EMAIL_DOMAIN=yop1.tchap.incubateur.net +INVITED_EMAIL_DOMAIN=yopext.tchap.incubateur.net NOT_INVITED_EMAIL_DOMAIN=gmail.com WRONG_SERVER_EMAIL_DOMAIN=wrong.server.com +NUMERIQUE_EMAIL_DOMAIN=numerique.gouv.fr # Browser Locale BROWSER_LOCALE=fr-FR @@ -36,4 +36,7 @@ BROWSER_LOCALE=fr-FR SCREENSHOTS_DIR=playwright-results/dev01 #Run tests in parallel -TEST_IN_PARALLEL=false \ No newline at end of file +TEST_IN_PARALLEL=false + + +USE_MAS=true \ No newline at end of file diff --git a/playwright/.env.dev02 b/playwright/.env.dev02 new file mode 100644 index 0000000..8fdaf33 --- /dev/null +++ b/playwright/.env.dev02 @@ -0,0 +1,42 @@ +# URLs +MAS_URL=https://auth.dev02.tchap.incubateur.net +KEYCLOAK_URL=https://identite-sandbox.proconnect.gouv.fr/users/start-sign-in +ELEMENT_URL=https://tchap.incubateur.net +#ELEMENT_URL=http://localhost:8088 +BASE_URL=https://matrix.dev02.tchap.incubateur.net +MAIL_URL=https://yop.tchap.incubateur.net + +# mailpit user credentials +MAILPIT_USER=tchap +MAILPIT_PWD= + +# Keycloak Admin Credentials +#KEYCLOAK_ADMIN_USERNAME=admin +#KEYCLOAK_ADMIN_PASSWORD=admin +#KEYCLOAK_REALM=proconnect-mock + +# MAS Admin API Credentials +MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 +MAS_ADMIN_CLIENT_SECRET= + +# Test User Credentials +TEST_USER_PREFIX=user.dev +TEST_USER_PASSWORD=Test123456sdksdfkljfs222! + +# Email domains for test users +STANDARD_EMAIL_DOMAIN=yop2.tchap.incubateur.net +INVITED_EMAIL_DOMAIN=yopext.tchap.incubateur.net +NOT_INVITED_EMAIL_DOMAIN=gmail.com +WRONG_SERVER_EMAIL_DOMAIN=wrong.server.com +NUMERIQUE_EMAIL_DOMAIN=numerique.gouv.fr + +# Browser Locale +BROWSER_LOCALE=fr-FR + +# Screenshots Directory +SCREENSHOTS_DIR=playwright-results/dev02 + +#Run tests in parallel +TEST_IN_PARALLEL=false + +USE_MAS=false \ No newline at end of file diff --git a/playwright/.env.local b/playwright/.env.local index ac624d0..524fca0 100644 --- a/playwright/.env.local +++ b/playwright/.env.local @@ -5,6 +5,10 @@ ELEMENT_URL=https://element.tchapgouv.com BASE_URL=https://matrix.tchapgouv.com MAIL_URL=https://mail.tchapgouv.com +# mailpit user credentials +MAILPIT_USER = +MAILPIT_PWD = + # Keycloak Admin Credentials KEYCLOAK_ADMIN_USERNAME=admin KEYCLOAK_ADMIN_PASSWORD=admin @@ -14,19 +18,16 @@ KEYCLOAK_REALM=proconnect-mock MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 MAS_ADMIN_CLIENT_SECRET= -# Test User Credentials -TEST_USER_PREFIX=playwright_test_user -TEST_USER_PASSWORD=Test@123456 - # Test User Credentials TEST_USER_PREFIX=user.local -TEST_USER_PASSWORD=Test@123456 +TEST_USER_PASSWORD=Test@123456azeErt!! # Email domains for test users STANDARD_EMAIL_DOMAIN=tchapgouv.com INVITED_EMAIL_DOMAIN=invited.externe.com NOT_INVITED_EMAIL_DOMAIN=not.invited.externe.com WRONG_SERVER_EMAIL_DOMAIN=wrong.server.com +NUMERIQUE_EMAIL_DOMAIN=numerique.gouv.fr # Browser Locale BROWSER_LOCALE=fr-FR @@ -38,4 +39,7 @@ SCREENSHOTS_DIR=playwright-results/local TEST_IN_PARALLEL=false #disable tls verification in nodeJS (required for axios to work with self signed certificate) -NODE_TLS_REJECT_UNAUTHORIZED = '0' \ No newline at end of file +NODE_TLS_REJECT_UNAUTHORIZED = '0' + + +USE_MAS=true \ No newline at end of file diff --git a/playwright/.env.preprod b/playwright/.env.preprod new file mode 100644 index 0000000..ae09471 --- /dev/null +++ b/playwright/.env.preprod @@ -0,0 +1,42 @@ +# URLs +MAS_URL=https://auth.a.tchap.gouv.fr +#KEYCLOAK_URL=https://identite-sandbox.proconnect.gouv.fr/users/start-sign-in +ELEMENT_URL=https://www.beta.tchap.gouv.fr +BASE_URL=https://matrix.a.tchap.gouv.fr +MAIL_URL=https://yop.tchap.incubateur.net + +# mailpit user credentials +MAILPIT_USER=tchap +MAILPIT_PWD= + +# Keycloak Admin Credentials +#KEYCLOAK_ADMIN_USERNAME=admin +#KEYCLOAK_ADMIN_PASSWORD=admin +#KEYCLOAK_REALM=proconnect-mock + +# MAS Admin API Credentials +MAS_ADMIN_CLIENT_ID= +MAS_ADMIN_CLIENT_SECRET= + +# Test User Credentials +TEST_USER_PREFIX=user.preprod +TEST_USER_PASSWORD=Test@123456sdksdfkljfs222 + +# Email domains for test users +STANDARD_EMAIL_DOMAIN=yop2.tchap.incubateur.net +INVITED_EMAIL_DOMAIN=yopext.tchap.incubateur.net +NOT_INVITED_EMAIL_DOMAIN=gmail.com +WRONG_SERVER_EMAIL_DOMAIN=wrong.server.com +NUMERIQUE_EMAIL_DOMAIN=numerique.gouv.fr + +# Browser Locale +BROWSER_LOCALE=fr-FR + +# Screenshots Directory +SCREENSHOTS_DIR=playwright-results/preprod + +#Run tests in parallel +TEST_IN_PARALLEL=false + + +USE_MAS=false \ No newline at end of file diff --git a/playwright/.env.sample b/playwright/.env.sample index 2d15b81..4cc6c97 100644 --- a/playwright/.env.sample +++ b/playwright/.env.sample @@ -5,6 +5,10 @@ ELEMENT_URL=https://element.tchapgouv.com BASE_URL=https://matrix.tchapgouv.com MAIL_URL=https://mail.tchapgouv.com +# mailpit user credentials +MAILPIT_USER = +MAILPIT_PWD = + # old login page flow TCHAP_LEGACY = false @@ -26,7 +30,7 @@ STANDARD_EMAIL_DOMAIN=incubateur.net INVITED_EMAIL_DOMAIN=invited.yopmail.com NOT_INVITED_EMAIL_DOMAIN=yopmail.com WRONG_SERVER_EMAIL_DOMAIN=agent2.incubateur.net - +NUMERIQUE_EMAIL_DOMAIN=numerique.gouv.fr # Screenshots Directory SCREENSHOTS_DIR=playwright-results diff --git a/playwright/README.md b/playwright/README.md index 3e1dddc..e8f0f50 100644 --- a/playwright/README.md +++ b/playwright/README.md @@ -98,4 +98,9 @@ npm run test:ui ## Captures d'écran -Les tests génèrent des captures d'écran à chaque étape importante du processus d'authentification. Ces captures sont enregistrées dans le répertoire `playwright-results/`. \ No newline at end of file +Les tests génèrent des captures d'écran à chaque étape importante du processus d'authentification. Ces captures sont enregistrées dans le répertoire `playwright-results/`. + + +## Executer avec docker + +`docker run -it --rm --ipc=host -v .:/app -w /app mcr.microsoft.com/playwright:v1.51.1-noble npm run test:dev02` \ No newline at end of file diff --git a/playwright/package.json b/playwright/package.json index 3c140b3..208e2f7 100644 --- a/playwright/package.json +++ b/playwright/package.json @@ -8,8 +8,10 @@ "test:headed": "playwright test --headed", "test:debug": "playwright test --debug", "test:ui": "playwright test --ui", - "test:local": "TEST_ENV=local playwright test", - "test:dev01": "TEST_ENV=dev01 playwright test" + "test:local": "ENV=local playwright test", + "test:dev02": "ENV=dev02 playwright test ./tests/minimal/", + "test:preprod": "ENV=preprod playwright test ./tests/minimal/" + }, "keywords": [ "playwright", diff --git a/playwright/sample-files/eicar.com b/playwright/sample-files/eicar.com new file mode 100644 index 0000000..a2463df --- /dev/null +++ b/playwright/sample-files/eicar.com @@ -0,0 +1 @@ +X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H* \ No newline at end of file diff --git a/playwright/sample-files/element.png b/playwright/sample-files/element.png new file mode 100644 index 0000000000000000000000000000000000000000..53ca7652b4eb6bc95c1a3a227fd93b6931f59202 GIT binary patch literal 17083 zcmXwh2Rzl^|NnVk*SPk`$jFsdMr4+Cua-SZAri`rA|oO0MI|XSQZh1=inOxuips3jgappy)&Tbr|<&&2hvs7L>GY4SQfG)JpgCS z4bJIaz7H*r;htS_uA3ye7B=*h<%R!v<`(RW-#9Y=`wo52PrsGy^?OOV)wlIqe`D$t zW{*sY`kt)4n7|-LiQM~&QaN=|nMb>CGjbC7C4HJTh0x>P_EE?CO_X$@O6EPm*_yDQ zrS!_41!q|DcY<8CJq91Y%N6}}YqfHKRJ2Wz=zoOA2xh0xg}`6e&46`rp=+?KZg_%p z=-$QoU#pu4o)EQ5*5;(Ex&~UshbSrfbM2T30ZOPv0z+W>3q7)9+e6Vo>W%BQJG;5* zQPl21jM|?r)VtV^dr4s{h0c(Jsu_6y;a7Q~$}we)kmL7)!X^%1STy~+U*4viCfJ%I zShGBJMXT#Y)^u~qQ|Z}VZYjnu9N%6aJ2dmH)Nr@zDbS}!0ZwJ@hAvLrLM2dO(dx2Y zyyxwe^hcsOrOb8O*gEJKBenaa3FnCb+Efa7!iECfN5$zHxK`5@OWfmDCECsGhn}9| zlVo(pU16y&(cmPjtue-4JPxETAi%TC9FkaVV4l5vfm&lgtbo^X>0m21 zE9PfXNL_`qk%Vo15a%JdmhWTq(rUY|bvLwiL1(nW5FNH(csyl1{K1&f8N8M_ge`ie z^@nHeq8U^8nQh>UY!~7NPUHPY8l__|^?rS*wXprH{^EAig7af=RS$vxe%SvTPDN`b zfLYOhX3fZ{c%5}Go&r)RRZ>!0d(3x)oDuG=)y`SGR`gaU7} zbummXMfpiP*+%&H4+{||T{t?xHTYI?IJX={5};F0kzhc4zU6RB2ez5E2&`BgHMxOx zeuHc9OaAZoWymi4+95SxN99AepEs_!vNJh|!QIX-T|cww*N>=%?W*XC9=WFH`@8nj|0tCO8A@<>=7yd{BEeu3-`?eDg~v4K-{PR` zo$*n8k3QgZ^c9VxOW$G-I=l3W1Ai21ul=J#E0>DPp~sru{}kxQBmqvV<+~1+v+hB) zyki1;Wx;*E;n!e6G5KAR?)sITZ92|bI-h7HTF+2>R5Sm>o=iw6P=6;Mrw(7LTPunc z|6D~-8$s-25pj24kRao;r`MVS|M~1H#GsVm%d%2d7`wGb5LYiC;&eq;$v&0$A6XsW zrE?2YIvYNG4iP8Dx(_|!=*JhV)4x*8pWDy**!+wGMFmri13#FaYF7rFiKD*^gumvA zVmERb;Fk*DGQUlyfAQ5YsE!Ax^rgx9x}O-R{RoivPH<9&8+`RH7R5jMCKb0$;kdoX zdn!T<8~J^4csrc`P%sk}$n`Dk_ht#{(f`auOyb?Ti*2+xsiSpzfN*C<7nhrwjHl!R z>y^cAop20L_xs88RP?6?*{CYcCWaZGZ9T(62pL}qw;!@z7`NuKjzkj9*lcjXGiqnB zK_tJUcX$=lkuk}E7I#BgKV`;C9^0pDuTz|?-|asNKJo6F6|XWeUyGjnLPv=(HcPk0TWDfcBHKh~Be^ zI7K1_c+<@yF2T#5`AS>!B_F~IV?_p&3=$CPCIebbM7yhan=WHU2Pjs#*<-(Ic5QwS zJfjb|;Wf6$2adT9+@cD9cAGZRvU1#Px`^t6*^$i;_X1z2RFD)h^engZTc?( z_|PnTY)^S$GsRpB?jXF!fC|=@u#;kt+sW%2bogX()D!3|UjF?c8D8IRpC01mtES=F z%iT(sfz7!9VsP^_iZPK6>nWq#?`I!+fAgenUoNP?kFUGtU_fipG=DwEwntH>y96-l zWem9$K!!jRQ`79hvKLvYM!4mPhg_WSvAC4CI%w8Oz^})HXS#!=vOgT4tU1x)?@QVA zKOV$m5+LhbtZ~VxC@5>&9oY7#J&1+hK|Ghs&cADmjN_3A&@RlQA3!^s8br@^gig?s zg&G(DNG z)5*P`b@`=m=WT={yvtes;1K!rAkOSUor}nTb(~qPF}P(*opy)7w2d`=sD>7&j_m0d zpAO?=0zzwTv@nCtqJVJh^tD4T@{w3+YCgO(YgZV7KsNVfliis}bIO?#|Vs{ zUsYdPQ&1LFSJFlfjh8ocMiZ$37@G{(bA0zi5Mpouno2{yRus=?@)9!rxTA12(J`RcXz!O zcq6iRDQ4H6iFO}rx|1et0BmG^H^D0d06#TDUB%6_hP0CY0nL{kiJOfRm%B5VO;}@g zGhXsIDw!Lx|4bABKaTU0ryBlyIQVx?=PETe+Z59l4iS`srf8|F+T2N`#P?BOD1Rnw zF&BEzVR^!{_`A+e40Hj(-qTtfB^Q;@JiLHNE$NRrKh~2s;j(QQCWX+GnbT*&8#p;1 zA?zdZjTq0%%w`j#{Mww`M7t~$ED(9(4SQl(xsOxEbFl*Gb4N}Gt9Ex>A@tw6E7UAnHS9B2c8vj-ofO;jX*b8mXPBp_+6c9* z0pz-`vN726GM=`A=1Ht?1HjLFUBB!j%kVdqp*qXB8Un40^-gRO!dD zWFP)8f7yTJ6*I`j4c#&zT{}&Ts0{yN&V@~`g>7;aGd`KBy{@!>MEQ61n?Bl$gO0=N z8Mj$(CZ8fiA@RK!&*Z@lnoB$rI_r%)y>$jSmyI*aE&gm1J?h0{oFRp%!-(aN^2DFL zP^U2-7+PulbMfG+I1ZchJ-pxQ^c?zspGTbPQZ7yox?Vy|6U^rUhBR!W+tCuQ^{fi| zIQnd~Kp7Euz#F*Sb?y?}r_;lS<$K6DlruUp-Y=P4rUo5Dj)y|R-uEUGGPf6Rhn?M} zytoD4%)Tdb@dbAFL?+h~AMiXTxV{xQb9`)Jo(0qw#)g#e#=0 zyacZN*%Pd!fdLM(OSq0MdHSnY**T`7QXi8Ne_kL$<{^mb-AAaOv|;NW2IF@4n+3nw z9IhY;X}B7FTOa{-^bCUr$;?d%ul&q!SdG)|e|?KdzD+z5My?1yFF+UW{pdDb*nk9J zr8YY=Hk~rD*Pw^>C4qZVnjLL z=jiYgywRSsyb{|o*pDcz-*Ojx79M`rc}wj9IT@|}8acm%eN5*l&r(GH;WhAH=D1q9 z+a;Z42t303vTh56T#e)XT#{rT$1ia{Zw&Folr~k!gU+Jv_0!^-C}fT@C9OxvDJcCP z5wwk;^UCT-hqqo6^*e8s;U7CE4Rmjzu%jc9+m#r2@_iMEOr!9v&3CV83Fpv~yIrsN zL!?mnkouwuc<=<^P1GAF|9zR!@s=fL`W%h^>hHTkQCMbughQU%$+K2P^*g4;5_0Pv z_@HG4*r)nU(o2`WlOe?Le!r|<#cXy$$!NCvhtzTX_8qf{XdMuJnK^X*r{V6GrxLlx zP%P6QDldeaLO#Z_5OIA2>BTh9Ia(Pz;*+{x{XM3r`iED(BwM-bZ#kn8FCG;v+WI(dRP4;ZlbC{6FgzzLI`+COI5?E$g<)Bg$Czc%1zd zX*)YN9PwVRM+l0#d@%gIU~va?y@QUG$X4|LLRUBhY;nk|mO! zY)*H>)8As>|3LPR(YilekJ5d>3%YD&fs%qCA8UjYA0xg0%Ac84?W0(m`tVjugIH$< zhl9W?ef=I2{#tGqqj1B0)8wvK8F{KLEF&&@8L^hu+z&7Njc?I($fI7=mQiQ-QfnCy z+Hd3(1%Xe(sEFx}-Cg6o@1@aOJ9L{sKYA|J+6A)Fq05ZMebimIfZ{yz@7Nt1ZQ8`T zxX}3>nT%X*;Ak!53_oeM{wur^|B`MR4`B2P#)^-uLTtShfbIxWzxMT3t34STk3(l+srjLDZWrAa2&ihoIjcNJJwfomeO+(_K1 z^K{g)KWqVE^$66KS3tHQ^}J= zVJmhgm>jg(h#o67nyh;)&ISVHz7q@TSvux=+;K(A6ASD#5wZ>by+9Z4vP~RN8ACYi zaa0K%-rAmiqk1*4^ykbyKo_QOGl}zEomsILPLxuygPy3-+IDgk6}5YSy}-YWH#CYd z10}wKyCk6F)>SIiV@m0zEEJR<7KW9`<2=+d@JS^Js89qQU@L3S`IzPqH6yq+7h->X ze_Xt0po(a8=V}1SL^-RgsL9%jHW#q`N94Ao9qRG@lbooH6b82>2b$#kyw^%$yq+7`G}g5y1eV%@3M zGU?CU|6+Yb$VusE)K5Jq4t+mT6cO7hNnD~$*OQR?hUHyIvrh=c2fMU)0M)k?BsgEu zy1H=)CtP-qS({i)HB9#VEmf4sjDM(F$>T7>iys-op-*>aeOkplHzkkEN$*YQ<4lfl z*os|!OWLu!+S&@lS%q7axtn>oAi$wXX^s_z*mpmB|(2? zwC)9cTl|)tzD|h)%L}4j zxd=IYLxCAwMQ`}LZt4i$e&E}Hn|2zb_sqVXiIbJH8=B`{s;Q8}qCEIUbK}w$6QMNQ zFpw1`HCQIUwP<=M`APmy!0K|rO z{}{?*WdSSQQ7#M59y57pZCVdu%#}lR66w-iD;^D6v?~>{Lc|M*8|CyexSfLBR)_m} zLthV6iA^V-*b1Ea6zjccHq?l_x5V4vBD~r(*&3cX^BqDT&VKv$ZT55O^kW`SEVG1k z>rU9I?a9L!arD(p>CY5QNBu+$>2S6yq7$b0jav$dUw97IATF8AF}AC`vYq3w>METD z)oS%juAbp_m?`{+#FC$_3pTP3ng7qr6z@Gwkf``=)`8J3GX7HiS_)KQguZ{`)7N*+ zn=2N&MW^xt96}Tu;ZgH1Xc+Qj@z9@)qiobbP0FALoCoLkX4NSNJ)kTyf&`-|mLJ8aGnQjtsaBB;NKE;-CKjB@~Xo)~W$q6zU82 zZrlClB}8c>Ggb&hxvgnTzDE((wlR|oHY*d=dl!k6;~yVpud06CovYfyytafq8@XUf zl>7XFB;Jd4l;%wrQe_oMiAyJT&|s&iS+#0{DU%H^q2eP_62^!Bx{bM}N7p0;^tZFM4 zQ|B=A`?@brrlTiR_Lhu7uWcG7>n!?iO`Se{|4)T*;Sg;x%><566zE4ZysLdy&TyY@ z{kfSXqQm%ww2tBdL>hWeU5|151RGm>RO1Lsh-((6kvnZ(fsOrlcm6;|{)Ap&Q(*B! zXkY;8fNIK!duT8VzG#notY!+b+8vr6X>7}H@sYIXOzgqpwx))Uc@H)fhW*~ryH#df z{5$R1&cn*V%T}R#OSy(nYu+UP+~qoT%P+*zyRzV{QpJlYT%&te#w1Q$yCFm1MAs6( zH$^RMT!*-N&K2?$vFZOPhyqh;^&FW21)@Ug&{{-hrwY{?t#qN7cycY^`Snlvj1P$G zE2sSaR8*n}8@*byfb-Lz5YTXwGE{@YuaiX=v=P&>!mj6+CGH|H{UyAF zwbrZSW&*$9UNx;Rn00gJAp`Ai$XwyST7?w{Q81-GE=)*4;paNFA47y|x$?yQt0(Ry z^6YIcb@S&G8Ut&lhZ6Uvo?q=3^<}^_;x}%R-mQFR#Icbvs9@5_g!JBA;t7|siy@qx zPx|h2(2be@XD=kf(2RbQtLvI8$purbv2}kb`~_)r*1e&c>QEfMSi||3FoBI1Fn~?n zmvK)1Q@H8GS|sk5NNEMLB8^W5JegQSPx{|^1Fae7tRWmeEObvyJWxpGp#?oME8aTU zAKMdSNp+f0uCE$HcGqSG-Y$3EVZCkkojsrD<-1i3X`Ya?Faxb% zTVxwbXA^Deh!Xw#v8mlHwN0ejXr$t3?rLZOE6;m+o(H$PTk9I-jOJv1#&MpyS)fZ0 zg3Tmwd7VsIRdDv8Kb{k+a%LAA%6-v$Eu0#n{+$$BAz9ip%tQ%=}qV4Bi93PRojiRw- z)Ejh&TDUB6re3FimdY>(N98J2k?~tgojsxjl|J3vs_6kCLX&k!fcGKq>E>gN56iH@DvdY&{WXt|jFXDKIj)tfjIgo0PaX|G{ZkHVrxBU)3M|N1?e_oIBR)r?n@SZ(-q1WUoOYfUZ_m%%e)<3z))SX-m*(wodpX6T{N7ks( zWqQHcCBAdn>gu>Y_q9O-T*Dkk>!0s~FI&~%Si>m4u+)j$UOiTc#eu79sQ|Zm3*iv% zF@dVC)!4J*pc)g!O}D?^E;HtU)RM+C-UMV2*rf!r5CmR=XZOUO4bhjaS=23U#&~wxZ@g|--HhINV}|RMiX3{o2taHQGahT;+?MKx0zC| z8G#zayw+e5MWo*Q^(03t{*tDY#RR>g7E-_*@=o!{!G-jDb>;=o>(hA|qIVw$K<7}P z3w-*K-G`&pWfW=-$A_p%0dgod;aR+!VTC2M*47w*QKhxAk2ZkWS!-j@)9Bf)9hx;} zPv&VpDlQ9-Sb;R>&bqD(^A)slOvWL!iL0D3*36n{d{XJ#vlPey6_=X)a`yCQuVvwC zS9zm;Miq#;rX{U*I%wUjS7vkcpWOU)L8qP1M+bKR6EBd~4vo~z!7Y>Ms!naSxtBhY zl3;9VryeaZ^v60pw?UXM4v!!Ajxo}tZP$s0;D(%5U3V(p*)-7ieUZnPnAr-DD7weJ zi2-r4UY;Hc86;}S^Ypsg1_&Dp>E^w4mr^cy@TtPUd_&ujB{O=Z`d?-7`T^Ldvi2cF z(mXNF(&}r033%)V4LP~Ze^7)R*h+k_)e`jH;(TG_JqHL1tbk;7kH1BM&ja!Tk4n&3pw9}IsHb%9@f}3R4eza6zc*%e;-lg-jQp4qDaGXix zy^ZHzxzR6c6v^;hiVS2>-4jAWI)N5bWv#tbj5_#tv(*+$PY|oy@7np(bn3(FFo|D@ z_)|rUi{jRGDCL#u`;FO}z;vs8ap*!@2~!Dn63MSdyGxAhR6z}`b=dk;Y~L@lDif};Wtg%({IP)9vFIx zBtWurC%99WDVur-v|ZH>?Ko|;Q~mLIY*o2`nbpG7i!A=cR38R!@nza*5dGRYQF&mL z1Sj@PBZEB0DP2Et=#WnNbgmn0C6)TX)0NbM(UWnd$JYI~(Zw4?bXf@3B|vgHJo1{T z1EZ}MsuZ-}`L6{*SQCxY(UuqrqGjx(Kg9!UW1PjP#!$87`y?Iz(E{jD`0RhTp&<*Xn>+W1vpJgGR&IqNTSTjE z=E*x`GQ`N#G)spNM^9|I4lB(jhNpFn)BokYN-@3%-xQihI5K?)fpyoZ7J zYnB|`H`<-!Pem`cDl$9p*zyC(^^(Xc%#M6uL3Tq(gF>PGDXY*mjec;dbEmKA9eI`W zW(|XzxNR{=KU^bmw>RJ9Ac-Lt5kef)D*YFg`w`T9dDMrQ!R;Q>7Izje>F_ji1yl7S zSUj4^0k6U;nsn0y`jWfENI(!#G!aV_zm=idLI ze&ZTpEhF`5o0klgH4g@LTLT@~dkKFqdTb9%n{<%+yaev-!}qFyP2Ro5>k?JnSHTD< zd32y?7UO#UB*qYE#UcrORSfUS?W>Cr4u37ay0eQT{DUM1l6sr2Q*Lf%q}Gqf65xY% z&%goArKWSQ;=xaQrZ}mz=(`J+%vQAD&h=cpAb#>0K$Wf+(%FhSoY8q-$>~v7L!7H2 zoRb{0VZmzI0R8V|HIk)BSG6}qX+t-nX@_EGcqV|%lSn(UspV^o8I%E!ov|S9@pAML z?+>fRrzozUEBWwZ5J945k**^B`mXP74*DG2?qTH{Op3>l&TxYILPV47E@cT8jVOW! zx-KFV&$_&zw>J>O4B^e4ZOiOR|1syB;VHO1C)*HfWfKd)b& zcwH>Zo6?eBFoxlrkhmss?&;^9&Gw@S^?NTx(lHn4@pfRR zB#$jF$3NpT#L2k4|4ERXbe$yb-o@#=T!Jg4$08h(MEIP3-?iH>-Ht{nAl~)+jT~%^ zDbKaJ5&Iov7_NZ2oR7l3RlINPz$V|2GsbM~OU6&5te_slpdVdr?zDKz(6u_WE&SdU zv6JmhfXtX}=jSWfe76btLXR-wu1*i``jq#Fwn1iFXR3S!=oS>*v#X!OBpksWqqv%7 zllavw!Hc>))RU%=NZCd~HjF75HJ#HJnc)Ctls@sYE^*V_o=$n-tcJ=eES-)G}S@5N`TE_Q)I z_@YTEe5D`T9LK~2&!{n;`oVs`xtM2Ze|X6DJv}~8hSw|R1G#c;b@4q-{{^-DM+(XY z{6?0fGW?ivo!iyEYVkG0*KkGJ`4$_t)fJ(5g}r8?a9>%z!ZAoLe7 zpO_!L-Dt-&o;VngvZbEGu=HtybfqHQw@&7dy`FN2$uag1YXrvIpvKUQ0%xN?kwRbR zeT)y)c38yp$%uP_+^PADx9ZSmrRGav{afmbFfR5qv~L{a+E`HU|5h$TS%EmEazuN1 z$>Tbs!W+|uuL1Ac%vh_x!TuEDnVuO1x7nblGjt(Ri*+R6kc3~ ztJ@IUAed>Nve@@;)9aOp_Drz58=4rXmR5JW5XgN%;7_{UXqfrw{L_9_DEbJi-=gU7 zY*g)^t6); zfX$BqHNv-A7W)0HF#Ey1ML*oYfnC{FL1?n{&J(u;*Wf$+w{J46k*h4p@$JW`tvQk|*wFJ^1>^j=#lB61w3BC)X|6 z5w`;U#wTb(VWh2!CK#&D8jh(?+tKR{!#t+?nH=zCKdnt5s^<6Q*)LLKr+JUc?? zNa)?F=jrUL8OpIT_$(&kp!nY05b`XgZJj2(87zMNB{*4DD{LS{kk|O0V~|?ZDm8DP z6&uM#-t1Jqxl8M>L1E3e+3z=MNn)SH*aY#KyE}*HU%J=JbC5Z(W814t7201~FmqXT z699Fa0T>hjFY_N3IE0!tR ze`haF*az1b(ltn3>*-4Exwk#XGoLeepB5O7c}I^G?EFevpL&R^N+}7LgxP|Yda^h; z&)$tIlFtdc%B*sJB-UOvz|n6o9i1-6MwP|vsYZup(t)P5`AAAsXt!~x`0+J1yg&ZR>VM|_xxw~(&6Mar|2bl6i*h6Lp;;mJ>I8Dy|&%LR^M@R z7e-50=s3|XFr}p6gxxr%-ibkVd(!K#;G9+up({c3S$6!8wmY1Mh%7mZ{qA{(?$}45 zYO^y@J$=%U3WKm68?Sl1VA_L&+(9Ni?cbd7Yj92*ISbvh#`I6a_nl2EtkAubHA9_QZF1R8x;oUn8yO@-417+0-#L~V3)6(@ zpS)VW4U*skT$yvBJG6G00kzxCr6a4szVSwlu+X+)c5oYxI6zR^dWU_ zu0XV?_~_BY+X)ztkdtgl7m8^8s&9a|2&X|0Pb`bs+U!DUs-#FYYAQ!wQG45B<7-7q z-tCpN&#c%jeykG^8W;ILMOa z;ib@Fk6sgCxV)>kIUiykj~u2MY<@q1$5SRA!?VJ5JF+Z8ENdRtv*AhYI#W%zrpFX!9v(wNT zW$F&Zop}Y{kdbzXzu@&JML}ky)Vp0Gjyrwt^mbPN2OFxDg(-kn(cpw9LqVTM*wuPo zZ+ACrx3K)~Qh7u41StsyvvDo-F|RJ{+nbz*o=~sDIRQ@jq&ja&b9Y&+{kP5$Ey8-^ zX81y%QWHa{3BX$$Z$2Kc;D+5n{a*ISM(@S&Op)k138RY3KhC_)>)YucbDSL1#e`Bg za~59E`u``HU~~m!Yi^dS!HJ>FyTpn7oqhEO8+;+5tzNHZaH@q!G%>iB0ULhpBO({p zro8RY{Z_%0Maq(ctTCyVAsXd=LwBNr2meP6c+sJ`impJvV2E5<`B3hR=eHsCkF2Zt z-Ip+7qV8@k|HxM~1o7SkmXhL^Ls!_ky7d_XX2!kJyY$nBG6jF*nv#Of(_$ zFYVC?iPkn5JUji0U6}@tI^EX$)Lj`oRlVlV-|8aMZ3|xAkH(dGmi5vmi>Kr=`aXibAc{0`H5Z5C-#jk*0SC{YGVoU?%HQ}Lvo2htykLz&1{s(6>vF4w_XB! zrsCJ)6!CJ1$}yIu@as0c#X}h^j^z}>%}U03(SP?>1jq30?8cz(9O#( zADqy+m>=TTaAa1g@UxS1m7@w9G4gYw&e_ihpckA3N)4K@HFAO>N5Dh@TkM@kwiw(gkz?bH2B|I$Nlw-t~l z{iPE@NZcHZ4qP;|kHF}P{&b&Oq3O%#ffqs7k6np)Yxc+yE1^$Xgk!C|n#KD94`2^4 z<4vfio-70t%((Jj9&smNg4R8)$7vRa_E3A?*Rm)1S3PHm_I5_xKy_&>O+oOlwj?&( zucRpS-MxzxiI6Yr!PCT5hiLj@v0DbT`8MA_#Y@@q-*VYkxYpw3$5$G8G4=}b-DSv3 z%CenZx`H-BqJ1WeP*)%U*|5PmsCdtmUCIy-#I_|kZMK!h(YePz4Wp0md~{d>SI?DrsjgI!rc~oJ*tcY}xt#v6jU-a7YkF;%!@S=?ptYJzw!} zD8$Z@xj%zUs_h=#bN*+hEu$aPiYPNSxd(LaKlU3}Antqf;~6ef>mnj|zE6Exw-M=< zF$ocW$FLE9f{ig0@SI#CS$dAk%lGyo@s8N{=jpy<$PR^lg?NoL(+N1EIA@ChHXj#$ z;IdR3P~H-0hEB@p-c08|^0@C`n4Tv8EU$B8B>1T|?R!-uOMxEyZiNjj=cQ7Zx8w|; zz6N}5=l{ZaX`kum>5g3Pvj#A{CbMD>;ipM zkNgsDgYjlKO@Zu^3XboQQ;Vm`NyYCzZC}k!VyDn@3<%kOc9)@33R)DeJNwf#{Zc7F z7fkIuU|)HmnuaB@hKtZimi%z(Ge?0_)bQR94=>R~fwnP3t^&g?;@FMM!hh>87yrYDaXx^TW%!O#z6 zvypw_kPr3--;z9D;|MBTe#`I8oHpreOBF$OPA3lLf|eP95{vsp(ZwV4<2K1Dk_jO9 zYGlA|<)dB9ab@+=kiXvNr|pd;@@JI5khm5FGf3-J9+iNiB*A6KAyIPKH%kJ|(#2RE zE=32ELSvGpPiir}mn!XRGL(bNVG*7LeCYxk!h|X2aR)#_ac?BdXfi!WybSOxUmSQ; z=GAelc~3>GdpL|IhAL1j{SCX1i%TPeqS?~*1gxQF1$t8p8Ly`CX>XBwKM01wI}SnujIxa*08f(y~Y;;hF^>?*tbyy zy3aN4b9lY03L6LvI$vPW3b6 zm97}5(jlbYCE;gid#~#stOSH_x$#b}dre4UEm`5(3`yraIAXPg?hx~79x-zrv=jL3 z@qN*md0&dk)9Eod-XhO+(Fk}cCBi7BpRVg){0o?`Xx?_l(RMHID89{4;P#Joy6^Pw z&lS7evERaj7+DTtgyrSNLVd7hlPSHC0FwI({aG15xc2{kblGuIg$Eg&Futdr{>v8l zTU`(SRDFB7W$EDzw&YE6=5ENnOC#iWHYuRrG`a0O|?B(BxBl5yxX z`ff`w$>$XyNTN#LxPr)JuFU6RMSeaUbj5o;xar1CyYS?f)HC4akkX!dfM>@Bd70Zi>GY!s2kWoPCw}t3-^Gy_dW4L{Xs6_%8i=cWrba zj^>5*#(q2)#g0BR23GM=2Yo+ah%Zx+lf$G;0Qrz5{9~!hB2LAF2Z%7Ke97Vkt|x^! zi(!^29A;OOHd<0+qq5<&!66|;JpF6nrg}h`pvT^^E?@W3vXlmOV&>2EWWmqATZDs< zSjUZ};>*=g5l7XVGm{hg1ME=Dul1FpBPV+$u@_+MW~>a3V#_jbCp#<(ARdlw{iJmj zV(M+|pxm44pzOjZMEroSVjPk<<^Pq`#o};$$%y7ZP*1NIAEq5Ct306dKvK?%Y>j`` zO~6(7m{NB(G3P9njWq7?sLUq$ine1y_g{gXlR`~F4o1Z=EtwB1QV5)e$t5D0B)ST1 zd=zDkGB}OA0<6D2@5QC@z=p;KYA+*ZPL8!$F&^dqUJdT}6Xu!=HN_yTL7~L7_a(ZI~i~N~uK@tl1(9)LUb&59f1!jL{hhP1f zF~r;bmZ1#tw&~J0mFO@%PTsHt8&hzGJ1ds>hOlvZ)AuUx(7KuDm52UsWGREEuR`X_ zUaN~64;Hx4ITbP`w^ly?W@TIKH%N;Io;MH+1O3}mXjdN4PyM;65R@Khh!^hrh}Vhz zG&>sLTy;J(8#k6p*ue~)%h+GW&A+-mbu5SHz#p&^6%;q3$4Vta;TSpRW#y*lK045` zZO_#LhBl7vRK%o=-HD2N6@^{3glnKcDyEBz&`cd2y&F zk-8R}>MrTlJy_XzVA_lJx%M23J+~-&-!3CEQy4))P6wefwrZZFa$2A(yE*LGTHi#Z zY+3KF=y6}vflAOV^CjgaQS>*t!s(esF{EZV2Wgqbpa5L8;CgZ|-&hJ96fR5%1sUJh z5CDoH2PIOrN-~Kk{6{#7NW~t3YfnFjJri3hSJ_gxap3{k94g48uWHL6pnu_ZS5s;% zSw$xCEI!M>zT6{VkLFhh;3~%gcFObQ1=!NQHGlB>Ef!$ulr-VXPjKUj<_^{?$5;Tk z*}2s{;a2Mg{rW-v@iRNLd5KGn9s!CH8%%JC9k2;nfp=+(8s703C-By`__5VWv-U{> zxsAN30jbz@cv|n()+cr+l^G>mefkjsQU1*UUvEr~m8?eL7w-pH#R@EN11x(WBhiwq zRn79(XAmKfH_0pdHwX=;FV#-&7Q=Nj{s_W07PM#2*V$(W+ZIhkclfhp7X2!3P=;yL zuww$iRX%!Jy$Lz?5z;|G69Ldm^@fv-V=q6c_Yrxy@kDt-XX@SI}B~YoL6z z3C=jlD{ZQ4z9iC{WpJNB0W`z)HsoySNN1rNUo2GFeo!~W^6`V__uOv?;8y7aFBg(F z@D|a1BRhRLP1pS2$)jm-$D@r*%r9-2pdsgWSO&;jqG)v6Ej05yB*3x=>}xk;j!1x$ zxsp&@a0{Xnf`D@N$J(uo zKQNAC;>JIO*OPEeW5Hg)=0}Nr8CFwnH&@pELyMrFd!#e?@x29eP8VGiPZ zxC+u425x6a8#3|`*l#w)3B>_xd@TUAx=j;sVKATL34l#r@X5IGC;#>qX&DC5U%iS9 z&|WrD;U_c#aQM%aaDtXz)5Hl__MId^pS|DFb*TVR)!qN~;j@2S!23lqnT0wNjH2Fq zhj2J;g8;wJc1+Mq0QO8tKsao?uNi?Ph{>@d9F)`7{8>==o7Tc`;i5B7dk`O~=8+io z>%5oZgy^CRmJ=^hPp2`jw-&&`3nKl6$7_G`tX};HfrPm$t(OOPwfDY5VTt-cIEOAA zuHmuW^YSSKSZ=Kak*&uA%JXw--1ujif&d+QZMBn|8_)U{{;c+fs2mA%TyhzJn@AAl zYMVTsbiKaju#0?*ZAR`r2TOcjX3XpGs* zJ*X_~Z74k2F3%0ly*o!ZWX~&Zhd`%()@jQ7=KF&5Rv(6HX6f*(N*lp9r`-{l3r8VB ziaruAxAvJES90-X1WJ(^1*Fa$1vR;9lU8!qL*sQYU(O=%1`7`e-R5^i)EU4i*Iv&l zZlLn)9PF8fiCVyg)5pcRfT!-{*DbijcLY{f!MWF?1&hd!h;bgO6W~KmU6|BciUXP1 zHL9TXop5MrXveO?s?=K66}Wbn#RS>9IZ$?$(+!bI;uPlwt+!^q?w!~US#=*51Rp*N zD~9cIhl$=j3So~f#|5z$`1Ro>`ZC8{N_g^=E6TdZCRiR6;QC{Umm4Sr^}azw@f!tw3O z774l%23^sZqY%DapCL{#Tt^Ak_{v-LFej~NjpI{|<&y#CU}E}dsV@^2YowauSYtE< zM|b}jO(z&HQ?&bo({x6C7Yed!vaWydEHkzM1{!7;f{~BH2c5M7VW0VnmFA3xdy|c7 z!?oeF129?}P@ypy>{7Wm9{VglJN!4Z+5Xqh7F4g(GvcuaAVP~w^UT)EC2Al2?p2HFb^Q>X^ zOtN%emNp*LM#_YxZTWdU3;52n8631nn1%)1pUg5nFkv>%S#Jo=JtWbQe?R?rEbET4 z%Y!SkejIT9Dg4g@E@l&UHMNWDe(C>7SJd~vB=7cRJ4}as?TE0jmfBkTKpAj@X_TDG zCahXrvUY_6_o?AFK9l~JRG)-DkYwJFI>j@v-iZuCAC>^vr;BGJU!&bFp}AjF1fkQ9 zKWaK{bVnN1(rou$xJv(p=cUi1&OMjnxI}vLu<(hgyS2P`EIIwy6TOLU*E`vlNWqu8euWkYP*PA4btgYu hDzub+GY*j`X7*PotqaP(U{3-7gY%~6iq1HM|3A(dOGN+x literal 0 HcmV?d00001 diff --git a/playwright/tests/minimal/minimal-scenario.spec.ts b/playwright/tests/minimal/minimal-scenario.spec.ts new file mode 100644 index 0000000..06a24a7 --- /dev/null +++ b/playwright/tests/minimal/minimal-scenario.spec.ts @@ -0,0 +1,318 @@ + +import { test, expect } from "../../fixtures/auth-fixture"; +import { generateRoomName, generateTestUserData, openCreateAccountLegacyLink, openResetPasswordEmailLegacy, } from "../../utils/auth-helpers"; +import { ELEMENT_URL, INVITED_EMAIL_DOMAIN, STANDARD_EMAIL_DOMAIN, USE_MAS } from "../../utils/config"; +import { getLatestVerificationCode, waitForMessage } from "../../utils/mailpit"; +import path from "path"; + +//this scenario is one big test to cover all the scenario on a not MAS synapse (dev02 - a) and one MAS synapse (ext01 - e) + + + + + +test.describe.serial("Minimal scenario", () => { + + test.setTimeout(180_000); + + const external_user = generateTestUserData(INVITED_EMAIL_DOMAIN); + const agent_user = generateTestUserData(STANDARD_EMAIL_DOMAIN); +/* + * tested: + * creer compte agent + * creer salon privé + * creer forum public + * creer salon privé ouverts aux externes + * inviter un externe + * envoyer fichier, fichier vérolé + * activer la sauvegarde + * vérifier qu'un externe ne peut pas créer de salon, ne peut pas chercher un forum + * se déconnecter + * reset mot de passe + * TODO : A. exporter les participants de la room + * TODO : A. expirer le compte, vérifier que les clients affichent un truc cohérent, + */ + + + + test("test all", async ({ + page, + context, + screenChecker, + browser + }) => { + + + + const invitee1_search_name = "olivier test1";// TODO : ensure that invitee exists in the environment + const invitee1_display_name = "Olivier Test1";// TODO : ensure that invitee exists in the environment + + const invitee2_email = "testeur@agent2.tchap.incubateur.net"; // TODO : ensure that invitee exists in the environment + const invitee2_display_name = "Testeur [Incubateur]";// TODO : ensure that invitee exists in the environment + + const public_room_name = generateRoomName("Forum"); + const room_name = generateRoomName("Salon Privé_"); + + // Grant clipboard permissions to browser context + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + + //creer compte agent without MAS + if(!USE_MAS){ + console.log("do not use MAS") + + await page.goto(ELEMENT_URL); + await page.getByRole('link', { name: 'Créer un compte' }).click(); + await page.getByRole('textbox', { name: 'Votre adresse mail' }).fill(agent_user.email); + await page.getByRole('button', { name: 'Continuer' }).click(); + await page.getByRole('textbox', { name: 'Adresse mail' }).fill(agent_user.email); + await page.getByRole('textbox', { name: 'Mot de passe', exact: true }).fill(agent_user.password); + await page.getByRole('textbox', { name: 'Confirmer le mot de passe' }).fill(agent_user.password); + await page.getByRole('button', { name: 'S’inscrire' }).click(); + + //cliquer sur le lien du mail pour valider l'inscription + const tab = await openCreateAccountLegacyLink(context, screenChecker, agent_user.email); + await expect(tab.getByText('Votre email a été validé')).toBeVisible(); + await tab.close(); + }else{ + console.log("use MAS") + + await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'load' }); + await screenChecker(page, '#/welcome'); + await page.getByRole('link').filter({ hasText: 'Créer un compte' }).click(); + + await screenChecker(page, '#/email-precheck-sso'); + await page.locator('input').fill(agent_user.email); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + await screenChecker(page, '/register'); + await page.getByRole('button').filter({ hasText: 'Continuer avec mon adresse mail' }).click(); + await screenChecker(page, '/register/password'); + await expect(page.locator('input[name="email"]')).toHaveValue(agent_user.email); + + await page.locator('input[name="password"]').fill(agent_user.password); + await page.locator('input[name="password_confirm"]').fill(agent_user.password); + + //wait for password-confirm matching confirmation + await page.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field + //await expect(page.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click({clickCount:3}); //2 clicks works better than one + + await screenChecker(page, '/verify-email'); + let verificationCode = await getLatestVerificationCode(agent_user.email); + await page.locator('input[name="code"]').fill(verificationCode); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + await screenChecker(page, '/consent'); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + } + + + //await expect(page.locator('text=Bienvenue')).toBeVisible({timeout: 20000}); + await page.waitForSelector(".mx_MatrixChat", { timeout: 20000 }); + + //configurer la sauvegarde + await screenChecker(page, "#/home") + + //configurer la sauvegarde (only possible at first connexion) + await page.getByRole('button', { name: 'Avatar' }).click(); + await page.getByRole('tab', { name: 'Messages chiffrés' }).click(); + await page.getByRole('button', { name: 'Configurer la sauvegarde' }).click(); + await page.getByRole('button', { name: 'Continuer' }).click(); + await page.getByRole('button', { name: 'Copier et continuer' }).click(); + + const handle = await page.evaluateHandle(() => navigator.clipboard.readText()); + const clipboardContent = await handle.jsonValue(); + console.log(`verification code : ${clipboardContent}`); + const verificationCode = clipboardContent; + + //fill the recupration code from the clipboard content + await page.getByRole('textbox', { name: 'Saisir le code de vérification' }).fill(verificationCode); + await page.getByRole('button', { name: 'Terminer la configuration' }).click(); + await page.getByRole('button', { name: 'Terminé' }).click(); + await page.getByRole('button', { name: 'Fermer la boîte de dialogue' }).click(); + + await screenChecker(page, "#/home") + + //await page.getByRole('button', { name: 'OK' }).click(); + //await page.getByRole('button', { name: 'OK' }).click(); + + + //creer salon public + await page.getByRole("button", { name: "Ajouter", exact: true }).click(); + await page.getByRole("menuitem", { name: "Nouveau salon", exact: true }).click(); + const dialog = page.locator(".tc_TchapCreateRoomDialog"); + await page.getByRole('textbox', { name: 'Nom' }).fill(public_room_name); + await dialog + .locator(".tc_TchapRoomTypeSelector_RadioButton_title") + .getByText("Forum") + .click(); + await dialog.getByRole("button", { name: "Créer un nouveau salon" }).click(); + await expect(page.locator('button').filter({ hasText: public_room_name })).toBeVisible(); + + //ecrire dans le salon public + await page.locator('.mx_BasicMessageComposer') + .getByRole('textbox') + .fill('message non chiffré'); + await page.getByRole('button', { name: 'Envoyer le message' }).click(); + await expect(page.getByRole('status', { name: 'Votre message a été envoyé' })).toBeVisible(); + + + + //chercher salon public + /* + bug in preprod + await page.getByRole("button", { name: "Ajouter", exact: true }).click(); + await page.getByRole("menuitem", { name: "Rejoindre un forum", exact: true }).click(); + await page.getByRole('textbox', { name: 'Rechercher' }).fill(public_room_name); + await expect(page.getByLabel('Suggestions').getByText(public_room_name)).toBeVisible(); + await page.getByRole('textbox', { name: 'Rechercher' }).press('Escape'); + */ + + //creer salon privé + await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); + await page.getByText('Nouveau salon').click(); + await page.getByRole('textbox', { name: 'Nom' }).fill(room_name); + await page.getByRole('button', { name: 'Créer un nouveau salon' }).click(); + + //ecrire dans le salon privé + await page.locator('.mx_BasicMessageComposer') + .getByRole('textbox') + .fill('message chiffré'); + await page.getByRole('button', { name: 'Envoyer le message' }).click(); + await expect(page.getByRole('status', { name: 'Votre message a été envoyé' })).toBeVisible(); + + //vérfier les parametres du salon privé + await page.locator('button').filter({ hasText: room_name }).click(); + await page.getByRole('menuitem', { name: 'Paramètres' }).click(); + await page.getByText('Vie privée').click(); + await expect(page.getByRole('switch', { name: 'Chiffré' })).toBeDisabled(); + await page.getByRole('button', { name: 'Fermer la boîte de dialogue' }).click(); + + //inviter agents by name + await page.getByRole('button', { name: 'Personnes' }).click(); + await page.getByRole('button', { name: 'Inviter', exact: true }).click(); + await page.getByRole('textbox', { name: 'Rechercher' }).fill(invitee1_search_name); + await page.getByText(invitee1_display_name).first().click(); + await page.getByRole('button', { name: 'Inviter' }).click(); + await expect(page.getByTestId('virtuoso-item-list').getByText(invitee1_display_name)).toBeVisible(); + + //inviter agents by email + await page.getByRole('button', { name: 'Inviter dans ce salon' }).click(); + await page.getByRole('textbox', { name: 'Rechercher' }).fill(invitee2_email); + await page.getByRole('button', { name: 'Inviter' }).click(); + await expect(page.getByTestId('virtuoso-item-list').getByText(invitee2_display_name)).toBeVisible(); + + //envoyer fichier png + await page + .locator(".mx_MessageComposer_actions input[type='file']") + .setInputFiles(path.join(__dirname, "../../sample-files/element.png")); + + await page.getByRole("button", { name: "Envoyer" }).click() + await page.getByRole('link', { name: 'element.png' }).click(); + await page.getByRole('button', { name: 'Fermer' }).click(); + + //envoyer fichier vérolé + await page + .locator(".mx_MessageComposer_actions input[type='file']") + .setInputFiles(path.join(__dirname, "../../sample-files/eicar.com")); + await page.getByRole('button', { name: 'Envoyer' }).click(); + await page.getByRole('listitem').filter({ hasText: /^Contenu bloqué$/ }) + + //creer salon privé ouvert aux externes + await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); + await page.getByText('Nouveau salon').click(); + await page.getByLabel('Créer un salon').click(); + await page.getByRole('radio', { name: 'Salon ouvert aux externes' }); + await page.getByRole('textbox', { name: 'Nom' }).fill('Salon ouvert aux externes'); + await page.getByRole('button', { name: 'Créer un nouveau salon' }).click(); + + //inviter agent externe + await page.getByRole('button', { name: 'Personnes' }).click(); + await page.getByRole('button', { name: 'Inviter dans ce salon' }).click(); + await page.getByRole('textbox', { name: 'Rechercher' }).fill(external_user.email); + await page.getByRole('button', { name: 'Inviter' }).click(); + //await expect(page.getByTestId('virtuoso-item-list').getByText(external_user.username)).toBeVisible(); + + //disconnect + await page.getByRole('button', { name: 'Avatar' }).click(); + await page.getByRole('button', { name: 'Se déconnecter' }).click(); + await page.locator('#mx_Dialog_Container').getByRole('button', { name: 'Se déconnecter' }).click(); + //reset passsword + await page.getByRole('link', { name: 'Se connecter' }).click(); + await page.getByRole('textbox', { name: 'Votre adresse mail' }).fill(agent_user.email); + await page.getByRole('button', { name: 'Continuer' }).click(); + await page.getByRole('button', { name: 'Mot de passe oublié ?' }).click(); + await page.getByRole('textbox', { name: 'Adresse mail' }).fill(agent_user.email); + await page.getByRole('button', { name: 'Envoyer le mail' }).click(); + + await openResetPasswordEmailLegacy(context, screenChecker, agent_user.email); + + const new_password = agent_user.password + '4'; + await page.getByRole('button', { name: 'Suivant' }).click(); + await page.getByRole('textbox', { name: 'Nouveau mot de passe', exact: true }).fill(new_password); + await page.getByRole('textbox', { name: 'Confirmer le nouveau mot de' }).fill(new_password); + await page.getByRole('button', { name: 'Réinitialiser le mot de passe' }).click(); + await page.getByRole('button', { name: 'Retourner à l’écran de' }).click();//back to login + + + const context_ext = await browser.newContext(); + const page_ext = await context_ext.newPage(); + + //invitation takes time to be available + const {message, content} = await waitForMessage(external_user.email , 20000, "Invitation"); + + //register user on ext01 (with MAS) + await page_ext.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'load' }); + await screenChecker(page_ext, '#/welcome'); + await page_ext.getByRole('link').filter({ hasText: 'Créer un compte' }).click(); + + await screenChecker(page_ext, '#/email-precheck-sso'); + await page_ext.locator('input').fill(external_user.email); + await page_ext.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + await screenChecker(page_ext, '/register'); + await page_ext.getByRole('button').filter({ hasText: 'Continuer avec mon adresse mail' }).click(); + + await expect(page_ext.locator('input[name="email"]')).toHaveValue(external_user.email); + + await page_ext.locator('input[name="password"]').fill(external_user.password); + await page_ext.locator('input[name="password_confirm"]').fill(external_user.password); + + //wait for password-confirm matching confirmation + await page_ext.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field + await expect(page_ext.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); + await page_ext.getByRole('button').filter({ hasText: 'Continuer' }).click({clickCount:2}); //2 clicks works better than one + + const verificationCode2 = await getLatestVerificationCode(external_user.email); + await page_ext.locator('input[name="code"]').fill(verificationCode2); + await page_ext.getByRole('button').filter({ hasText: 'Continuer' }).click(); + await page_ext.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + await page_ext.waitForSelector(".mx_MatrixChat", { timeout: 20000 }); + + //rejoindre le salon + await page_ext.locator('div').filter({ hasText: /^Salon ouvert aux externes$/ }).first().click(); + await page_ext.getByRole('button', { name: 'Accepter' }).click(); + await expect(await page_ext.getByText('a créé ce salon. C’est le début de')).toBeVisible(); + + //ne peut pas créer de salon + await page_ext.getByRole('button', { name: 'Ajouter', exact: true }).click(); + await page_ext.getByText('Nouveau salon').click(); + await page_ext.getByRole('textbox', { name: 'Nom' }).fill('test'); + await screenChecker(page_ext, '#/room') + await page_ext.getByRole('button', { name: 'Créer un nouveau salon' }).click(); + await screenChecker(page_ext, '#/room') + await expect(await page_ext.getByText('En tant qu\'invité externe,')).toBeVisible(); + await page_ext.getByRole('button', { name: 'OK' }).click(); + + + //ne peut pas trouver le salon public + /* + await page_ext.getByRole('button', { name: 'Ajouter', exact: true }).click(); + await page_ext.getByRole('menuitem', { name: 'Rejoindre un forum', exact: true }).click(); + await page_ext.getByRole('textbox', { name: 'Rechercher' }).fill(public_room_name); + await expect(page_ext.getByLabel('Suggestions').getByText(public_room_name)).toHaveCount(0); + await page_ext.getByRole('textbox', { name: 'Rechercher' }).press('Escape'); + */ + }) +}) \ No newline at end of file diff --git a/playwright/tests/web/create-room.spec.ts b/playwright/tests/web/create-room.spec.ts index 12a3d98..d60f710 100644 --- a/playwright/tests/web/create-room.spec.ts +++ b/playwright/tests/web/create-room.spec.ts @@ -12,9 +12,9 @@ test.describe("Create Room", () => { const name = "Test room public 1"; - await page.getByRole("button", { name: "Add room", exact: true }).click(); + await page.getByRole("button", { name: "Ajouter", exact: true }).click(); - await page.getByRole("menuitem", { name: "New room", exact: true }).click(); + await page.getByRole("menuitem", { name: "Nouveau salon", exact: true }).click(); const dialog = page.locator(".tc_TchapCreateRoomDialog"); // Fill name @@ -23,11 +23,11 @@ test.describe("Create Room", () => { // Select public room option await dialog .locator(".tc_TchapRoomTypeSelector_RadioButton_title") - .getByText("Public room") + .getByText("Forum") .click(); // Submit - await dialog.getByRole("button", { name: "Create New Room" }).click(); + await dialog.getByRole("button", { name: "Créer un nouveau salon" }).click(); // In local test An error dialog should appear first complaining about wss socket and SSL certificate error // So not really working locally @@ -56,9 +56,9 @@ test.describe("Create Room", () => { console.log("authenticatedUser", authenticatedUser); const name = "Test room private 1"; - await page.getByRole("button", { name: "Add room", exact: true }).click(); + await page.getByRole("button", { name: "Ajouter", exact: true }).click(); - await page.getByRole("menuitem", { name: "New room", exact: true }).click(); + await page.getByRole("menuitem", { name: "Nouveau salon", exact: true }).click(); const dialog = page.locator(".tc_TchapCreateRoomDialog"); // Fill name @@ -67,11 +67,11 @@ test.describe("Create Room", () => { // Select public room option await dialog .locator(".tc_TchapRoomTypeSelector_RadioButton_title") - .getByText("Private room", { exact: true }) + .getByText("Salon", { exact: true }) .click(); // Submit - await dialog.getByRole("button", { name: "Create New Room" }).click(); + await dialog.getByRole("button", { name: "Créer un nouveau salon" }).click(); // In local test An error dialog should appear first complaining about wss socket and SSL certificate error // So not really working locally @@ -100,9 +100,9 @@ test.describe("Create Room", () => { console.log("authenticatedUser", authenticatedUser); const name = "Test room private external 1"; - await page.getByRole("button", { name: "Add room", exact: true }).click(); + await page.getByRole("button", { name: "Ajouter", exact: true }).click(); - await page.getByRole("menuitem", { name: "New room", exact: true }).click(); + await page.getByRole("menuitem", { name: "Nouveau salon", exact: true }).click(); const dialog = page.locator(".tc_TchapCreateRoomDialog"); // Fill name @@ -111,11 +111,11 @@ test.describe("Create Room", () => { // Select public room option await dialog .locator(".tc_TchapRoomTypeSelector_RadioButton_title") - .getByText("Private room open to external users") + .getByText("Salon ouvert aux externes") .click(); // Submit - await dialog.getByRole("button", { name: "Create New Room" }).click(); + await dialog.getByRole("button", { name: "Créer un nouveau salon" }).click(); // In local test An error dialog should appear first complaining about wss socket and SSL certificate error // So not really working locally diff --git a/playwright/utils/auth-helpers.ts b/playwright/utils/auth-helpers.ts index 60dce3e..9a168f6 100644 --- a/playwright/utils/auth-helpers.ts +++ b/playwright/utils/auth-helpers.ts @@ -4,7 +4,7 @@ import { waitForMasUser, createMasUserWithPassword, deactivateMasUser } from './ import { ELEMENT_URL, KEYCLOAK_URL, MAS_URL, SCREENSHOTS_DIR, TEST_USER_PASSWORD, TEST_USER_PREFIX } from './config'; import { Credentials } from './api'; import { ScreenCheckerFixture } from '../fixtures/auth-fixture'; -import { getPasswordResetLink } from './mailpit.js'; +import { getCreateAccountLegacyLink, getPasswordResetLink, getPasswordResetLinkLegacy } from './mailpit.js'; /** * Test user type */ @@ -211,6 +211,38 @@ export async function openResetPasswordEmail(context: BrowserContext, screenChec return resetPasswordPage; } +// open reset password screen from email +export async function openResetPasswordEmailLegacy(context: BrowserContext, screenChecker:ScreenCheckerFixture, userEmail: string): Promise { + // Use Mailpit API to get password reset link instead of browser automation + const resetLink = await getPasswordResetLinkLegacy(userEmail); + + // Create a new page and navigate directly to the reset link + const resetPasswordPage = await context.newPage(); + await resetPasswordPage.goto(resetLink); + + await screenChecker(resetPasswordPage, 'password_reset'); + + await resetPasswordPage.getByRole('button', {name:'Continuer la réinitialisation'}).click(); + await resetPasswordPage.getByText('La vérification de votre adresse email est réussie!'); + + return resetPasswordPage; +} + + +// clik on Create Account Legacy Link +export async function openCreateAccountLegacyLink(context: BrowserContext, screenChecker:ScreenCheckerFixture, userEmail: string): Promise { + // Use Mailpit API to get password reset link instead of browser automation + const resetLink = await getCreateAccountLegacyLink(userEmail); + + // Create a new page and navigate directly to the reset link + const resetPasswordPage = await context.newPage(); + await resetPasswordPage.goto(resetLink); + + await screenChecker(resetPasswordPage, 'unstable/registration'); + + return resetPasswordPage; +} + export async function loginWithPassword( page: Page, @@ -266,6 +298,13 @@ export async function performSimplePasswordLogin( console.log(`[Auth] Password login successful for user: ${user.username}`); } +export function generateRoomName(prefix:string){ + const timestamp = new Date().getTime(); + const randomSuffix = Math.floor(Math.random() * 10000); + return `prefix_${timestamp}_${randomSuffix}` +} + + // Generate a unique username and email for testing export function generateTestUserData(domain:string):TestUser { const timestamp = new Date().getTime(); @@ -304,7 +343,7 @@ export async function populateLocalStorageWithCredentials(page: Page, credential // Retain any other settings which may have already been set ...JSON.parse(window.localStorage.getItem("mx_local_settings") ?? "{}"), // Ensure the language is set to a consistent value - language: "en", + language: "fr", }), ); }, diff --git a/playwright/utils/config.ts b/playwright/utils/config.ts index 9711db9..7df9d2d 100644 --- a/playwright/utils/config.ts +++ b/playwright/utils/config.ts @@ -3,7 +3,7 @@ import path from 'path'; // Determine which environment to use -export const env = process.env.TEST_ENV || 'local'; +export const env = process.env.ENV || 'local'; console.log(`Loading environment configuration for: ${env}`); // Load environment variables from the appropriate .env file @@ -13,12 +13,14 @@ dotenv.config({ path: path.resolve(__dirname, `../.env.${env}`) }); //dotenv.config(); // URLs -export const MAS_URL = process.env.MAS_URL || 'https://auth.tchapgouv.com'; -export const KEYCLOAK_URL = process.env.KEYCLOAK_URL || 'https://sso.tchapgouv.com'; -export const ELEMENT_URL = process.env.ELEMENT_URL || 'https://element.tchapgouv.com'; -export const BASE_URL = process.env.BASE_URL || "https://matrix.tchapgouv.com"; -export const MAIL_URL = process.env.MAIL_URL || "https://mail.tchapgouv.com"; +export const MAS_URL = process.env.MAS_URL|| ""; +export const KEYCLOAK_URL = process.env.KEYCLOAK_URL || ""; +export const ELEMENT_URL = process.env.ELEMENT_URL || ""; +export const BASE_URL = process.env.BASE_URL|| ""; +export const MAIL_URL = process.env.MAIL_URL || ""; +export const MAILPIT_USER = process.env.MAILPIT_USER || ""; +export const MAILPIT_PWD = process.env.MAILPIT_PWD || ""; export const TCHAP_LEGACY:boolean = Boolean(process.env.TCHAP_LEGACY); @@ -36,11 +38,11 @@ export const TEST_USER_PREFIX = process.env.TEST_USER_PREFIX || 'user.test'; export const TEST_USER_PASSWORD = process.env.TEST_USER_PASSWORD || 'Test@123456'; // Email domains for test users -export const STANDARD_EMAIL_DOMAIN = process.env.STANDARD_EMAIL_DOMAIN || 'tchapgouv.com'; -export const INVITED_EMAIL_DOMAIN = process.env.INVITED_EMAIL_DOMAIN || 'invited.externe.com'; -export const NOT_INVITED_EMAIL_DOMAIN = process.env.NOT_INVITED_EMAIL_DOMAIN || 'not.invited.externe.com'; -export const WRONG_SERVER_EMAIL_DOMAIN = process.env.WRONG_SERVER_EMAIL_DOMAIN || 'wrong.server.com'; -export const NUMERIQUE_EMAIL_DOMAIN = process.env.NUMERIQUE_EMAIL_DOMAIN || 'numerique.gouv.fr'; +export const STANDARD_EMAIL_DOMAIN = process.env.STANDARD_EMAIL_DOMAIN || ""; +export const INVITED_EMAIL_DOMAIN = process.env.INVITED_EMAIL_DOMAIN || ""; +export const NOT_INVITED_EMAIL_DOMAIN = process.env.NOT_INVITED_EMAIL_DOMAIN|| ""; +export const WRONG_SERVER_EMAIL_DOMAIN = process.env.WRONG_SERVER_EMAIL_DOMAIN || ""; +export const NUMERIQUE_EMAIL_DOMAIN = process.env.NUMERIQUE_EMAIL_DOMAIN || ""; // Screenshots directory export const SCREENSHOTS_DIR = process.env.SCREENSHOTS_DIR || 'playwright-results'; @@ -48,6 +50,7 @@ export const SCREENSHOTS_DIR = process.env.SCREENSHOTS_DIR || 'playwright-result // Browser locale export const BROWSER_LOCALE = process.env.BROWSER_LOCALE || 'fr-FR'; +export const USE_MAS = process.env.USE_MAS === 'true' || false; // TODO Move all below to env file // Fixed tests data, that can be used across environement diff --git a/playwright/utils/mailpit.ts b/playwright/utils/mailpit.ts index d4751bd..edb1135 100644 --- a/playwright/utils/mailpit.ts +++ b/playwright/utils/mailpit.ts @@ -1,5 +1,5 @@ import { MailpitClient } from 'mailpit-api'; -import { MAIL_URL } from './config'; +import { MAIL_URL, MAILPIT_PWD, MAILPIT_USER } from './config'; /** @@ -16,6 +16,13 @@ function extractCodeFromContent(content: string): string { return codeMatch[1]; } +export async function getMailpitClient(){ + const mailpit = await new MailpitClient(MAIL_URL, MAILPIT_USER != "" ? {username: MAILPIT_USER, password : MAILPIT_PWD} : undefined); + await mailpit.getInfo(); + return mailpit; +} + + /** * Get the most recent verification code from Mailpit for a specific recipient * @param toEmail - The recipient email address to filter messages @@ -23,32 +30,8 @@ function extractCodeFromContent(content: string): string { */ export async function getLatestVerificationCode(toEmail: string): Promise { try { - const mailpit = new MailpitClient(MAIL_URL); - await mailpit.getInfo(); - - // Search for messages sent to the specific email address (most recent first) - const messages = await mailpit.searchMessages({ - query: `to:${toEmail}`, - start: 0, - limit: 1, - }); - - if (!messages.messages || messages.messages.length === 0) { - throw new Error(`No emails found for recipient: ${toEmail}`); - } - - const latestMessage = messages.messages[0]; - console.log(`[Mailpit] Found email for ${toEmail}: ${latestMessage.Subject} (ID: ${latestMessage.ID})`); - // Get the full message content - const message = await mailpit.getMessageSummary(latestMessage.ID); - - // Try to extract code from text content first, fallback to HTML - let content = message.Text || message.HTML || ''; - - if (!content) { - throw new Error('Email content is empty'); - } + const {message, content} = await waitForMessage(toEmail , 20000, "Votre code de vérification est"); console.log('[Mailpit] Email content preview:', content.substring(0, 200)); @@ -79,37 +62,40 @@ function extractResetLinkFromContent(content: string): string { return urlMatch[1]; } -/** - * Get the password reset link from the most recent email for a specific recipient - * @param toEmail - The recipient email address to filter messages - * @returns The password reset URL from the most recent email - */ -export async function getPasswordResetLink(toEmail: string): Promise { + +function extractResetLinkLegacyFromContent(content: string): string { + // Match URLs that contain password/recovery or similar patterns + // Look for full URLs in the email + console.log(content); + + const urlMatch = content.match(/(https?:\/\/[^\s<>"]+(?:password_reset)[^\s<>"]*)/i); + if (!urlMatch) { + throw new Error('Unable to extract password reset link from email content'); + } + return urlMatch[1]; +} + +export async function getPasswordResetLinkLegacy(toEmail: string): Promise { try { - const mailpit = new MailpitClient(MAIL_URL); - await mailpit.getInfo(); - - // Search for messages sent to the specific email address (most recent first) - const messages = await mailpit.searchMessages({ - query: `to:${toEmail}`, - start: 0, - limit: 1, - }); - - if (!messages.messages || messages.messages.length === 0) { - throw new Error(`No emails found for recipient: ${toEmail}`); - } - const latestMessage = messages.messages[0]; - console.log(`[Mailpit] Found password reset email for ${toEmail}: ${latestMessage.Subject} (ID: ${latestMessage.ID})`); + const {message, content} = await waitForMessage(toEmail , 30000, "Changement de mot de passe"); - // Get the full message content - const message = await mailpit.getMessageSummary(latestMessage.ID); - let content = message.Text; - - if (!content) { - throw new Error('Email content is empty'); - } + console.log('[Mailpit] Email content preview:', content.substring(0, 300)); + + const resetLink = extractResetLinkLegacyFromContent(content); + console.log(`[Mailpit] Extracted password reset link: ${resetLink}`); + + return resetLink; + } catch (error) { + console.error('[Mailpit] Error fetching password reset link:', error); + throw error; + } +} + +export async function getPasswordResetLink(toEmail: string): Promise { + try { + + const {message, content} = await waitForMessage(toEmail , 20000, "Réinitialisez le mot de passe"); console.log('[Mailpit] Email content preview:', content.substring(0, 300)); @@ -122,3 +108,87 @@ export async function getPasswordResetLink(toEmail: string): Promise { throw error; } } + +/** + * Get the password reset link from the most recent email for a specific recipient + * @param toEmail - The recipient email address to filter messages + * @returns The password reset URL from the most recent email + */ +export async function getCreateAccountLegacyLink(toEmail: string): Promise { + try { + const {message, content} = await waitForMessage(toEmail , 25000, "Vérifiez votre adresse email"); + + const resetLink = extractCreateAccountLegacyLink(content); + console.log(`[Mailpit] Extracted password reset link: ${resetLink}`); + + return resetLink; + } catch (error) { + console.error('[Mailpit] Error fetching password reset link:', error); + throw error; + } +} + +function extractCreateAccountLegacyLink(content: string): string { + // Match URLs that contain password/recovery or similar patterns + // Look for full URLs in the email + console.log(content); + + const urlMatch = content.match(/(https?:\/\/[^\s<>"]+(?:email\/submit_token)[^\s<>"]*)/i); + if (!urlMatch) { + throw new Error('Unable to extract Create Account Legacy Link from email content'); + } + return urlMatch[1]; +} + + +/** + * Search for messages and get content with retry + */ +export async function waitForMessage(toEmail: string, maxWaitTimeMs = 10000, subject:string): Promise<{message: any, content: string}> { + + const mailpit = await getMailpitClient(); + + const startTime = Date.now(); + let retryCount = 0; + const retryDelay = 1000; // 1 second delay + + while (Date.now() - startTime < maxWaitTimeMs) { + try { + const messages = await mailpit.searchMessages({ + query: `to:${toEmail} subject:${subject}`, + start: 0, + limit: 1, + }); + + if (messages.messages && messages.messages.length > 0) { + const latestMessage = messages.messages[0]; + console.log(`[Mailpit] Found email for ${toEmail}: ${latestMessage.Subject} (ID: ${latestMessage.ID})`); + + // Get the full message content + const message = await mailpit.getMessageSummary(latestMessage.ID); + let content = message.Text; + + if (!content) { + throw new Error('Email content is empty'); + } + + console.log('[Mailpit] Email content preview:', content.substring(0, 300)); + + return { message, content }; + } + + // No message found, retry + retryCount++; + console.log(`[Mailpit] Waiting for emails found for ${toEmail} with subject ${subject} , retrying... (${retryCount})`); + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } catch (error) { + // Error occurred, retry + retryCount++; + console.log(`[Mailpit] Error searching for emails:`, error); + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + } + + // Max time reached without success + throw new Error(`No emails found for ${toEmail} after ${maxWaitTimeMs}ms`); +} \ No newline at end of file From 30ff3213e6ed857ec628109d440a0379e840cd47 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Fri, 13 Mar 2026 10:02:26 +0100 Subject: [PATCH 55/71] add expiration tests (#33) --- playwright/.env.dev01 | 4 +- playwright/.env.dev02 | 4 +- playwright/.env.local | 4 +- playwright/.env.preprod | 10 +- playwright/.env.sample | 4 +- playwright/playwright.config.ts | 2 +- .../tests/auth/tchap-expiration.spec.ts | 123 ++++++++++++++++++ playwright/utils/auth-helpers.ts | 17 ++- playwright/utils/config.ts | 3 +- playwright/utils/mailpit.ts | 21 +++ playwright/utils/synapse-admin.ts | 45 +++++++ 11 files changed, 226 insertions(+), 11 deletions(-) create mode 100644 playwright/tests/auth/tchap-expiration.spec.ts create mode 100644 playwright/utils/synapse-admin.ts diff --git a/playwright/.env.dev01 b/playwright/.env.dev01 index 758c1da..7187cc8 100644 --- a/playwright/.env.dev01 +++ b/playwright/.env.dev01 @@ -39,4 +39,6 @@ SCREENSHOTS_DIR=playwright-results/dev01 TEST_IN_PARALLEL=false -USE_MAS=true \ No newline at end of file +USE_MAS=true + +SYNAPSE_ADMIN_TOKEN= \ No newline at end of file diff --git a/playwright/.env.dev02 b/playwright/.env.dev02 index 8fdaf33..240c452 100644 --- a/playwright/.env.dev02 +++ b/playwright/.env.dev02 @@ -39,4 +39,6 @@ SCREENSHOTS_DIR=playwright-results/dev02 #Run tests in parallel TEST_IN_PARALLEL=false -USE_MAS=false \ No newline at end of file +USE_MAS=false + +SYNAPSE_ADMIN_TOKEN= \ No newline at end of file diff --git a/playwright/.env.local b/playwright/.env.local index 524fca0..1139166 100644 --- a/playwright/.env.local +++ b/playwright/.env.local @@ -42,4 +42,6 @@ TEST_IN_PARALLEL=false NODE_TLS_REJECT_UNAUTHORIZED = '0' -USE_MAS=true \ No newline at end of file +USE_MAS=true + +SYNAPSE_ADMIN_TOKEN= \ No newline at end of file diff --git a/playwright/.env.preprod b/playwright/.env.preprod index ae09471..2ca5879 100644 --- a/playwright/.env.preprod +++ b/playwright/.env.preprod @@ -1,8 +1,8 @@ # URLs -MAS_URL=https://auth.a.tchap.gouv.fr +MAS_URL=https://auth.i.tchap.gouv.fr #KEYCLOAK_URL=https://identite-sandbox.proconnect.gouv.fr/users/start-sign-in ELEMENT_URL=https://www.beta.tchap.gouv.fr -BASE_URL=https://matrix.a.tchap.gouv.fr +BASE_URL=https://matrix.i.tchap.gouv.fr MAIL_URL=https://yop.tchap.incubateur.net # mailpit user credentials @@ -23,7 +23,7 @@ TEST_USER_PREFIX=user.preprod TEST_USER_PASSWORD=Test@123456sdksdfkljfs222 # Email domains for test users -STANDARD_EMAIL_DOMAIN=yop2.tchap.incubateur.net +STANDARD_EMAIL_DOMAIN=yop1.tchap.incubateur.net INVITED_EMAIL_DOMAIN=yopext.tchap.incubateur.net NOT_INVITED_EMAIL_DOMAIN=gmail.com WRONG_SERVER_EMAIL_DOMAIN=wrong.server.com @@ -39,4 +39,6 @@ SCREENSHOTS_DIR=playwright-results/preprod TEST_IN_PARALLEL=false -USE_MAS=false \ No newline at end of file +USE_MAS=true + +SYNAPSE_ADMIN_TOKEN= \ No newline at end of file diff --git a/playwright/.env.sample b/playwright/.env.sample index 4cc6c97..cea7a19 100644 --- a/playwright/.env.sample +++ b/playwright/.env.sample @@ -39,4 +39,6 @@ SCREENSHOTS_DIR=playwright-results BROWSER_LOCALE=fr-FR #disable tls verification in nodeJS (required for axios to work with self signed certificate) -NODE_TLS_REJECT_UNAUTHORIZED = '0' \ No newline at end of file +NODE_TLS_REJECT_UNAUTHORIZED = '0' + +SYNAPSE_ADMIN_TOKEN= \ No newline at end of file diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts index a0c38a7..1307585 100644 --- a/playwright/playwright.config.ts +++ b/playwright/playwright.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 2, + retries: process.env.CI ? 2 : 0, /* Reporter to use */ reporter: 'html', /* Shared settings for all the projects below */ diff --git a/playwright/tests/auth/tchap-expiration.spec.ts b/playwright/tests/auth/tchap-expiration.spec.ts new file mode 100644 index 0000000..fade22d --- /dev/null +++ b/playwright/tests/auth/tchap-expiration.spec.ts @@ -0,0 +1,123 @@ +import { test, expect } from '../../fixtures/auth-fixture'; +import { + createMasTestUser, + loginWithPassword, + openRenewAccountEmail +} from '../../utils/auth-helpers'; +import { getMasUserByEmail } from '../../utils/mas-admin'; +import { STANDARD_EMAIL_DOMAIN } from '../../utils/config'; +import { setAccountExpiration } from '../../utils/synapse-admin'; + +test.describe('Tchap : account expiration', () => { + // Define the password for test users + const PASSWORD = "Test123456sdksdfkljfs222!"; + + test('should show expiration message when account is expired', async ({ + context, + page, + request, + screenChecker: screen + }) => { + // Create a new user with password authentication + const user = await createMasTestUser(STANDARD_EMAIL_DOMAIN); + console.log(`Created test user: ${user.username} with email: ${user.email}`); + + // First, log in normally to verify the account works + await loginWithPassword(page, user, screen); + + // Wait for successful login and navigation to home page + await page.waitForSelector(".mx_MatrixChat", { timeout: 20000 }); + console.log('User successfully logged in'); + + // Get the user's Matrix ID + const masUser = await getMasUserByEmail(user.email); + const matrixId = `@${masUser.attributes.username}:dev01.tchap.incubateur.net`; + console.log(`User Matrix ID: ${matrixId}`); + + // Set an expiration timestamp in the past to make the account expired + const expirationTs = Math.floor(Date.now()) - 3600000; // 1 hour in the past + console.log(`Setting expiration timestamp to: ${expirationTs} (1 hour in the past)`); + + // Call the Synapse API to set account expiration + await setAccountExpiration(request, matrixId, expirationTs, true); + + await page.getByLabel('Avatar').click(); + //await screenChecker(page, `/`) + await page.getByRole('button', { name: 'Se déconnecter' }).click(); + + // Wait a moment for the expiration to take effect + await new Promise(resolve => setTimeout(resolve, 2000)); + + + // Try to log in again with the expired account + await loginWithPassword(page, user, screen); + + // Verify that an expiration message is shown + await expect(page.getByText('Une erreur s’est produite')).toBeVisible(); + + console.log('Verified that the expired account shows expiration message'); + + // Clean up + await page.close(); + }); + + + test('should show expiration message inside app when account is expired', async ({ + context, + page, + request, + screenChecker: screen + }) => { + + test.setTimeout(180_000); + + // Create a new user with password authentication + const user = await createMasTestUser(STANDARD_EMAIL_DOMAIN); + console.log(`Created test user: ${user.username} with email: ${user.email}`); + + // First, log in normally to verify the account works + await loginWithPassword(page, user, screen); + + // Wait for successful login and navigation to home page + await page.waitForSelector(".mx_MatrixChat", { timeout: 20000 }); + console.log('User successfully logged in'); + + // Get the user's Matrix ID + const masUser = await getMasUserByEmail(user.email); + const matrixId = `@${masUser.attributes.username}:dev01.tchap.incubateur.net`; + console.log(`User Matrix ID: ${matrixId}`); + + // Set an expiration timestamp in the past to make the account expired + const expirationTs = Math.floor(Date.now()) - 3600000; // 1 hour in the past + console.log(`Setting expiration timestamp to: ${expirationTs} (1 hour in the past)`); + + // Call the Synapse API to set account expiration + await setAccountExpiration(request, matrixId, expirationTs, true); + + //trigger the expiration panel by searching a forum + await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Rejoindre un forum', exact: true }).click(); + await page.getByRole('textbox', { name: 'Rechercher' }).fill("any"); + + await expect(page.getByRole('heading', { name: 'Votre compte Tchap a expiré' })).toBeVisible({timeout:20000}); + + console.log(`Account has been expired`); + + await page.getByRole('button', { name: 'Envoyer un nouvel email' }).click(); + await expect(page.getByText('Un nouvel email de renouvellement vous a été adressé')).toBeVisible(); + await page.getByRole('button', { name: 'Envoyer un nouvel email' }).click(); + await expect(page.getByText('Attendez')).toBeVisible(); + + await openRenewAccountEmail(context, screen, user.email); + + await page.getByRole('button', { name: 'Continuer' }).click(); + await expect(page.getByRole('heading', { name: 'Votre compte Tchap a été' })).toBeVisible(); + await page.getByRole('button', { name: 'Rafraîchir la page' }).click(); + + await page.waitForSelector(".mx_MatrixChat", { timeout: 20000 }); + await expect(page.getByRole('heading', { name: 'Votre compte Tchap a été' })).not.toBeVisible(); + + + }); + +}); \ No newline at end of file diff --git a/playwright/utils/auth-helpers.ts b/playwright/utils/auth-helpers.ts index 9a168f6..660bc0b 100644 --- a/playwright/utils/auth-helpers.ts +++ b/playwright/utils/auth-helpers.ts @@ -4,7 +4,7 @@ import { waitForMasUser, createMasUserWithPassword, deactivateMasUser } from './ import { ELEMENT_URL, KEYCLOAK_URL, MAS_URL, SCREENSHOTS_DIR, TEST_USER_PASSWORD, TEST_USER_PREFIX } from './config'; import { Credentials } from './api'; import { ScreenCheckerFixture } from '../fixtures/auth-fixture'; -import { getCreateAccountLegacyLink, getPasswordResetLink, getPasswordResetLinkLegacy } from './mailpit.js'; +import { getCreateAccountLegacyLink, getExpirationAccountLink as getRenewAccountLink, getPasswordResetLink, getPasswordResetLinkLegacy } from './mailpit.js'; /** * Test user type */ @@ -211,6 +211,21 @@ export async function openResetPasswordEmail(context: BrowserContext, screenChec return resetPasswordPage; } +// open reset password screen from email +export async function openRenewAccountEmail(context: BrowserContext, screenChecker:ScreenCheckerFixture, userEmail: string): Promise { + // Use Mailpit API to get password reset link instead of browser automation + const renewLink = await getRenewAccountLink(userEmail); + + // Create a new page and navigate directly to the reset link + const resetPasswordPage = await context.newPage(); + await resetPasswordPage.goto(renewLink); + + await screenChecker(resetPasswordPage, 'email_account_validity'); + + return resetPasswordPage; +} + + // open reset password screen from email export async function openResetPasswordEmailLegacy(context: BrowserContext, screenChecker:ScreenCheckerFixture, userEmail: string): Promise { // Use Mailpit API to get password reset link instead of browser automation diff --git a/playwright/utils/config.ts b/playwright/utils/config.ts index 7df9d2d..e8e9021 100644 --- a/playwright/utils/config.ts +++ b/playwright/utils/config.ts @@ -3,7 +3,7 @@ import path from 'path'; // Determine which environment to use -export const env = process.env.ENV || 'local'; +export const env = process.env.ENV || 'dev01'; console.log(`Loading environment configuration for: ${env}`); // Load environment variables from the appropriate .env file @@ -58,3 +58,4 @@ export const FIX_USER_USERNAME = "Michelle_test"; export const FIX_USER_PASSWORD = "Michelle1313!"; export const FIX_USER_EMAIL = "Michelle1313!"; +export const SYNAPSE_ADMIN_TOKEN = process.env.SYNAPSE_ADMIN_TOKEN|| ""; diff --git a/playwright/utils/mailpit.ts b/playwright/utils/mailpit.ts index edb1135..399be5d 100644 --- a/playwright/utils/mailpit.ts +++ b/playwright/utils/mailpit.ts @@ -140,6 +140,27 @@ function extractCreateAccountLegacyLink(content: string): string { return urlMatch[1]; } +export async function getExpirationAccountLink(toEmail: string): Promise { + try { + + const {message, content} = await waitForMessage(toEmail , 20000, "Renouvelez votre compte Tchap"); + + console.log('[Mailpit] Email content preview:', content.substring(0, 300)); + + const urlMatch = content.match(/(https?:\/\/[^\s<>"]+(?:email_account_validity)[^\s<>"]*)/i); + if (!urlMatch) { + throw new Error('Unable to extract expiration account link from email content'); + } + const resetLink = urlMatch[1]; + console.log(`[Mailpit] Extracted expiration account link: ${resetLink}`); + + return resetLink; + } catch (error) { + console.error('[Mailpit] Error fetchingexpiration account:', error); + throw error; + } +} + /** * Search for messages and get content with retry diff --git a/playwright/utils/synapse-admin.ts b/playwright/utils/synapse-admin.ts new file mode 100644 index 0000000..aaf8ec8 --- /dev/null +++ b/playwright/utils/synapse-admin.ts @@ -0,0 +1,45 @@ +import { APIRequestContext } from '@playwright/test'; +import { SYNAPSE_ADMIN_TOKEN, BASE_URL } from './config'; + +/** + * Helper function to set account expiration using the Synapse admin API + * + * @param request - Playwright API request context + * @param userId - Matrix user ID (e.g. @username:domain) + * @param expirationTs - Expiration timestamp (in seconds since epoch) + * @param enableRenewalEmails - Whether to send renewal emails + * @returns Promise resolving to the API response + */ +export async function setAccountExpiration( + request: APIRequestContext, + userId: string, + expirationTs: number, + enableRenewalEmails: boolean = true +): Promise { + console.log(`[Synapse API] Setting expiration for user: ${userId} to timestamp: ${expirationTs}`); + + const response = await request.post( + `${BASE_URL}/_synapse/client/email_account_validity/admin`, + { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${SYNAPSE_ADMIN_TOKEN}` + }, + data: { + user_id: userId, + expiration_ts: expirationTs, + enable_renewal_emails: enableRenewalEmails + } + } + ); + + if (!response.ok()) { + const errorText = await response.text(); + console.error(`[Synapse API] Failed to set account expiration: ${response.status()} - ${errorText}`); + throw new Error(`Failed to set account expiration: ${response.status()} - ${errorText}`); + } + + const result = await response.json(); + console.log(`[Synapse API] Account expiration set successfully: ${JSON.stringify(result)}`); + return result; +} \ No newline at end of file From e3b30061ffbdee9b8faf2c2ac0c6f91a0e599528 Mon Sep 17 00:00:00 2001 From: olivierdelcroix Date: Fri, 13 Mar 2026 10:05:33 +0100 Subject: [PATCH 56/71] move files --- .../tests/auth/{ => oidc}/mas-login-oidc.spec.ts | 8 ++++---- .../tests/auth/{ => oidc}/mas-register-oidc.spec.ts | 8 ++++---- .../tests/auth/{ => oidc}/tchap-login-oidc.spec.ts | 8 ++++---- .../tests/auth/{ => password}/mas-legacy.spec.ts | 2 +- .../auth/{ => password}/mas-login-password.spec.ts | 6 +++--- .../auth/{ => password}/mas-register-password.spec.ts | 2 +- .../auth/{ => password}/tchap-login-password.spec.ts | 8 ++++---- .../{ => password}/tchap-register-password.spec.ts | 10 +++++----- .../auth/{ => password}/tchap-reset-password.spec.ts | 10 +++++----- 9 files changed, 31 insertions(+), 31 deletions(-) rename playwright/tests/auth/{ => oidc}/mas-login-oidc.spec.ts (98%) rename playwright/tests/auth/{ => oidc}/mas-register-oidc.spec.ts (95%) rename playwright/tests/auth/{ => oidc}/tchap-login-oidc.spec.ts (89%) rename playwright/tests/auth/{ => password}/mas-legacy.spec.ts (97%) rename playwright/tests/auth/{ => password}/mas-login-password.spec.ts (87%) rename playwright/tests/auth/{ => password}/mas-register-password.spec.ts (96%) rename playwright/tests/auth/{ => password}/tchap-login-password.spec.ts (88%) rename playwright/tests/auth/{ => password}/tchap-register-password.spec.ts (96%) rename playwright/tests/auth/{ => password}/tchap-reset-password.spec.ts (88%) diff --git a/playwright/tests/auth/mas-login-oidc.spec.ts b/playwright/tests/auth/oidc/mas-login-oidc.spec.ts similarity index 98% rename from playwright/tests/auth/mas-login-oidc.spec.ts rename to playwright/tests/auth/oidc/mas-login-oidc.spec.ts index 025072d..38c6e4c 100644 --- a/playwright/tests/auth/mas-login-oidc.spec.ts +++ b/playwright/tests/auth/oidc/mas-login-oidc.spec.ts @@ -1,11 +1,11 @@ -import { test, expect } from '../../fixtures/auth-fixture'; +import { test, expect } from '../../../fixtures/auth-fixture'; import { createKeycloakTestUser, performOidcLogin, TestUser, -} from '../../utils/auth-helpers'; -import { createMasUserWithPassword, getMasUserByEmail, deactivateMasUser,oauthLinkExistsByUserId, oauthLinkExistsBySubject, getOauthLinkBySubject, deleteOauthLink, reactivateMasUser, addUserEmail } from '../../utils/mas-admin'; -import { SCREENSHOTS_DIR, STANDARD_EMAIL_DOMAIN } from '../../utils/config'; +} from '../../../utils/auth-helpers'; +import { createMasUserWithPassword, getMasUserByEmail, deactivateMasUser,oauthLinkExistsByUserId, oauthLinkExistsBySubject, getOauthLinkBySubject, deleteOauthLink, reactivateMasUser, addUserEmail } from '../../../utils/mas-admin'; +import { SCREENSHOTS_DIR, STANDARD_EMAIL_DOMAIN } from '../../../utils/config'; test.describe('MAS Login OIDC', () => { diff --git a/playwright/tests/auth/mas-register-oidc.spec.ts b/playwright/tests/auth/oidc/mas-register-oidc.spec.ts similarity index 95% rename from playwright/tests/auth/mas-register-oidc.spec.ts rename to playwright/tests/auth/oidc/mas-register-oidc.spec.ts index bebf1b1..ce676f1 100644 --- a/playwright/tests/auth/mas-register-oidc.spec.ts +++ b/playwright/tests/auth/oidc/mas-register-oidc.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/auth-fixture'; +import { test, expect } from '../../../fixtures/auth-fixture'; import { performOidcLogin, verifyUserInMas, @@ -6,9 +6,9 @@ import { cleanupMasTestUser, performPasswordLogin, TestUser -} from '../../utils/auth-helpers'; -import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser } from '../../utils/mas-admin'; -import { SCREENSHOTS_DIR } from '../../utils/config'; +} from '../../../utils/auth-helpers'; +import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser } from '../../../utils/mas-admin'; +import { SCREENSHOTS_DIR } from '../../../utils/config'; test.describe('MAS register OIDC', () => { diff --git a/playwright/tests/auth/tchap-login-oidc.spec.ts b/playwright/tests/auth/oidc/tchap-login-oidc.spec.ts similarity index 89% rename from playwright/tests/auth/tchap-login-oidc.spec.ts rename to playwright/tests/auth/oidc/tchap-login-oidc.spec.ts index 8d54459..30a37d2 100644 --- a/playwright/tests/auth/tchap-login-oidc.spec.ts +++ b/playwright/tests/auth/oidc/tchap-login-oidc.spec.ts @@ -1,10 +1,10 @@ -import { test, expect } from '../../fixtures/auth-fixture'; +import { test, expect } from '../../../fixtures/auth-fixture'; import { verifyUserInMas, performOidcLoginFromTchap -} from '../../utils/auth-helpers'; -import { checkMasUserExistsByEmail, createMasUserWithPassword } from '../../utils/mas-admin'; -import { SCREENSHOTS_DIR, TCHAP_LEGACY } from '../../utils/config'; +} from '../../../utils/auth-helpers'; +import { checkMasUserExistsByEmail, createMasUserWithPassword } from '../../../utils/mas-admin'; +import { SCREENSHOTS_DIR, TCHAP_LEGACY } from '../../../utils/config'; //flaky on await expect(page.locator('text=Configuration')).toBeVisible({timeout: 20000}); diff --git a/playwright/tests/auth/mas-legacy.spec.ts b/playwright/tests/auth/password/mas-legacy.spec.ts similarity index 97% rename from playwright/tests/auth/mas-legacy.spec.ts rename to playwright/tests/auth/password/mas-legacy.spec.ts index 72b9c19..65dd8a2 100644 --- a/playwright/tests/auth/mas-legacy.spec.ts +++ b/playwright/tests/auth/password/mas-legacy.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { BASE_URL, MAS_URL } from '../../utils/config'; +import { BASE_URL, MAS_URL } from '../../../utils/config'; test.describe('Tchap : Legacy SSO Flow', () => { diff --git a/playwright/tests/auth/mas-login-password.spec.ts b/playwright/tests/auth/password/mas-login-password.spec.ts similarity index 87% rename from playwright/tests/auth/mas-login-password.spec.ts rename to playwright/tests/auth/password/mas-login-password.spec.ts index 159f90b..cc8fbe7 100644 --- a/playwright/tests/auth/mas-login-password.spec.ts +++ b/playwright/tests/auth/password/mas-login-password.spec.ts @@ -1,11 +1,11 @@ -import { test, expect } from '../../fixtures/auth-fixture'; +import { test, expect } from '../../../fixtures/auth-fixture'; import { createMasTestUser, cleanupMasTestUser, performPasswordLogin, TestUser -} from '../../utils/auth-helpers'; -import { SCREENSHOTS_DIR } from '../../utils/config'; +} from '../../../utils/auth-helpers'; +import { SCREENSHOTS_DIR } from '../../../utils/config'; test.describe('MAS login password', () => { diff --git a/playwright/tests/auth/mas-register-password.spec.ts b/playwright/tests/auth/password/mas-register-password.spec.ts similarity index 96% rename from playwright/tests/auth/mas-register-password.spec.ts rename to playwright/tests/auth/password/mas-register-password.spec.ts index f695055..698e1a7 100644 --- a/playwright/tests/auth/mas-register-password.spec.ts +++ b/playwright/tests/auth/password/mas-register-password.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/auth-fixture'; +import { test, expect } from '../../../fixtures/auth-fixture'; test.describe('MAS Register password', () => { diff --git a/playwright/tests/auth/tchap-login-password.spec.ts b/playwright/tests/auth/password/tchap-login-password.spec.ts similarity index 88% rename from playwright/tests/auth/tchap-login-password.spec.ts rename to playwright/tests/auth/password/tchap-login-password.spec.ts index 3d875b8..8287e8b 100644 --- a/playwright/tests/auth/tchap-login-password.spec.ts +++ b/playwright/tests/auth/password/tchap-login-password.spec.ts @@ -1,8 +1,8 @@ -import { test, expect } from '../../fixtures/auth-fixture'; -import { checkMasUserExistsByEmail, createMasUserWithPassword } from '../../utils/mas-admin'; -import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from '../../utils/config'; +import { test, expect } from '../../../fixtures/auth-fixture'; +import { checkMasUserExistsByEmail, createMasUserWithPassword } from '../../../utils/mas-admin'; +import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from '../../../utils/config'; import { Page } from '@playwright/test'; -import { loginWithPassword } from '../../utils/auth-helpers'; +import { loginWithPassword } from '../../../utils/auth-helpers'; test.describe('Tchap : Login password', () => { diff --git a/playwright/tests/auth/tchap-register-password.spec.ts b/playwright/tests/auth/password/tchap-register-password.spec.ts similarity index 96% rename from playwright/tests/auth/tchap-register-password.spec.ts rename to playwright/tests/auth/password/tchap-register-password.spec.ts index d9cf65b..7618570 100644 --- a/playwright/tests/auth/tchap-register-password.spec.ts +++ b/playwright/tests/auth/password/tchap-register-password.spec.ts @@ -1,11 +1,11 @@ -import { test, expect } from '../../fixtures/auth-fixture'; +import { test, expect } from '../../../fixtures/auth-fixture'; import { createMasTestUser, generateTestUserData -} from '../../utils/auth-helpers'; -import { getMasUserByEmail } from '../../utils/mas-admin'; -import {ELEMENT_URL, NOT_INVITED_EMAIL_DOMAIN, STANDARD_EMAIL_DOMAIN, WRONG_SERVER_EMAIL_DOMAIN } from '../../utils/config'; -import { getLatestVerificationCode } from '../../utils/mailpit'; +} from '../../../utils/auth-helpers'; +import { getMasUserByEmail } from '../../../utils/mas-admin'; +import {ELEMENT_URL, NOT_INVITED_EMAIL_DOMAIN, STANDARD_EMAIL_DOMAIN, WRONG_SERVER_EMAIL_DOMAIN } from '../../../utils/config'; +import { getLatestVerificationCode } from '../../../utils/mailpit'; test.describe('Tchap : register with password', () => { diff --git a/playwright/tests/auth/tchap-reset-password.spec.ts b/playwright/tests/auth/password/tchap-reset-password.spec.ts similarity index 88% rename from playwright/tests/auth/tchap-reset-password.spec.ts rename to playwright/tests/auth/password/tchap-reset-password.spec.ts index 33f807b..b8323ac 100644 --- a/playwright/tests/auth/tchap-reset-password.spec.ts +++ b/playwright/tests/auth/password/tchap-reset-password.spec.ts @@ -1,7 +1,7 @@ -import { test, expect } from '../../fixtures/auth-fixture'; -import { checkMasUserExistsByEmail, createMasUserWithPassword } from '../../utils/mas-admin'; -import { ELEMENT_URL } from '../../utils/config'; -import { openResetPasswordEmail } from '../../utils/auth-helpers'; +import { test, expect } from '../../../fixtures/auth-fixture'; +import { checkMasUserExistsByEmail, createMasUserWithPassword } from '../../../utils/mas-admin'; +import { ELEMENT_URL } from '../../../utils/config'; +import { openResetPasswordEmail } from '../../../utils/auth-helpers'; test.describe('Tchap : reset password', () => { @@ -40,7 +40,7 @@ test.describe('Tchap : reset password', () => { await expect(resetPwdPage.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); await resetPwdPage.getByRole('button').filter({ hasText: 'Sauvegarder et continuer' }).click({clickCount:2}); - //new tab is redirected back to welcome page + //new tab is redirected back to MAS welcome page await expect(resetPwdPage.getByRole('link').filter({ hasText: 'Continuer dans Tchap' })).toBeVisible(); await screenChecker(resetPwdPage, '/') From 4d196c9b48a1c5db9cb521642ff3bd556ca270a3 Mon Sep 17 00:00:00 2001 From: olivierdelcroix Date: Mon, 23 Mar 2026 11:37:35 +0100 Subject: [PATCH 57/71] move files to upper level --- fixtures/auth-fixture.ts | 173 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 fixtures/auth-fixture.ts diff --git a/fixtures/auth-fixture.ts b/fixtures/auth-fixture.ts new file mode 100644 index 0000000..4bba4a8 --- /dev/null +++ b/fixtures/auth-fixture.ts @@ -0,0 +1,173 @@ +import { test as base, Browser, Page, TestInfo } from "@playwright/test"; +import { + createKeycloakTestUser, + cleanupKeycloakTestUser, + TestUser, + TypeUser, + populateLocalStorageWithCredentials, +} from "../utils/auth-helpers"; +import { disposeApiContext as disposeKeycloakApiContext } from "../utils/keycloak-admin"; +import { createMasUserWithPassword, deactivateMasUser, disposeApiContext as disposeMasApiContext, waitForMasUser } from "../utils/mas-admin"; +import { generateTestUserData } from "../utils/auth-helpers"; +import fs from 'fs'; +import path from 'path'; +import { SCREENSHOTS_DIR } from '../utils/config'; + +import { + STANDARD_EMAIL_DOMAIN, + INVITED_EMAIL_DOMAIN, + NOT_INVITED_EMAIL_DOMAIN, + WRONG_SERVER_EMAIL_DOMAIN, + NUMERIQUE_EMAIL_DOMAIN, + BASE_URL, + ELEMENT_URL +} from "../utils/config"; +import { ClientServerApi, Credentials } from "../utils/api"; + +function generateUserDataFixture(domain: string) { + return async ({}, use: (user: TestUser) => Promise) => { + try { + const user = generateTestUserData(domain); + + // Use the test user in the test + await use(user); + + } finally { + // Dispose API contexts + await Promise.all([ + ]); + } + }; +} + +/** + * Function to create a test user fixture with a specific domain + */ +function createKeycloakUserFixture(domain: string) { + return async ({}, use: (user: TestUser) => Promise) => { + try { + const testUserData = generateTestUserData(domain); + + // Create a test user in Keycloak + const user = await createKeycloakTestUser(testUserData); + + // Use the test user in the test + await use(user); + + // Clean up the test user after the test + await cleanupKeycloakTestUser(user); + console.log(`Cleaned up test user: ${user.username}`); + } finally { + // Dispose API contexts + await Promise.all([disposeKeycloakApiContext(), disposeMasApiContext()]); + console.log("API contexts disposed"); + } + }; +} + +export type ScreenCheckerFixture = (page: Page, urlFragment: string) => Promise; +export type StartTchapRegisterWithEmailFixture = (page: Page, email: string) => Promise; +export type AuthenticatedUserFixture = (page: Page, user: TestUser, request: any) => Promise; + +async function screenCheckerFixture({}: {}, use: (screenChecker: ScreenCheckerFixture) => Promise, testInfo: TestInfo) { + //this fixture clean up the screenshot folder before the tests + //and exposes a method to capture a screenshot from an waited url + + const screenshotPath = path.join(SCREENSHOTS_DIR, testInfo.title.replace(/\s+/g, '_')); + let counter = 1; + + if (fs.existsSync(screenshotPath)) { + fs.rmSync(screenshotPath, { recursive: true, force: true }); + } + fs.mkdirSync(screenshotPath, { recursive: true }); + + const screenChecker = async (page: Page, urlFragment: string) => { + const browserName = page.context().browser()?.browserType().name(); + + await page.waitForURL((url) => { console.log("current page url : ", url.pathname); return url.toString().includes(urlFragment)}, {waitUntil:"load"}); + const filename = `${browserName}_${counter.toString().padStart(2, '0')}-${urlFragment.replace(/[^\w]/g, '_')}.png`; + await page.screenshot({ path: path.join(screenshotPath, filename), fullPage:true }); + counter++; + }; + + await use(screenChecker); +} + +async function startTchapRegisterWithEmailFixture({ screenChecker }: { screenChecker: ScreenCheckerFixture }, use: (start: StartTchapRegisterWithEmailFixture) => Promise) { + const start = async (page: Page, email: string) => { + await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'load' }); + await screenChecker(page, '#/welcome'); + await page.getByRole('link').filter({ hasText: 'Créer un compte' }).click(); + + await screenChecker(page, '#/email-precheck-sso'); + await page.locator('input').fill(email); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + await screenChecker(page, '/register'); + await page.getByRole('button').filter({ hasText: 'Continuer avec mon adresse mail' }).click(); + }; + await use(start); +} + +async function authenticatedUserFixture({ page, userData: user, request }: { page: Page, userData: TestUser, request: any }, use: (credentials: Credentials) => Promise) { + // 1. Register user + const userId = await createMasUserWithPassword( + user.username, + user.email, + user.password + ); + const csAPI = new ClientServerApi(BASE_URL, request); + + await waitForMasUser(user.email); + + const credentials = (await csAPI.loginUser( + user.username, + user.password + )) as Credentials; + + // 2. Populate localStorage + await populateLocalStorageWithCredentials(page, credentials); + + // 3. Load app + await page.goto(ELEMENT_URL); + await page.waitForSelector(".mx_MatrixChat", { timeout: 20000 }); + + // 4. Pass page to test + await use(credentials); + + // Clean up, deactivate user + await deactivateMasUser(userId); + console.log(`Cleaned up MAS user: ${user.username}`); +} + +/** + * Extend the basic test fixtures with our authentication fixtures + */ +export const test = base.extend<{ + userData: TestUser, + oidcUser: TestUser; + oidcExternalUserWithInvit: TestUser; + oidcExternalUserWitoutInvit: TestUser; + oidcUserOnWrongServer: TestUser; + oidcUserWithFallbackRules: TestUser; + authenticatedUser: Credentials; + typeUser: TypeUser; + screenChecker: ScreenCheckerFixture; + startTchapRegisterWithEmail: StartTchapRegisterWithEmailFixture; +}>({ + /** + * Create a test user in Keycloak before the test and clean it up after + */ + userData: generateUserDataFixture(STANDARD_EMAIL_DOMAIN), + oidcUser: createKeycloakUserFixture(STANDARD_EMAIL_DOMAIN), + oidcExternalUserWithInvit: createKeycloakUserFixture(INVITED_EMAIL_DOMAIN), + oidcExternalUserWitoutInvit: createKeycloakUserFixture(NOT_INVITED_EMAIL_DOMAIN), + oidcUserOnWrongServer: createKeycloakUserFixture(WRONG_SERVER_EMAIL_DOMAIN), + oidcUserWithFallbackRules: createKeycloakUserFixture(NUMERIQUE_EMAIL_DOMAIN), + authenticatedUser: authenticatedUserFixture, + typeUser: TypeUser.MAS_PASSWORD_USER, + screenChecker: screenCheckerFixture, + startTchapRegisterWithEmail: startTchapRegisterWithEmailFixture, +}); + +export { expect } from "@playwright/test"; \ No newline at end of file From 7fe6f766a531d816bbafd9065b4811d9d92a64eb Mon Sep 17 00:00:00 2001 From: olivierdelcroix Date: Mon, 23 Mar 2026 11:38:26 +0100 Subject: [PATCH 58/71] move files to upper level --- playwright/.env.dev01 => .env.dev01 | 0 playwright/.env.dev02 => .env.dev02 | 0 playwright/.env.local => .env.local | 4 +- playwright/.env.preprod => .env.preprod | 0 playwright/.env.sample => .env.sample | 0 .gitignore | 39 +++- README.md | 103 +++++++++-- playwright/init.sh => init.sh | 0 .../package-lock.json => package-lock.json | 26 ++- playwright/package.json => package.json | 0 ...aywright.config.ts => playwright.config.ts | 2 +- playwright/.gitignore | 32 ---- playwright/README.md | 106 ----------- playwright/fixtures/auth-fixture.ts | 173 ------------------ .../sample-files => sample-files}/eicar.com | 0 .../sample-files => sample-files}/element.png | Bin .../auth/logout}/tchap-logout.spec.ts | 8 +- .../auth/oidc/mas-login-oidc.spec.ts | 0 .../auth/oidc/mas-register-oidc.spec.ts | 8 +- .../auth/oidc/tchap-login-oidc.spec.ts | 0 .../auth/password/mas-legacy.spec.ts | 0 .../auth/password/mas-login-password.spec.ts | 0 .../password/mas-register-password.spec.ts | 0 .../password/tchap-login-password.spec.ts | 0 .../password/tchap-register-password.spec.ts | 0 .../password/tchap-reset-password.spec.ts | 0 .../tchap-expiration.spec.ts | 0 .../minimal/minimal-scenario.spec.ts | 0 .../tests => tests}/web/create-room.spec.ts | 0 playwright/tsconfig.json => tsconfig.json | 0 {playwright/utils => utils}/TchapAppPage.ts | 0 {playwright/utils => utils}/api.ts | 0 {playwright/utils => utils}/auth-helpers.ts | 0 {playwright/utils => utils}/config.ts | 2 +- {playwright/utils => utils}/keycloak-admin.ts | 0 {playwright/utils => utils}/mailpit.ts | 0 {playwright/utils => utils}/mas-admin.ts | 0 {playwright/utils => utils}/synapse-admin.ts | 0 38 files changed, 150 insertions(+), 353 deletions(-) rename playwright/.env.dev01 => .env.dev01 (100%) rename playwright/.env.dev02 => .env.dev02 (100%) rename playwright/.env.local => .env.local (97%) rename playwright/.env.preprod => .env.preprod (100%) rename playwright/.env.sample => .env.sample (100%) rename playwright/init.sh => init.sh (100%) rename playwright/package-lock.json => package-lock.json (96%) rename playwright/package.json => package.json (100%) rename playwright/playwright.config.ts => playwright.config.ts (98%) delete mode 100644 playwright/.gitignore delete mode 100644 playwright/README.md delete mode 100644 playwright/fixtures/auth-fixture.ts rename {playwright/sample-files => sample-files}/eicar.com (100%) rename {playwright/sample-files => sample-files}/element.png (100%) rename {playwright/tests/auth => tests/auth/logout}/tchap-logout.spec.ts (82%) rename {playwright/tests => tests}/auth/oidc/mas-login-oidc.spec.ts (100%) rename {playwright/tests => tests}/auth/oidc/mas-register-oidc.spec.ts (95%) rename {playwright/tests => tests}/auth/oidc/tchap-login-oidc.spec.ts (100%) rename {playwright/tests => tests}/auth/password/mas-legacy.spec.ts (100%) rename {playwright/tests => tests}/auth/password/mas-login-password.spec.ts (100%) rename {playwright/tests => tests}/auth/password/mas-register-password.spec.ts (100%) rename {playwright/tests => tests}/auth/password/tchap-login-password.spec.ts (100%) rename {playwright/tests => tests}/auth/password/tchap-register-password.spec.ts (100%) rename {playwright/tests => tests}/auth/password/tchap-reset-password.spec.ts (100%) rename {playwright/tests/auth => tests/email-account-validity}/tchap-expiration.spec.ts (100%) rename {playwright/tests => tests}/minimal/minimal-scenario.spec.ts (100%) rename {playwright/tests => tests}/web/create-room.spec.ts (100%) rename playwright/tsconfig.json => tsconfig.json (100%) rename {playwright/utils => utils}/TchapAppPage.ts (100%) rename {playwright/utils => utils}/api.ts (100%) rename {playwright/utils => utils}/auth-helpers.ts (100%) rename {playwright/utils => utils}/config.ts (98%) rename {playwright/utils => utils}/keycloak-admin.ts (100%) rename {playwright/utils => utils}/mailpit.ts (100%) rename {playwright/utils => utils}/mas-admin.ts (100%) rename {playwright/utils => utils}/synapse-admin.ts (100%) diff --git a/playwright/.env.dev01 b/.env.dev01 similarity index 100% rename from playwright/.env.dev01 rename to .env.dev01 diff --git a/playwright/.env.dev02 b/.env.dev02 similarity index 100% rename from playwright/.env.dev02 rename to .env.dev02 diff --git a/playwright/.env.local b/.env.local similarity index 97% rename from playwright/.env.local rename to .env.local index 1139166..f4c7a2a 100644 --- a/playwright/.env.local +++ b/.env.local @@ -6,8 +6,8 @@ BASE_URL=https://matrix.tchapgouv.com MAIL_URL=https://mail.tchapgouv.com # mailpit user credentials -MAILPIT_USER = -MAILPIT_PWD = +MAILPIT_USER = +MAILPIT_PWD = # Keycloak Admin Credentials KEYCLOAK_ADMIN_USERNAME=admin diff --git a/playwright/.env.preprod b/.env.preprod similarity index 100% rename from playwright/.env.preprod rename to .env.preprod diff --git a/playwright/.env.sample b/.env.sample similarity index 100% rename from playwright/.env.sample rename to .env.sample diff --git a/.gitignore b/.gitignore index 37dd651..ccdddcd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,32 @@ -tmp/ -playwright/node_modules/ -playwright/playwright-report/ -playwright/playwright-results/ -playwright/test-results/ -playwright/.env +# Dependencies +node_modules/ +.npm +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Playwright +playwright-report/ +playwright-results/ +test-results/ +playwright/.cache/ + +# Environment variables .env -.history/ -.idea + +# Build artifacts +dist/ +build/ + +# IDE +.idea/ +.vscode/* +!.vscode/extensions.json +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +*.code-workspace + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md index 590b91a..e8f0f50 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,106 @@ +# Playwright Tests for Matrix Authentication Service -# folder structure +Ce projet contient des tests Playwright pour tester le scénario d'authentification OIDC avec Keycloak dans le service d'authentification Matrix (MAS). -- /playwright : test E2E +## Prérequis -# playwright tests +- Node.js 16 ou supérieur +- npm ou yarn +- Docker pour exécuter Keycloak et PostgreSQL +- Keycloak accessible sur `sso.tchapgouv.com` +- Matrix Authentication Service accessible sur `auth.tchapgouv.com` -## setup synapse +### Démarrage des services -checkout la branche : https://github.com/tchapgouv/element-docker-demo/tree/local-dev +Avant d'exécuter les tests, vous devez démarrer les services Keycloak et Matrix Authentication Service : -```element-docker-demo % ./setup_tchap.sh ``` - > tchapgouv.com - > Use local mkcert CA for SSL? [y/n] y +```bash +# Démarrer Keycloak et PostgreSQL +./tchap/start-local-stack.sh + +# Démarrer le service Matrix Authentication Service +./tchap/start-local-mas.sh +``` + +Ces scripts démarrent les services nécessaires pour les tests : +- PostgreSQL pour le stockage des données +- Keycloak avec le realm `proconnect-mock` préconfiguré +- Matrix Authentication Service configuré pour utiliser Keycloak comme fournisseur OIDC + +Les entrees DNS doivent etre présentes dans le fichier hosts -.env and SSL configured; you can now docker compose up +## Installation + +Pour initialiser rapidement le projet, utilisez le script d'initialisation : + +```bash +# Rendre le script exécutable +chmod +x init.sh + +# Exécuter le script d'initialisation +./init.sh +``` -```element-docker-demo % ./start_tchap_light.sh ``` +Ce script va : +- Créer le répertoire pour les résultats des tests +- Installer les dépendances npm +- Installer les navigateurs Playwright -> dependency failed to start: container element-docker-demo-postgres-1 is unhealthy +Vous pouvez également effectuer ces étapes manuellement : -Postgres met du temps à démarrer, relancer synapse puis nginx à la main +```bash +# Installer les dépendances +npm install -créer un compte à https://element.tchapgouv.com/ +# Installer les navigateurs Playwright +npx playwright install +``` -> L’inscription a été désactivée sur ce serveur d’accueil. +## Configuration -c'est normal le MAS n'est pas démarré +Les tests utilisent un fichier `.env` pour la configuration. Vous pouvez modifier ce fichier pour adapter les tests à votre environnement. +``` +# URLs +MAS_URL=https://auth.tchapgouv.com +KEYCLOAK_URL=https://sso.tchapgouv.com + +# Keycloak Admin Credentials +KEYCLOAK_ADMIN_USERNAME=admin +KEYCLOAK_ADMIN_PASSWORD=admin +KEYCLOAK_REALM=proconnect-mock + +# MAS Admin API Credentials +MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 +MAS_ADMIN_CLIENT_SECRET=phoo8ahneir3ohY2eigh4xuu6Oodaewi + +# Test User Credentials +TEST_USER_PREFIX=playwright_test_user +TEST_USER_PASSWORD=Test@123456 +``` -## launch tests +## Exécution des tests ```bash -cd playwright -playwright % npm test +# Exécuter tous les tests +npm test + +# Exécuter les tests avec l'interface utilisateur visible +npm run test:headed + +# Exécuter les tests en mode debug +npm run test:debug + +# Exécuter les tests avec l'interface utilisateur de Playwright +npm run test:ui ``` +## Captures d'écran + +Les tests génèrent des captures d'écran à chaque étape importante du processus d'authentification. Ces captures sont enregistrées dans le répertoire `playwright-results/`. + + +## Executer avec docker +`docker run -it --rm --ipc=host -v .:/app -w /app mcr.microsoft.com/playwright:v1.51.1-noble npm run test:dev02` \ No newline at end of file diff --git a/playwright/init.sh b/init.sh similarity index 100% rename from playwright/init.sh rename to init.sh diff --git a/playwright/package-lock.json b/package-lock.json similarity index 96% rename from playwright/package-lock.json rename to package-lock.json index 2a91404..a5442bc 100644 --- a/playwright/package-lock.json +++ b/package-lock.json @@ -9,10 +9,10 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "mailpit-api": "^1.5.4", "@playwright/test": "^1.40.0", "@types/node": "^20.10.0", "dotenv": "^16.3.1", + "mailpit-api": "^1.5.4", "typescript": "^5.3.2" } }, @@ -44,12 +44,14 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, "license": "MIT" }, "node_modules/axios": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -61,6 +63,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -74,6 +77,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -86,6 +90,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -107,6 +112,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -121,6 +127,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -130,6 +137,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -139,6 +147,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -151,6 +160,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -166,6 +176,7 @@ "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "dev": true, "funding": [ { "type": "individual", @@ -186,6 +197,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -216,6 +228,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -225,6 +238,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -249,6 +263,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -262,6 +277,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -274,6 +290,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -286,6 +303,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -301,6 +319,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -313,6 +332,7 @@ "version": "1.5.4", "resolved": "https://registry.npmjs.org/mailpit-api/-/mailpit-api-1.5.4.tgz", "integrity": "sha512-Zz7qrNHF7p67sDn7VzBmP3Djk7eJNcBRiwCKeiWl5ADRou14++wvP1Mq9XD6sLyU5sQ/tgo3rXbmwTbNx1uY0A==", + "dev": true, "license": "MIT", "dependencies": { "axios": "^1.12.1" @@ -325,6 +345,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -334,6 +355,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -343,6 +365,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -385,6 +408,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, "license": "MIT" }, "node_modules/typescript": { diff --git a/playwright/package.json b/package.json similarity index 100% rename from playwright/package.json rename to package.json diff --git a/playwright/playwright.config.ts b/playwright.config.ts similarity index 98% rename from playwright/playwright.config.ts rename to playwright.config.ts index 1307585..04536d8 100644 --- a/playwright/playwright.config.ts +++ b/playwright.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ /* Define how many workers */ // Limit the number of workers on CI, use default locally - workers: process.env.CI ? 2 : 2, + workers: process.env.CI ? 2 : 1, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, diff --git a/playwright/.gitignore b/playwright/.gitignore deleted file mode 100644 index ccdddcd..0000000 --- a/playwright/.gitignore +++ /dev/null @@ -1,32 +0,0 @@ -# Dependencies -node_modules/ -.npm -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Playwright -playwright-report/ -playwright-results/ -test-results/ -playwright/.cache/ - -# Environment variables -.env - -# Build artifacts -dist/ -build/ - -# IDE -.idea/ -.vscode/* -!.vscode/extensions.json -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -*.code-workspace - -# OS -.DS_Store -Thumbs.db diff --git a/playwright/README.md b/playwright/README.md deleted file mode 100644 index e8f0f50..0000000 --- a/playwright/README.md +++ /dev/null @@ -1,106 +0,0 @@ -# Playwright Tests for Matrix Authentication Service - -Ce projet contient des tests Playwright pour tester le scénario d'authentification OIDC avec Keycloak dans le service d'authentification Matrix (MAS). - -## Prérequis - -- Node.js 16 ou supérieur -- npm ou yarn -- Docker pour exécuter Keycloak et PostgreSQL -- Keycloak accessible sur `sso.tchapgouv.com` -- Matrix Authentication Service accessible sur `auth.tchapgouv.com` - -### Démarrage des services - -Avant d'exécuter les tests, vous devez démarrer les services Keycloak et Matrix Authentication Service : - -```bash -# Démarrer Keycloak et PostgreSQL -./tchap/start-local-stack.sh - -# Démarrer le service Matrix Authentication Service -./tchap/start-local-mas.sh -``` - -Ces scripts démarrent les services nécessaires pour les tests : -- PostgreSQL pour le stockage des données -- Keycloak avec le realm `proconnect-mock` préconfiguré -- Matrix Authentication Service configuré pour utiliser Keycloak comme fournisseur OIDC - -Les entrees DNS doivent etre présentes dans le fichier hosts - -## Installation - -Pour initialiser rapidement le projet, utilisez le script d'initialisation : - -```bash -# Rendre le script exécutable -chmod +x init.sh - -# Exécuter le script d'initialisation -./init.sh -``` - -Ce script va : -- Créer le répertoire pour les résultats des tests -- Installer les dépendances npm -- Installer les navigateurs Playwright - - -Vous pouvez également effectuer ces étapes manuellement : - -```bash -# Installer les dépendances -npm install - -# Installer les navigateurs Playwright -npx playwright install -``` - -## Configuration - -Les tests utilisent un fichier `.env` pour la configuration. Vous pouvez modifier ce fichier pour adapter les tests à votre environnement. - -``` -# URLs -MAS_URL=https://auth.tchapgouv.com -KEYCLOAK_URL=https://sso.tchapgouv.com - -# Keycloak Admin Credentials -KEYCLOAK_ADMIN_USERNAME=admin -KEYCLOAK_ADMIN_PASSWORD=admin -KEYCLOAK_REALM=proconnect-mock - -# MAS Admin API Credentials -MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 -MAS_ADMIN_CLIENT_SECRET=phoo8ahneir3ohY2eigh4xuu6Oodaewi - -# Test User Credentials -TEST_USER_PREFIX=playwright_test_user -TEST_USER_PASSWORD=Test@123456 -``` - -## Exécution des tests - -```bash -# Exécuter tous les tests -npm test - -# Exécuter les tests avec l'interface utilisateur visible -npm run test:headed - -# Exécuter les tests en mode debug -npm run test:debug - -# Exécuter les tests avec l'interface utilisateur de Playwright -npm run test:ui -``` - -## Captures d'écran - -Les tests génèrent des captures d'écran à chaque étape importante du processus d'authentification. Ces captures sont enregistrées dans le répertoire `playwright-results/`. - - -## Executer avec docker - -`docker run -it --rm --ipc=host -v .:/app -w /app mcr.microsoft.com/playwright:v1.51.1-noble npm run test:dev02` \ No newline at end of file diff --git a/playwright/fixtures/auth-fixture.ts b/playwright/fixtures/auth-fixture.ts deleted file mode 100644 index 4bba4a8..0000000 --- a/playwright/fixtures/auth-fixture.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { test as base, Browser, Page, TestInfo } from "@playwright/test"; -import { - createKeycloakTestUser, - cleanupKeycloakTestUser, - TestUser, - TypeUser, - populateLocalStorageWithCredentials, -} from "../utils/auth-helpers"; -import { disposeApiContext as disposeKeycloakApiContext } from "../utils/keycloak-admin"; -import { createMasUserWithPassword, deactivateMasUser, disposeApiContext as disposeMasApiContext, waitForMasUser } from "../utils/mas-admin"; -import { generateTestUserData } from "../utils/auth-helpers"; -import fs from 'fs'; -import path from 'path'; -import { SCREENSHOTS_DIR } from '../utils/config'; - -import { - STANDARD_EMAIL_DOMAIN, - INVITED_EMAIL_DOMAIN, - NOT_INVITED_EMAIL_DOMAIN, - WRONG_SERVER_EMAIL_DOMAIN, - NUMERIQUE_EMAIL_DOMAIN, - BASE_URL, - ELEMENT_URL -} from "../utils/config"; -import { ClientServerApi, Credentials } from "../utils/api"; - -function generateUserDataFixture(domain: string) { - return async ({}, use: (user: TestUser) => Promise) => { - try { - const user = generateTestUserData(domain); - - // Use the test user in the test - await use(user); - - } finally { - // Dispose API contexts - await Promise.all([ - ]); - } - }; -} - -/** - * Function to create a test user fixture with a specific domain - */ -function createKeycloakUserFixture(domain: string) { - return async ({}, use: (user: TestUser) => Promise) => { - try { - const testUserData = generateTestUserData(domain); - - // Create a test user in Keycloak - const user = await createKeycloakTestUser(testUserData); - - // Use the test user in the test - await use(user); - - // Clean up the test user after the test - await cleanupKeycloakTestUser(user); - console.log(`Cleaned up test user: ${user.username}`); - } finally { - // Dispose API contexts - await Promise.all([disposeKeycloakApiContext(), disposeMasApiContext()]); - console.log("API contexts disposed"); - } - }; -} - -export type ScreenCheckerFixture = (page: Page, urlFragment: string) => Promise; -export type StartTchapRegisterWithEmailFixture = (page: Page, email: string) => Promise; -export type AuthenticatedUserFixture = (page: Page, user: TestUser, request: any) => Promise; - -async function screenCheckerFixture({}: {}, use: (screenChecker: ScreenCheckerFixture) => Promise, testInfo: TestInfo) { - //this fixture clean up the screenshot folder before the tests - //and exposes a method to capture a screenshot from an waited url - - const screenshotPath = path.join(SCREENSHOTS_DIR, testInfo.title.replace(/\s+/g, '_')); - let counter = 1; - - if (fs.existsSync(screenshotPath)) { - fs.rmSync(screenshotPath, { recursive: true, force: true }); - } - fs.mkdirSync(screenshotPath, { recursive: true }); - - const screenChecker = async (page: Page, urlFragment: string) => { - const browserName = page.context().browser()?.browserType().name(); - - await page.waitForURL((url) => { console.log("current page url : ", url.pathname); return url.toString().includes(urlFragment)}, {waitUntil:"load"}); - const filename = `${browserName}_${counter.toString().padStart(2, '0')}-${urlFragment.replace(/[^\w]/g, '_')}.png`; - await page.screenshot({ path: path.join(screenshotPath, filename), fullPage:true }); - counter++; - }; - - await use(screenChecker); -} - -async function startTchapRegisterWithEmailFixture({ screenChecker }: { screenChecker: ScreenCheckerFixture }, use: (start: StartTchapRegisterWithEmailFixture) => Promise) { - const start = async (page: Page, email: string) => { - await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'load' }); - await screenChecker(page, '#/welcome'); - await page.getByRole('link').filter({ hasText: 'Créer un compte' }).click(); - - await screenChecker(page, '#/email-precheck-sso'); - await page.locator('input').fill(email); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - - await screenChecker(page, '/register'); - await page.getByRole('button').filter({ hasText: 'Continuer avec mon adresse mail' }).click(); - }; - await use(start); -} - -async function authenticatedUserFixture({ page, userData: user, request }: { page: Page, userData: TestUser, request: any }, use: (credentials: Credentials) => Promise) { - // 1. Register user - const userId = await createMasUserWithPassword( - user.username, - user.email, - user.password - ); - const csAPI = new ClientServerApi(BASE_URL, request); - - await waitForMasUser(user.email); - - const credentials = (await csAPI.loginUser( - user.username, - user.password - )) as Credentials; - - // 2. Populate localStorage - await populateLocalStorageWithCredentials(page, credentials); - - // 3. Load app - await page.goto(ELEMENT_URL); - await page.waitForSelector(".mx_MatrixChat", { timeout: 20000 }); - - // 4. Pass page to test - await use(credentials); - - // Clean up, deactivate user - await deactivateMasUser(userId); - console.log(`Cleaned up MAS user: ${user.username}`); -} - -/** - * Extend the basic test fixtures with our authentication fixtures - */ -export const test = base.extend<{ - userData: TestUser, - oidcUser: TestUser; - oidcExternalUserWithInvit: TestUser; - oidcExternalUserWitoutInvit: TestUser; - oidcUserOnWrongServer: TestUser; - oidcUserWithFallbackRules: TestUser; - authenticatedUser: Credentials; - typeUser: TypeUser; - screenChecker: ScreenCheckerFixture; - startTchapRegisterWithEmail: StartTchapRegisterWithEmailFixture; -}>({ - /** - * Create a test user in Keycloak before the test and clean it up after - */ - userData: generateUserDataFixture(STANDARD_EMAIL_DOMAIN), - oidcUser: createKeycloakUserFixture(STANDARD_EMAIL_DOMAIN), - oidcExternalUserWithInvit: createKeycloakUserFixture(INVITED_EMAIL_DOMAIN), - oidcExternalUserWitoutInvit: createKeycloakUserFixture(NOT_INVITED_EMAIL_DOMAIN), - oidcUserOnWrongServer: createKeycloakUserFixture(WRONG_SERVER_EMAIL_DOMAIN), - oidcUserWithFallbackRules: createKeycloakUserFixture(NUMERIQUE_EMAIL_DOMAIN), - authenticatedUser: authenticatedUserFixture, - typeUser: TypeUser.MAS_PASSWORD_USER, - screenChecker: screenCheckerFixture, - startTchapRegisterWithEmail: startTchapRegisterWithEmailFixture, -}); - -export { expect } from "@playwright/test"; \ No newline at end of file diff --git a/playwright/sample-files/eicar.com b/sample-files/eicar.com similarity index 100% rename from playwright/sample-files/eicar.com rename to sample-files/eicar.com diff --git a/playwright/sample-files/element.png b/sample-files/element.png similarity index 100% rename from playwright/sample-files/element.png rename to sample-files/element.png diff --git a/playwright/tests/auth/tchap-logout.spec.ts b/tests/auth/logout/tchap-logout.spec.ts similarity index 82% rename from playwright/tests/auth/tchap-logout.spec.ts rename to tests/auth/logout/tchap-logout.spec.ts index bee8489..3368ea1 100644 --- a/playwright/tests/auth/tchap-logout.spec.ts +++ b/tests/auth/logout/tchap-logout.spec.ts @@ -1,8 +1,6 @@ -import { test, expect } from '../../fixtures/auth-fixture'; -import { checkMasUserExistsByEmail, createMasUserWithPassword } from '../../utils/mas-admin'; -import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from '../../utils/config'; -import { Page } from '@playwright/test'; -import { loginWithPassword } from '../../utils/auth-helpers'; +import { test, expect } from '../../../fixtures/auth-fixture'; +import {createMasUserWithPassword } from '../../../utils/mas-admin'; +import { loginWithPassword } from '../../../utils/auth-helpers'; test.describe('Tchap : logout', () => { diff --git a/playwright/tests/auth/oidc/mas-login-oidc.spec.ts b/tests/auth/oidc/mas-login-oidc.spec.ts similarity index 100% rename from playwright/tests/auth/oidc/mas-login-oidc.spec.ts rename to tests/auth/oidc/mas-login-oidc.spec.ts diff --git a/playwright/tests/auth/oidc/mas-register-oidc.spec.ts b/tests/auth/oidc/mas-register-oidc.spec.ts similarity index 95% rename from playwright/tests/auth/oidc/mas-register-oidc.spec.ts rename to tests/auth/oidc/mas-register-oidc.spec.ts index ce676f1..e9b4f5c 100644 --- a/playwright/tests/auth/oidc/mas-register-oidc.spec.ts +++ b/tests/auth/oidc/mas-register-oidc.spec.ts @@ -1,13 +1,9 @@ import { test, expect } from '../../../fixtures/auth-fixture'; import { performOidcLogin, - verifyUserInMas, - createMasTestUser, - cleanupMasTestUser, - performPasswordLogin, - TestUser + verifyUserInMas } from '../../../utils/auth-helpers'; -import { checkMasUserExistsByEmail, createMasUserWithPassword, getMasUserByEmail, deactivateMasUser } from '../../../utils/mas-admin'; +import { checkMasUserExistsByEmail} from '../../../utils/mas-admin'; import { SCREENSHOTS_DIR } from '../../../utils/config'; test.describe('MAS register OIDC', () => { diff --git a/playwright/tests/auth/oidc/tchap-login-oidc.spec.ts b/tests/auth/oidc/tchap-login-oidc.spec.ts similarity index 100% rename from playwright/tests/auth/oidc/tchap-login-oidc.spec.ts rename to tests/auth/oidc/tchap-login-oidc.spec.ts diff --git a/playwright/tests/auth/password/mas-legacy.spec.ts b/tests/auth/password/mas-legacy.spec.ts similarity index 100% rename from playwright/tests/auth/password/mas-legacy.spec.ts rename to tests/auth/password/mas-legacy.spec.ts diff --git a/playwright/tests/auth/password/mas-login-password.spec.ts b/tests/auth/password/mas-login-password.spec.ts similarity index 100% rename from playwright/tests/auth/password/mas-login-password.spec.ts rename to tests/auth/password/mas-login-password.spec.ts diff --git a/playwright/tests/auth/password/mas-register-password.spec.ts b/tests/auth/password/mas-register-password.spec.ts similarity index 100% rename from playwright/tests/auth/password/mas-register-password.spec.ts rename to tests/auth/password/mas-register-password.spec.ts diff --git a/playwright/tests/auth/password/tchap-login-password.spec.ts b/tests/auth/password/tchap-login-password.spec.ts similarity index 100% rename from playwright/tests/auth/password/tchap-login-password.spec.ts rename to tests/auth/password/tchap-login-password.spec.ts diff --git a/playwright/tests/auth/password/tchap-register-password.spec.ts b/tests/auth/password/tchap-register-password.spec.ts similarity index 100% rename from playwright/tests/auth/password/tchap-register-password.spec.ts rename to tests/auth/password/tchap-register-password.spec.ts diff --git a/playwright/tests/auth/password/tchap-reset-password.spec.ts b/tests/auth/password/tchap-reset-password.spec.ts similarity index 100% rename from playwright/tests/auth/password/tchap-reset-password.spec.ts rename to tests/auth/password/tchap-reset-password.spec.ts diff --git a/playwright/tests/auth/tchap-expiration.spec.ts b/tests/email-account-validity/tchap-expiration.spec.ts similarity index 100% rename from playwright/tests/auth/tchap-expiration.spec.ts rename to tests/email-account-validity/tchap-expiration.spec.ts diff --git a/playwright/tests/minimal/minimal-scenario.spec.ts b/tests/minimal/minimal-scenario.spec.ts similarity index 100% rename from playwright/tests/minimal/minimal-scenario.spec.ts rename to tests/minimal/minimal-scenario.spec.ts diff --git a/playwright/tests/web/create-room.spec.ts b/tests/web/create-room.spec.ts similarity index 100% rename from playwright/tests/web/create-room.spec.ts rename to tests/web/create-room.spec.ts diff --git a/playwright/tsconfig.json b/tsconfig.json similarity index 100% rename from playwright/tsconfig.json rename to tsconfig.json diff --git a/playwright/utils/TchapAppPage.ts b/utils/TchapAppPage.ts similarity index 100% rename from playwright/utils/TchapAppPage.ts rename to utils/TchapAppPage.ts diff --git a/playwright/utils/api.ts b/utils/api.ts similarity index 100% rename from playwright/utils/api.ts rename to utils/api.ts diff --git a/playwright/utils/auth-helpers.ts b/utils/auth-helpers.ts similarity index 100% rename from playwright/utils/auth-helpers.ts rename to utils/auth-helpers.ts diff --git a/playwright/utils/config.ts b/utils/config.ts similarity index 98% rename from playwright/utils/config.ts rename to utils/config.ts index e8e9021..877d89e 100644 --- a/playwright/utils/config.ts +++ b/utils/config.ts @@ -3,7 +3,7 @@ import path from 'path'; // Determine which environment to use -export const env = process.env.ENV || 'dev01'; +export const env = process.env.ENV || 'local'; console.log(`Loading environment configuration for: ${env}`); // Load environment variables from the appropriate .env file diff --git a/playwright/utils/keycloak-admin.ts b/utils/keycloak-admin.ts similarity index 100% rename from playwright/utils/keycloak-admin.ts rename to utils/keycloak-admin.ts diff --git a/playwright/utils/mailpit.ts b/utils/mailpit.ts similarity index 100% rename from playwright/utils/mailpit.ts rename to utils/mailpit.ts diff --git a/playwright/utils/mas-admin.ts b/utils/mas-admin.ts similarity index 100% rename from playwright/utils/mas-admin.ts rename to utils/mas-admin.ts diff --git a/playwright/utils/synapse-admin.ts b/utils/synapse-admin.ts similarity index 100% rename from playwright/utils/synapse-admin.ts rename to utils/synapse-admin.ts From 02cdad8215ad3653c3ac6478c6fc275be26f0b46 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Tue, 31 Mar 2026 14:21:24 +0200 Subject: [PATCH 59/71] tests for reactivating account silently (#35) * add code for testing deactivating account with error page * update tests to account silently account reactivation * reactivate user when session was already linked * logs --- tests/auth/oidc/mas-login-oidc.spec.ts | 128 +-------- .../password/tchap-login-password.spec.ts | 3 +- .../password/tchap-register-password.spec.ts | 2 +- .../password/tchap-reset-password.spec.ts | 3 + .../tchap-deactivated-account.spec.ts | 254 ++++++++++++++++++ utils/mas-admin.ts | 48 +++- 6 files changed, 306 insertions(+), 132 deletions(-) create mode 100644 tests/email-account-validity/tchap-deactivated-account.spec.ts diff --git a/tests/auth/oidc/mas-login-oidc.spec.ts b/tests/auth/oidc/mas-login-oidc.spec.ts index 38c6e4c..ab3647d 100644 --- a/tests/auth/oidc/mas-login-oidc.spec.ts +++ b/tests/auth/oidc/mas-login-oidc.spec.ts @@ -4,7 +4,7 @@ import { performOidcLogin, TestUser, } from '../../../utils/auth-helpers'; -import { createMasUserWithPassword, getMasUserByEmail, deactivateMasUser,oauthLinkExistsByUserId, oauthLinkExistsBySubject, getOauthLinkBySubject, deleteOauthLink, reactivateMasUser, addUserEmail } from '../../../utils/mas-admin'; +import { createMasUserWithPassword, getMasUserByEmail, deactivateMasUser,oauthLinkExistsByUserId, oauthLinkExistsBySubject} from '../../../utils/mas-admin'; import { SCREENSHOTS_DIR, STANDARD_EMAIL_DOMAIN } from '../../../utils/config'; test.describe('MAS Login OIDC', () => { @@ -116,132 +116,6 @@ test.describe('MAS Login OIDC', () => { } }); - test('match account by email when former account is deactivated but another one is valid', async ({browser, context, page, oidcUserWithFallbackRules: userLegacy }) => { - const screenshot_path = test.info().title.replace(" ", "_"); - - // Create a user in MAS with the same email as the Keycloak user - console.log(`Creating MAS user with same email as Keycloak user: ${userLegacy.email}`); - - const formerTchapAccountMasId = await createMasUserWithPassword(userLegacy.username, userLegacy.email, userLegacy.password); - await deactivateMasUser(formerTchapAccountMasId); - - const newTchapAccountWithIndex = { - username: userLegacy.username + "2", - email: userLegacy.email, - password: userLegacy.password, - masId: "", - } - newTchapAccountWithIndex.masId = await createMasUserWithPassword(newTchapAccountWithIndex.username, newTchapAccountWithIndex.email, newTchapAccountWithIndex.password); - - try { - - // Perform the OIDC login flow with KC Account(=userLegacy) - await performOidcLogin(page, userLegacy, screenshot_path); - - // Since the account already exists, we should be automatically logged in - // Verify we're successfully logged in - await expect(page.locator('text=Connecté')).toBeVisible(); - - // Take a screenshot of the authenticated state - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); - - // Verify the user in MAS is linked to the indexed account - const userAfterLogin = await getMasUserByEmail(newTchapAccountWithIndex.email); - expect(userAfterLogin.id).toBe(newTchapAccountWithIndex.masId); - expect(userAfterLogin.attributes['username']).toBe(newTchapAccountWithIndex.username); - await expect(page.locator(`text=${newTchapAccountWithIndex.username}`)).toBeVisible(); - expect(await oauthLinkExistsBySubject(userLegacy.username)).toBe(true); - - console.log(`Successfully verified account linking for user with email: ${newTchapAccountWithIndex.email}`); - - } finally { - // Clean up the MAS user - await deactivateMasUser(newTchapAccountWithIndex.masId); - console.log(`Cleaned up MAS user: ${newTchapAccountWithIndex.username}`); - } - }); - - test('match account by email when account was deactivated but is reactivated by support', async ({browser, context, page, oidcUserWithFallbackRules: userLegacy }) => { - const screenshot_path = test.info().title.replace(" ", "_"); - - // Create a user in MAS with the same email as the Keycloak user - console.log(`Creating MAS user with same email as Keycloak user: ${userLegacy.email}`); - - userLegacy.masId = await createMasUserWithPassword(userLegacy.username, userLegacy.email, userLegacy.password); - - try { - - // Perform the OIDC login flow - await performOidcLogin(page, userLegacy, screenshot_path); - - // Since the account already exists, we should be automatically logged in - // Verify we're successfully logged in - await expect(page.locator('text=Connecté')).toBeVisible(); - - // Take a screenshot of the authenticated state - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); - - // Verify the user in MAS is still the same (account was linked, not created new) - const userAfterLogin = await getMasUserByEmail(userLegacy.email); - expect(userAfterLogin.id).toBe(userLegacy.masId); - //expect(await oauthLinkExistsByUserId(userLegacy.masId)).toBe(true); - expect(await oauthLinkExistsBySubject(userLegacy.username)).toBe(true); - - console.log(`Successfully verified account linking for user with email: ${userLegacy.email}`); - - // deactvate account - await deactivateMasUser(userLegacy.masId); - - // SUPPORT PROCESS PERFORMED BY BOT ADMIN - const links = await getOauthLinkBySubject(userLegacy.username); - await deleteOauthLink(links[0]['id']) - await reactivateMasUser(userLegacy.masId); - await addUserEmail(userLegacy.masId, userLegacy.email) - // END OF SUPPORT PROCESS - - // Create a new incognito browser context - const context2 = await browser.newContext(); - - // Create a page. - const page2 = await context2.newPage(); - - //RESTART ANOTHER OIDC LOGIN - await page2.goto('/login'); - - const screenshot_path_2 = screenshot_path + "_2" - // Take a screenshot of the login page - await page2.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path_2}/01-login-page.png` }); - - // Perform the OIDC login flow - await performOidcLogin(page2, userLegacy, screenshot_path_2); - - await page2.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path_2}/04-linked-account.png` }); - - // Since the account already exists, we should be automatically logged in - // Verify we're successfully logged in - await expect(page2.locator('text=Connecté')).toBeVisible(); - - // Take a screenshot of the authenticated state - await page2.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path_2}/05-connected-account.png` }); - - - console.log(`Successfully verified account linking for user with email: ${userLegacy.email}`); - - - // Verify the user in MAS is still the same (account was linked, not created new) - const userAfterLogin2 = await getMasUserByEmail(userLegacy.email); - expect(userAfterLogin2.id).toBe(userLegacy.masId); - //expect(await oauthLinkExistsByUserId(userLegacy.masId)).toBe(true); - expect(await oauthLinkExistsBySubject(userLegacy.username)).toBe(true); - - console.log(`Successfully verified account linking for user with email: ${userLegacy.email}`); - - } finally { - // Clean up the MAS user - await deactivateMasUser(userLegacy.masId); - console.log(`Cleaned up MAS user: ${userLegacy.username}`); - } - }); test('match account by username throw error when email does not match', async ({ page}) => { const screenshot_path = test.info().title.replace(" ", "_"); diff --git a/tests/auth/password/tchap-login-password.spec.ts b/tests/auth/password/tchap-login-password.spec.ts index 8287e8b..b7c588c 100644 --- a/tests/auth/password/tchap-login-password.spec.ts +++ b/tests/auth/password/tchap-login-password.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '../../../fixtures/auth-fixture'; -import { checkMasUserExistsByEmail, createMasUserWithPassword } from '../../../utils/mas-admin'; +import { checkMasUserExistsByEmail, createMasUserWithPassword, deactivateMasUser } from '../../../utils/mas-admin'; import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from '../../../utils/config'; import { Page } from '@playwright/test'; import { loginWithPassword } from '../../../utils/auth-helpers'; @@ -44,4 +44,5 @@ test.describe('Tchap : Login password', () => { console.log(`Successfully authenticated and verified user ${userData.username} (${userData.email})`); }); + }); diff --git a/tests/auth/password/tchap-register-password.spec.ts b/tests/auth/password/tchap-register-password.spec.ts index 7618570..ac2e4db 100644 --- a/tests/auth/password/tchap-register-password.spec.ts +++ b/tests/auth/password/tchap-register-password.spec.ts @@ -4,7 +4,7 @@ import { generateTestUserData } from '../../../utils/auth-helpers'; import { getMasUserByEmail } from '../../../utils/mas-admin'; -import {ELEMENT_URL, NOT_INVITED_EMAIL_DOMAIN, STANDARD_EMAIL_DOMAIN, WRONG_SERVER_EMAIL_DOMAIN } from '../../../utils/config'; +import {NOT_INVITED_EMAIL_DOMAIN, STANDARD_EMAIL_DOMAIN, WRONG_SERVER_EMAIL_DOMAIN } from '../../../utils/config'; import { getLatestVerificationCode } from '../../../utils/mailpit'; diff --git a/tests/auth/password/tchap-reset-password.spec.ts b/tests/auth/password/tchap-reset-password.spec.ts index b8323ac..e512bc3 100644 --- a/tests/auth/password/tchap-reset-password.spec.ts +++ b/tests/auth/password/tchap-reset-password.spec.ts @@ -48,4 +48,7 @@ test.describe('Tchap : reset password', () => { await screenChecker(page, '/recover/progress') }); + + + //TODO add reset password when user is connected -> show an error }); diff --git a/tests/email-account-validity/tchap-deactivated-account.spec.ts b/tests/email-account-validity/tchap-deactivated-account.spec.ts new file mode 100644 index 0000000..5ed936a --- /dev/null +++ b/tests/email-account-validity/tchap-deactivated-account.spec.ts @@ -0,0 +1,254 @@ + + + +import { test, expect } from '../../fixtures/auth-fixture'; +import { addUserEmail, checkMasUserExistsByEmail, createMasUserWithPassword, deactivateMasUser, deleteOauthLink , getMasUserByEmail, getOauthLinkBySubject, getUserEmail, oauthLinkExistsBySubject, reactivateMasUser } from '../../utils/mas-admin'; +import { SCREENSHOTS_DIR, ELEMENT_URL } from '../../utils/config'; +import { performOidcLogin } from '../../utils/auth-helpers'; +import { getLatestVerificationCode } from '../../utils/mailpit'; + + +test.describe('Tchap : Login password', () => { + + test('password login when account is deactivated displays "Identifiants Invalides"', + async ({ page, browser, userData, screenChecker }) => { + + //create user + userData.masId = await createMasUserWithPassword(userData.username, userData.email, userData.password); + + //login + await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); + await screenChecker(page, `#/welcome`) + await page.getByRole('link').filter({hasText : "Se connecter"}).click(); + await screenChecker(page, `#/email-precheck-sso`) + await page.locator('input').fill(userData.email); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + await screenChecker(page, `/login`) + await expect(page.locator('input[name="username"]')).toHaveValue(userData.email); + await page.locator('input[name="password"]').fill(userData.password); + await page.locator('button[type="submit"]').click(); + + //deactivate user + await deactivateMasUser(userData.masId); + + //login with another browser + page = await (await browser.newContext()).newPage(); + + //login + await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); + await screenChecker(page, `#/welcome`) + await page.getByRole('link').filter({hasText : "Se connecter"}).click(); + await screenChecker(page, `#/email-precheck-sso`) + await page.locator('input').fill(userData.email); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + await screenChecker(page, `/login`) + await expect(page.locator('input[name="username"]')).toHaveValue(userData.email); + await page.locator('input[name="password"]').fill(userData.password); + await page.locator('button[type="submit"]').click(); + + await expect(page.locator('text=Identifiants invalides')).toBeVisible(); + }); + + test('register when account is deactivated reactivates account silently', + async ({ page, userData, screenChecker, startTchapRegisterWithEmail }) => { + + //create user + userData.masId = await createMasUserWithPassword(userData.username, userData.email, userData.password); + + //deactivate user + await deactivateMasUser(userData.masId); + + //register + await startTchapRegisterWithEmail(page, userData.email); + await screenChecker(page, '/register/password'); + await expect(page.locator('input[name="email"]')).toHaveValue(userData.email); + await page.locator('input[name="password"]').fill(userData.password); + await page.locator('input[name="password_confirm"]').fill(userData.password); + await page.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field + await expect(page.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click({clickCount:2}); //2 clicks works better than one + await screenChecker(page, '/verify-email'); + let verificationCode = await getLatestVerificationCode(userData.email); + await page.locator('input[name="code"]').fill(verificationCode); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + await screenChecker(page, '/consent'); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); + await page.waitForSelector(".mx_MatrixChat", { timeout: 20000 }); + + //same user, but reactivated + const created_user = await getMasUserByEmail(userData.email); + console.log(created_user); + expect(created_user.id).toBe(userData.masId); + expect(created_user.attributes.deactivated_at).toBeNull(); + + }); + + test('match account by email when former account is deactivated but another one is valid', + async ({page, oidcUser, screenChecker }) => { + const screenshot_path = test.info().title.replace(" ", "_"); + + // Create a user in MAS with the same email as the Keycloak user + console.log(`Creating MAS user with same email as Keycloak user: ${oidcUser.email}`); + const formerTchapAccountMasId = await createMasUserWithPassword(oidcUser.username, oidcUser.email, oidcUser.password); + await deactivateMasUser(formerTchapAccountMasId); + + const newTchapAccountWithIndex = { + username: oidcUser.username + "2", + email: oidcUser.email, + password: oidcUser.password, + masId: "", + } + //create another user with same email but different username + newTchapAccountWithIndex.masId = await createMasUserWithPassword(newTchapAccountWithIndex.username, newTchapAccountWithIndex.email, newTchapAccountWithIndex.password); + + try { + + // Perform the OIDC login flow with KC Account(=userLegacy) + await performOidcLogin(page, oidcUser, screenshot_path); + + // Since the account already exists, we should be automatically logged in + // Verify we're successfully logged in + await expect(page.locator('text=Connecté')).toBeVisible(); + + // Take a screenshot of the authenticated state + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); + + // Verify the user in MAS is linked to the indexed account + const userAfterLogin = await getMasUserByEmail(newTchapAccountWithIndex.email); + expect(userAfterLogin.id).toBe(newTchapAccountWithIndex.masId); + expect(userAfterLogin.attributes['username']).toBe(newTchapAccountWithIndex.username); + await expect(page.locator(`text=${newTchapAccountWithIndex.username}`)).toBeVisible(); + expect(await oauthLinkExistsBySubject(oidcUser.username)).toBe(true); + + console.log(`Successfully verified account linking for user with email: ${newTchapAccountWithIndex.email}`); + + } finally { + // Clean up the MAS user + await deactivateMasUser(newTchapAccountWithIndex.masId); + console.log(`Cleaned up MAS user: ${newTchapAccountWithIndex.username}`); + } + }); + + test('oidc login match account by email when account was deactivated, reactivate account', + async ({page, browser, oidcUser, screenChecker }) => { + + test.setTimeout(30000); + + const screenshot_path = test.info().title.replace(" ", "_"); + + //create user + oidcUser.masId = await createMasUserWithPassword(oidcUser.username, oidcUser.email, oidcUser.password); + + //login into account + await performOidcLogin(page, oidcUser, screenshot_path); + + //deactivate, email is unset + await deactivateMasUser(oidcUser.masId); + try{ + //user has no email when deactivated + await getMasUserByEmail(oidcUser.email); + } catch(e){ + expect(e).toBeDefined(); + } + + //login with another browser context + page = await (await browser.newContext()).newPage(); + + + // Perform the OIDC login flow + await performOidcLogin(page, oidcUser, screenshot_path); + await screenChecker(page, "/") + await expect(page.locator('text=Connecté')).toBeVisible(); + + //mas user is reactivated and email is set + const created_user = await getMasUserByEmail(oidcUser.email); + expect(created_user.id).toBe(oidcUser.masId); + expect(created_user.attributes.deactivated_at).toBeNull(); + + }); + + //legacy + //skip it + test.skip('match account by email when account was deactivated but is reactivated by support', async ({browser, page, oidcUser }) => { + const screenshot_path = test.info().title.replace(" ", "_"); + + // Create a user in MAS with the same email as the Keycloak user + console.log(`Creating MAS user with same email as Keycloak user: ${oidcUser.email}`); + + oidcUser.masId = await createMasUserWithPassword(oidcUser.username, oidcUser.email, oidcUser.password); + + try { + + // Perform the OIDC login flow + await performOidcLogin(page, oidcUser, screenshot_path); + + // Since the account already exists, we should be automatically logged in + // Verify we're successfully logged in + await expect(page.locator('text=Connecté')).toBeVisible(); + + // Take a screenshot of the authenticated state + await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); + + // Verify the user in MAS is still the same (account was linked, not created new) + const userAfterLogin = await getMasUserByEmail(oidcUser.email); + expect(userAfterLogin.id).toBe(oidcUser.masId); + //expect(await oauthLinkExistsByUserId(userLegacy.masId)).toBe(true); + expect(await oauthLinkExistsBySubject(oidcUser.username)).toBe(true); + + console.log(`Successfully verified account linking for user with email: ${oidcUser.email}`); + + // deactvate account + await deactivateMasUser(oidcUser.masId); + + // SUPPORT PROCESS PERFORMED BY BOT ADMIN + const links = await getOauthLinkBySubject(oidcUser.username); + await deleteOauthLink(links[0]['id']) + await reactivateMasUser(oidcUser.masId); + await addUserEmail(oidcUser.masId, oidcUser.email) + // END OF SUPPORT PROCESS + + // Create a new incognito browser context + const context2 = await browser.newContext(); + + // Create a page. + const page2 = await context2.newPage(); + + //RESTART ANOTHER OIDC LOGIN + await page2.goto('/login'); + + const screenshot_path_2 = screenshot_path + "_2" + // Take a screenshot of the login page + await page2.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path_2}/01-login-page.png` }); + + // Perform the OIDC login flow + await performOidcLogin(page2, oidcUser, screenshot_path_2); + + await page2.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path_2}/04-linked-account.png` }); + + // Since the account already exists, we should be automatically logged in + // Verify we're successfully logged in + await expect(page2.locator('text=Connecté')).toBeVisible(); + + // Take a screenshot of the authenticated state + await page2.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path_2}/05-connected-account.png` }); + + + console.log(`Successfully verified account linking for user with email: ${oidcUser.email}`); + + + // Verify the user in MAS is still the same (account was linked, not created new) + const userAfterLogin2 = await getMasUserByEmail(oidcUser.email); + expect(userAfterLogin2.id).toBe(oidcUser.masId); + //expect(await oauthLinkExistsByUserId(userLegacy.masId)).toBe(true); + expect(await oauthLinkExistsBySubject(oidcUser.username)).toBe(true); + + console.log(`Successfully verified account linking for user with email: ${oidcUser.email}`); + + } finally { + // Clean up the MAS user + await deactivateMasUser(oidcUser.masId); + console.log(`Cleaned up MAS user: ${oidcUser.username}`); + } + }); + +}) \ No newline at end of file diff --git a/utils/mas-admin.ts b/utils/mas-admin.ts index f2bb7a5..0cd9793 100644 --- a/utils/mas-admin.ts +++ b/utils/mas-admin.ts @@ -246,11 +246,11 @@ export async function deactivateMasUser(userId: string): Promise { if (!response.ok()) { const errorText = await response.text(); - console.error(`[MAS API] Failed to delete user: ${response.status()} - ${errorText}`); - throw new Error(`Failed to delete MAS user: ${response.status()} - ${errorText}`); + console.error(`[MAS API] Failed to deactivate user: ${response.status()} - ${errorText}`); + throw new Error(`Failed to deactivate MAS user: ${response.status()} - ${errorText}`); } - console.log(`[MAS API] User deleted successfully`); + console.log(`[MAS API] User deactivated successfully`); } /** @@ -410,6 +410,48 @@ export async function addUserEmail(userId: string, email: string): Promise return; } +export async function getUserEmail(userId: string, email: string): Promise { + const token = await getMasAdminToken(); + const apiRequestContext = await getApiContext(); + + const response = await apiRequestContext.get(`/api/admin/v1/user-emails?filter[user]=${userId}&filter[email]=${email}`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok()) { + const errorText = await response.text(); + console.error(`[MAS API] Failed to set email for user: ${response.status()} - ${errorText}`); + throw new Error(`Failed to set email for user: ${response.status()} - ${errorText}`); + } + const json = await response.json(); + const user_email = json.data[0]; + console.log(`[MAS API] User email retrieved for userId:${userId}, user_email : ${JSON.stringify(user_email)} `); + return user_email; +} + + +export async function deleteUserEmail(id: string): Promise { + const token = await getMasAdminToken(); + const apiRequestContext = await getApiContext(); + + const response = await apiRequestContext.delete(`/api/admin/v1/user-emails/${id}`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok()) { + const errorText = await response.text(); + console.error(`[MAS API] Failed to set email for user: ${response.status()} - ${errorText}`); + throw new Error(`Failed to set email for user: ${response.status()} - ${errorText}`); + } + console.log(`[MAS API] User email deleted for user-emails:${id}, response : ${JSON.stringify(response)} `); + return; +} /** From b980c4b7f64f23b5c76298fd68bb3852ae23269e Mon Sep 17 00:00:00 2001 From: Olivier D Date: Fri, 10 Apr 2026 12:05:05 +0200 Subject: [PATCH 60/71] remove legacy account creation without MAS, add biome (#36) --- .env.dev01 | 7 +- .env.dev02 | 2 - .env.local | 3 - .env.preprod | 1 - biome.json | 46 ++ fixtures/auth-fixture.ts | 82 ++- package-lock.json | 164 +++++ package.json | 12 +- playwright.config.ts | 24 +- tests/auth/logout/tchap-logout.spec.ts | 21 +- tests/auth/oidc/mas-login-oidc.spec.ts | 128 ++-- tests/auth/oidc/mas-register-oidc.spec.ts | 104 ++-- tests/auth/oidc/tchap-login-oidc.spec.ts | 37 +- tests/auth/password/mas-legacy.spec.ts | 11 +- .../auth/password/mas-login-password.spec.ts | 31 +- .../password/mas-register-password.spec.ts | 27 +- .../password/tchap-login-password.spec.ts | 50 +- .../password/tchap-register-password.spec.ts | 113 ++-- .../password/tchap-reset-password.spec.ts | 59 +- .../tchap-deactivated-account.spec.ts | 258 ++++---- .../tchap-expiration.spec.ts | 72 ++- tests/minimal/minimal-scenario.spec.ts | 575 +++++++++--------- tests/web/create-room.spec.ts | 120 ++-- utils/TchapAppPage.ts | 352 +++++------ utils/api.ts | 196 +++--- utils/auth-helpers.ts | 330 +++++----- utils/config.ts | 38 +- utils/keycloak-admin.ts | 74 ++- utils/mailpit.ts | 123 ++-- utils/mas-admin.ts | 272 +++++---- utils/synapse-admin.ts | 37 +- 31 files changed, 1865 insertions(+), 1504 deletions(-) create mode 100644 biome.json diff --git a/.env.dev01 b/.env.dev01 index 7187cc8..00b591a 100644 --- a/.env.dev01 +++ b/.env.dev01 @@ -6,7 +6,7 @@ BASE_URL=https://matrix.dev01.tchap.incubateur.net MAIL_URL=https://yop.tchap.incubateur.net # mailpit user credentials -MAILPIT_USER=tchap +MAILPIT_USER=tchap MAILPIT_PWD= # Keycloak Admin Credentials @@ -19,7 +19,7 @@ MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 MAS_ADMIN_CLIENT_SECRET= # Test User Credentials -TEST_USER_PREFIX=user.local +TEST_USER_PREFIX=user.dev TEST_USER_PASSWORD=Test123456sdksdfkljfs222! # Email domains for test users @@ -38,7 +38,4 @@ SCREENSHOTS_DIR=playwright-results/dev01 #Run tests in parallel TEST_IN_PARALLEL=false - -USE_MAS=true - SYNAPSE_ADMIN_TOKEN= \ No newline at end of file diff --git a/.env.dev02 b/.env.dev02 index 240c452..d8531e5 100644 --- a/.env.dev02 +++ b/.env.dev02 @@ -39,6 +39,4 @@ SCREENSHOTS_DIR=playwright-results/dev02 #Run tests in parallel TEST_IN_PARALLEL=false -USE_MAS=false - SYNAPSE_ADMIN_TOKEN= \ No newline at end of file diff --git a/.env.local b/.env.local index f4c7a2a..89790d8 100644 --- a/.env.local +++ b/.env.local @@ -41,7 +41,4 @@ TEST_IN_PARALLEL=false #disable tls verification in nodeJS (required for axios to work with self signed certificate) NODE_TLS_REJECT_UNAUTHORIZED = '0' - -USE_MAS=true - SYNAPSE_ADMIN_TOKEN= \ No newline at end of file diff --git a/.env.preprod b/.env.preprod index 2ca5879..14bbf84 100644 --- a/.env.preprod +++ b/.env.preprod @@ -39,6 +39,5 @@ SCREENSHOTS_DIR=playwright-results/preprod TEST_IN_PARALLEL=false -USE_MAS=true SYNAPSE_ADMIN_TOKEN= \ No newline at end of file diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..eb3d921 --- /dev/null +++ b/biome.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.11/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingCommas": "es5", + "semicolons": "always" + }, + "parser": { + "unsafeParameterDecoratorsEnabled": true + } + }, + "json": { + "parser": { + "allowTrailingCommas": false + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/fixtures/auth-fixture.ts b/fixtures/auth-fixture.ts index 4bba4a8..dd1cfac 100644 --- a/fixtures/auth-fixture.ts +++ b/fixtures/auth-fixture.ts @@ -1,14 +1,19 @@ -import { test as base, Browser, Page, TestInfo } from "@playwright/test"; +import { test as base, Browser, type Page, type TestInfo } from '@playwright/test'; import { createKeycloakTestUser, cleanupKeycloakTestUser, - TestUser, + type TestUser, TypeUser, populateLocalStorageWithCredentials, -} from "../utils/auth-helpers"; -import { disposeApiContext as disposeKeycloakApiContext } from "../utils/keycloak-admin"; -import { createMasUserWithPassword, deactivateMasUser, disposeApiContext as disposeMasApiContext, waitForMasUser } from "../utils/mas-admin"; -import { generateTestUserData } from "../utils/auth-helpers"; +} from '../utils/auth-helpers'; +import { disposeApiContext as disposeKeycloakApiContext } from '../utils/keycloak-admin'; +import { + createMasUserWithPassword, + deactivateMasUser, + disposeApiContext as disposeMasApiContext, + waitForMasUser, +} from '../utils/mas-admin'; +import { generateTestUserData } from '../utils/auth-helpers'; import fs from 'fs'; import path from 'path'; import { SCREENSHOTS_DIR } from '../utils/config'; @@ -20,9 +25,9 @@ import { WRONG_SERVER_EMAIL_DOMAIN, NUMERIQUE_EMAIL_DOMAIN, BASE_URL, - ELEMENT_URL -} from "../utils/config"; -import { ClientServerApi, Credentials } from "../utils/api"; + ELEMENT_URL, +} from '../utils/config'; +import { ClientServerApi, type Credentials } from '../utils/api'; function generateUserDataFixture(domain: string) { return async ({}, use: (user: TestUser) => Promise) => { @@ -31,11 +36,9 @@ function generateUserDataFixture(domain: string) { // Use the test user in the test await use(user); - } finally { // Dispose API contexts - await Promise.all([ - ]); + await Promise.all([]); } }; } @@ -60,16 +63,24 @@ function createKeycloakUserFixture(domain: string) { } finally { // Dispose API contexts await Promise.all([disposeKeycloakApiContext(), disposeMasApiContext()]); - console.log("API contexts disposed"); + console.log('API contexts disposed'); } }; } export type ScreenCheckerFixture = (page: Page, urlFragment: string) => Promise; export type StartTchapRegisterWithEmailFixture = (page: Page, email: string) => Promise; -export type AuthenticatedUserFixture = (page: Page, user: TestUser, request: any) => Promise; - -async function screenCheckerFixture({}: {}, use: (screenChecker: ScreenCheckerFixture) => Promise, testInfo: TestInfo) { +export type AuthenticatedUserFixture = ( + page: Page, + user: TestUser, + request: any +) => Promise; + +async function screenCheckerFixture( + {}: {}, + use: (screenChecker: ScreenCheckerFixture) => Promise, + testInfo: TestInfo +) { //this fixture clean up the screenshot folder before the tests //and exposes a method to capture a screenshot from an waited url @@ -83,17 +94,26 @@ async function screenCheckerFixture({}: {}, use: (screenChecker: ScreenCheckerFi const screenChecker = async (page: Page, urlFragment: string) => { const browserName = page.context().browser()?.browserType().name(); - - await page.waitForURL((url) => { console.log("current page url : ", url.pathname); return url.toString().includes(urlFragment)}, {waitUntil:"load"}); + + await page.waitForURL( + (url) => { + console.log('current page url : ', url.pathname); + return url.toString().includes(urlFragment); + }, + { waitUntil: 'load' } + ); const filename = `${browserName}_${counter.toString().padStart(2, '0')}-${urlFragment.replace(/[^\w]/g, '_')}.png`; - await page.screenshot({ path: path.join(screenshotPath, filename), fullPage:true }); + await page.screenshot({ path: path.join(screenshotPath, filename), fullPage: true }); counter++; }; await use(screenChecker); } -async function startTchapRegisterWithEmailFixture({ screenChecker }: { screenChecker: ScreenCheckerFixture }, use: (start: StartTchapRegisterWithEmailFixture) => Promise) { +async function startTchapRegisterWithEmailFixture( + { screenChecker }: { screenChecker: ScreenCheckerFixture }, + use: (start: StartTchapRegisterWithEmailFixture) => Promise +) { const start = async (page: Page, email: string) => { await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'load' }); await screenChecker(page, '#/welcome'); @@ -109,28 +129,24 @@ async function startTchapRegisterWithEmailFixture({ screenChecker }: { screenChe await use(start); } -async function authenticatedUserFixture({ page, userData: user, request }: { page: Page, userData: TestUser, request: any }, use: (credentials: Credentials) => Promise) { +async function authenticatedUserFixture( + { page, userData: user, request }: { page: Page; userData: TestUser; request: any }, + use: (credentials: Credentials) => Promise +) { // 1. Register user - const userId = await createMasUserWithPassword( - user.username, - user.email, - user.password - ); + const userId = await createMasUserWithPassword(user.username, user.email, user.password); const csAPI = new ClientServerApi(BASE_URL, request); await waitForMasUser(user.email); - const credentials = (await csAPI.loginUser( - user.username, - user.password - )) as Credentials; + const credentials = (await csAPI.loginUser(user.username, user.password)) as Credentials; // 2. Populate localStorage await populateLocalStorageWithCredentials(page, credentials); // 3. Load app await page.goto(ELEMENT_URL); - await page.waitForSelector(".mx_MatrixChat", { timeout: 20000 }); + await page.waitForSelector('.mx_MatrixChat', { timeout: 20000 }); // 4. Pass page to test await use(credentials); @@ -144,7 +160,7 @@ async function authenticatedUserFixture({ page, userData: user, request }: { pag * Extend the basic test fixtures with our authentication fixtures */ export const test = base.extend<{ - userData: TestUser, + userData: TestUser; oidcUser: TestUser; oidcExternalUserWithInvit: TestUser; oidcExternalUserWitoutInvit: TestUser; @@ -170,4 +186,4 @@ export const test = base.extend<{ startTchapRegisterWithEmail: startTchapRegisterWithEmailFixture, }); -export { expect } from "@playwright/test"; \ No newline at end of file +export { expect } from '@playwright/test'; diff --git a/package-lock.json b/package-lock.json index a5442bc..4ed4618 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { + "@biomejs/biome": "^2.4.11", "@playwright/test": "^1.40.0", "@types/node": "^20.10.0", "dotenv": "^16.3.1", @@ -16,6 +17,169 @@ "typescript": "^5.3.2" } }, + "node_modules/@biomejs/biome": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.11.tgz", + "integrity": "sha512-nWxHX8tf3Opb/qRgZpBbsTOqOodkbrkJ7S+JxJAruxOReaDPPmPuLBAGQ8vigyUgo0QBB+oQltNEAvalLcjggA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.11", + "@biomejs/cli-darwin-x64": "2.4.11", + "@biomejs/cli-linux-arm64": "2.4.11", + "@biomejs/cli-linux-arm64-musl": "2.4.11", + "@biomejs/cli-linux-x64": "2.4.11", + "@biomejs/cli-linux-x64-musl": "2.4.11", + "@biomejs/cli-win32-arm64": "2.4.11", + "@biomejs/cli-win32-x64": "2.4.11" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.11.tgz", + "integrity": "sha512-wOt+ed+L2dgZanWyL6i29qlXMc088N11optzpo10peayObBaAshbTcxKUchzEMp9QSY8rh5h6VfAFE3WTS1rqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.11.tgz", + "integrity": "sha512-gZ6zR8XmZlExfi/Pz/PffmdpWOQ8Qhy7oBztgkR8/ylSRyLwfRPSadmiVCV8WQ8PoJ2MWUy2fgID9zmtgUUJmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.11.tgz", + "integrity": "sha512-avdJaEElXrKceK0va9FkJ4P5ci3N01TGkc6ni3P8l3BElqbOz42Wg2IyX3gbh0ZLEd4HVKEIrmuVu/AMuSeFFA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.11.tgz", + "integrity": "sha512-+Sbo1OAmlegtdwqFE8iOxFIWLh1B3OEgsuZfBpyyN/kWuqZ8dx9ZEes6zVnDMo+zRHF2wLynRVhoQmV7ohxl2Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.11.tgz", + "integrity": "sha512-TagWV0iomp5LnEnxWFg4nQO+e52Fow349vaX0Q/PIcX6Zhk4GGBgp3qqZ8PVkpC+cuehRctMf3+6+FgQ8jCEFQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.11.tgz", + "integrity": "sha512-bexd2IklK7ZgPhrz6jXzpIL6dEAH9MlJU1xGTrypx+FICxrXUp4CqtwfiuoDKse+UlgAlWtzML3jrMqeEAHEhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.11.tgz", + "integrity": "sha512-RJhaTnY8byzxDt4bDVb7AFPHkPcjOPK3xBip4ZRTrN3TEfyhjLRm3r3mqknqydgVTB74XG8l4jMLwEACEeihVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.11.tgz", + "integrity": "sha512-A8D3JM/00C2KQgUV3oj8Ba15EHEYwebAGCy5Sf9GAjr5Y3+kJIYOiESoqRDeuRZueuMdCsbLZIUqmPhpYXJE9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@playwright/test": { "version": "1.51.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.51.1.tgz", diff --git a/package.json b/package.json index 208e2f7..8f7c033 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,11 @@ "test:ui": "playwright test --ui", "test:local": "ENV=local playwright test", "test:dev02": "ENV=dev02 playwright test ./tests/minimal/", - "test:preprod": "ENV=preprod playwright test ./tests/minimal/" - + "test:preprod": "ENV=preprod playwright test ./tests/minimal/", + "lint": "biome lint .", + "lint:fix": "biome lint . --fix", + "format": "biome format .", + "format:fix": "biome format . --write" }, "keywords": [ "playwright", @@ -22,10 +25,11 @@ "author": "", "license": "ISC", "devDependencies": { + "@biomejs/biome": "^2.4.11", "@playwright/test": "^1.40.0", "@types/node": "^20.10.0", "dotenv": "^16.3.1", - "typescript": "^5.3.2", - "mailpit-api": "^1.5.4" + "mailpit-api": "^1.5.4", + "typescript": "^5.3.2" } } diff --git a/playwright.config.ts b/playwright.config.ts index 04536d8..b809b32 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -21,7 +21,7 @@ export default defineConfig({ /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, - + retries: process.env.CI ? 2 : 0, /* Reporter to use */ reporter: 'html', @@ -29,26 +29,26 @@ export default defineConfig({ use: { /* Base URL to use in actions like `await page.goto('/')` */ baseURL: process.env.MAS_URL || 'https://auth.tchapgouv.com', - + /* Set locale to French */ locale: BROWSER_LOCALE, - + /* Collect trace when retrying the failed test */ trace: 'on-first-retry', - + /* Take screenshot on failure */ screenshot: 'only-on-failure', - + /* Record video on failure */ video: 'on-first-retry', - + /* Ignore HTTPS errors */ ignoreHTTPSErrors: true, }, - + /* Configure projects for major browsers */ projects: [ - /* e2e tests do not work well on firefox nor webkit (bit flaky) + /* e2e tests do not work well on firefox nor webkit (bit flaky) { name: 'firefox', use: { ...devices['Desktop Firefox'] }, @@ -57,12 +57,10 @@ export default defineConfig({ name: 'webkit', use: { ...devices['Desktop Edge'] }, }, - */ - { + */ + { name: 'chromium', use: { ...devices['Desktop Chrome'] }, - }, - - + }, ], }); diff --git a/tests/auth/logout/tchap-logout.spec.ts b/tests/auth/logout/tchap-logout.spec.ts index 3368ea1..ce07eee 100644 --- a/tests/auth/logout/tchap-logout.spec.ts +++ b/tests/auth/logout/tchap-logout.spec.ts @@ -1,27 +1,29 @@ import { test, expect } from '../../../fixtures/auth-fixture'; -import {createMasUserWithPassword } from '../../../utils/mas-admin'; +import { createMasUserWithPassword } from '../../../utils/mas-admin'; import { loginWithPassword } from '../../../utils/auth-helpers'; - test.describe('Tchap : logout', () => { + test('tchap login-logout-login', async ({ page, userData, screenChecker }) => { + userData.masId = await createMasUserWithPassword( + userData.username, + userData.email, + userData.password + ); - test('tchap login-logout-login', async ({ page, userData: userData, screenChecker }) => { - userData.masId = await createMasUserWithPassword(userData.username, userData.email, userData.password); - // First login await loginWithPassword(page, userData, screenChecker); // Success - Tchap home - await expect(page.locator('text=Bienvenue')).toBeVisible({timeout: 20000}); + await expect(page.locator('text=Bienvenue')).toBeVisible({ timeout: 20000 }); //logout await page.getByLabel('Avatar').click(); //await screenChecker(page, `/`) await page.getByRole('button', { name: 'Se déconnecter' }).click(); - await screenChecker(page, `/welcome`) + await screenChecker(page, `/welcome`); // Second login - await page.getByRole('link').filter({hasText : "Se connecter"}).click(); + await page.getByRole('link').filter({ hasText: 'Se connecter' }).click(); // Email precheck await screenChecker(page, `#/email-precheck-sso`); @@ -39,8 +41,7 @@ test.describe('Tchap : logout', () => { await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); // Success - Confirm identity - await expect(page.locator('text=Confirmez votre identité')).toBeVisible({timeout: 20000}); + await expect(page.locator('text=Confirmez votre identité')).toBeVisible({ timeout: 20000 }); await screenChecker(page, `/`); }); - }); diff --git a/tests/auth/oidc/mas-login-oidc.spec.ts b/tests/auth/oidc/mas-login-oidc.spec.ts index ab3647d..7b76dcc 100644 --- a/tests/auth/oidc/mas-login-oidc.spec.ts +++ b/tests/auth/oidc/mas-login-oidc.spec.ts @@ -1,34 +1,40 @@ import { test, expect } from '../../../fixtures/auth-fixture'; -import { +import { createKeycloakTestUser, performOidcLogin, - TestUser, + type TestUser, } from '../../../utils/auth-helpers'; -import { createMasUserWithPassword, getMasUserByEmail, deactivateMasUser,oauthLinkExistsByUserId, oauthLinkExistsBySubject} from '../../../utils/mas-admin'; +import { + createMasUserWithPassword, + getMasUserByEmail, + deactivateMasUser, + oauthLinkExistsByUserId, + oauthLinkExistsBySubject, +} from '../../../utils/mas-admin'; import { SCREENSHOTS_DIR, STANDARD_EMAIL_DOMAIN } from '../../../utils/config'; test.describe('MAS Login OIDC', () => { - - test('match account by email', async ({ page, oidcUser: oidcUser }) => { - const screenshot_path = test.info().title.replace(" ", "_"); + test('match account by email', async ({ page, oidcUser }) => { + const screenshot_path = test.info().title.replace(' ', '_'); // Create a user in MAS with the same email as the Keycloak user console.log(`Creating MAS user with same email as Keycloak user: ${oidcUser.email}`); - - oidcUser.masId = await createMasUserWithPassword(oidcUser.username, oidcUser.email, "any"); - + + oidcUser.masId = await createMasUserWithPassword(oidcUser.username, oidcUser.email, 'any'); + try { - // Perform the OIDC login flow await performOidcLogin(page, oidcUser, screenshot_path); - + // Since the account already exists, we should be automatically logged in // Verify we're successfully logged in await expect(page.locator('text=Connecté')).toBeVisible(); - + // Take a screenshot of the authenticated state - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); - + await page.screenshot({ + path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png`, + }); + // Verify the user in MAS is still the same (account was linked, not created new) const userAfterLogin = await getMasUserByEmail(oidcUser.email); expect(userAfterLogin.id).toBe(oidcUser.masId); @@ -43,35 +49,46 @@ test.describe('MAS Login OIDC', () => { } }); - test('match external account by email', async ({ page, oidcExternalUserWitoutInvit: externalUser }) => { - // we use the fixture oidcExternalUserWitoutInvit because as long as the account is created, + test('match external account by email', async ({ + page, + oidcExternalUserWitoutInvit: externalUser, + }) => { + // we use the fixture oidcExternalUserWitoutInvit because as long as the account is created, // there is no invitation pending in the identity server. - - const screenshot_path = test.info().title.replace(" ", "_"); + + const screenshot_path = test.info().title.replace(' ', '_'); // Create a user in MAS with the same email as the Keycloak user console.log(`Creating MAS user with same email as Keycloak user: ${externalUser.email}`); - - externalUser.masId = await createMasUserWithPassword(externalUser.username, externalUser.email, externalUser.password); - + + externalUser.masId = await createMasUserWithPassword( + externalUser.username, + externalUser.email, + externalUser.password + ); + try { // Perform the OIDC login flow await performOidcLogin(page, externalUser, screenshot_path); - + // Since the account already exists, we should be automatically logged in // Verify we're successfully logged in await expect(page.locator('text=Connecté')).toBeVisible(); - + // Take a screenshot of the authenticated state - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); - + await page.screenshot({ + path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png`, + }); + // Verify the user in MAS is still the same (account was linked, not created new) const userAfterLogin = await getMasUserByEmail(externalUser.email); expect(userAfterLogin.id).toBe(externalUser.masId); //expect(await oauthLinkExistsByUserId(userLegacy.masId)).toBe(true); expect(await oauthLinkExistsBySubject(externalUser.username)).toBe(true); - console.log(`Successfully verified account linking for user with email: ${externalUser.email}`); + console.log( + `Successfully verified account linking for user with email: ${externalUser.email}` + ); } finally { // Clean up the MAS user await deactivateMasUser(externalUser.masId); @@ -79,30 +96,39 @@ test.describe('MAS Login OIDC', () => { } }); - test('match account by email with fallback rules', async ({ page, oidcUserWithFallbackRules: oidcUser }) => { - const screenshot_path = test.info().title.replace(" ", "_"); + test('match account by email with fallback rules', async ({ + page, + oidcUserWithFallbackRules: oidcUser, + }) => { + const screenshot_path = test.info().title.replace(' ', '_'); - const old_email_domain = "@beta.gouv.fr"; + const old_email_domain = '@beta.gouv.fr'; const old_email = oidcUser.email.replace(/@.*/, old_email_domain); - // Create a user in MAS with the same email as the Keycloak user - console.log(`Creating MAS user with old email: ${old_email} whereas email in keycloak is : ${oidcUser.email}`); - - oidcUser.masId = await createMasUserWithPassword(oidcUser.username+"different_from_email", old_email, oidcUser.password); - + console.log( + `Creating MAS user with old email: ${old_email} whereas email in keycloak is : ${oidcUser.email}` + ); + + oidcUser.masId = await createMasUserWithPassword( + oidcUser.username + 'different_from_email', + old_email, + oidcUser.password + ); + try { - // Perform the OIDC login flow await performOidcLogin(page, oidcUser, screenshot_path); - + // Since the account already exists, we should be automatically logged in // Verify we're successfully logged in await expect(page.locator('text=Connecté')).toBeVisible(); - + // Take a screenshot of the authenticated state - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); - + await page.screenshot({ + path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png`, + }); + // Verify the user in MAS is still the same (account was linked, not created new) const userAfterLogin = await getMasUserByEmail(old_email); expect(userAfterLogin.id).toBe(oidcUser.masId); @@ -116,39 +142,37 @@ test.describe('MAS Login OIDC', () => { } }); + test('match account by username throw error when email does not match', async ({ page }) => { + const screenshot_path = test.info().title.replace(' ', '_'); - test('match account by username throw error when email does not match', async ({ page}) => { - const screenshot_path = test.info().title.replace(" ", "_"); - //create a user in keycloak with an `email` that matches a `localpart` in MAS //while the email in MAS is different //then linking will not be on the `email` but on the `localpart` //which is a failing edge case //we expect an error page const domain = STANDARD_EMAIL_DOMAIN; - + const randomSuffix = Math.floor(Math.random() * 10000000); const mas_user_email = `any-email-${randomSuffix}@${domain}`; - const testUser: TestUser = { - username: `test.user${randomSuffix}-${domain}`, - email: `test.user${randomSuffix}@${domain}`, - password: "1234!", - }; + const testUser: TestUser = { + username: `test.user${randomSuffix}-${domain}`, + email: `test.user${randomSuffix}@${domain}`, + password: '1234!', + }; const user = await createKeycloakTestUser(testUser); user.masId = await createMasUserWithPassword(user.username, mas_user_email, user.password); - + try { // Perform the OIDC login flow await performOidcLogin(page, user, screenshot_path); - + // Get error //await expect(page.locator('text=unknown_error')); await expect(page.locator('text="Invalid Data"')).toBeVisible(); -; - + console.log(`Successfully verified account linking for user with email: ${user.email}`); } finally { // Clean up the MAS user diff --git a/tests/auth/oidc/mas-register-oidc.spec.ts b/tests/auth/oidc/mas-register-oidc.spec.ts index e9b4f5c..94fa64a 100644 --- a/tests/auth/oidc/mas-register-oidc.spec.ts +++ b/tests/auth/oidc/mas-register-oidc.spec.ts @@ -1,111 +1,123 @@ import { test, expect } from '../../../fixtures/auth-fixture'; -import { - performOidcLogin, - verifyUserInMas -} from '../../../utils/auth-helpers'; -import { checkMasUserExistsByEmail} from '../../../utils/mas-admin'; +import { performOidcLogin, verifyUserInMas } from '../../../utils/auth-helpers'; +import { checkMasUserExistsByEmail } from '../../../utils/mas-admin'; import { SCREENSHOTS_DIR } from '../../../utils/config'; test.describe('MAS register OIDC', () => { - - test('mas register oidc - with allowed account', async ({ page, oidcUser: oidcUser }) => { - const screenshot_path = test.info().title.replace(" ", "_"); + test('mas register oidc - with allowed account', async ({ page, oidcUser }) => { + const screenshot_path = test.info().title.replace(' ', '_'); // Verify the test user doesn't exist in MAS yet const existsBeforeLogin = await checkMasUserExistsByEmail(oidcUser.email); expect(existsBeforeLogin).toBe(false); - + // Perform the OIDC login flow - await performOidcLogin(page, oidcUser,screenshot_path); + await performOidcLogin(page, oidcUser, screenshot_path); // Verify we're successfully logged in // This could be checking for a specific element that's only visible when logged in await expect(page.locator('text=Connecté')).toBeVisible(); - + // Take a screenshot of the authenticated state await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-authenticated.png` }); - + // Verify the user was created in MAS await verifyUserInMas(oidcUser); - + // Double-check with the API const existsAfterLogin = await checkMasUserExistsByEmail(oidcUser.email); expect(existsAfterLogin).toBe(true); - - console.log(`Successfully authenticated and verified user ${oidcUser.username} (${oidcUser.email})`); + + console.log( + `Successfully authenticated and verified user ${oidcUser.username} (${oidcUser.email})` + ); }); - - test('mas register oidc - with extern without invit', async ({ page, oidcExternalUserWitoutInvit: oidcExternalUserWitoutInvit }) => { - const screenshot_path = test.info().title.replace(" ", "_"); + + test('mas register oidc - with extern without invit', async ({ + page, + oidcExternalUserWitoutInvit, + }) => { + const screenshot_path = test.info().title.replace(' ', '_'); // Verify the test user doesn't exist in MAS yet const existsBeforeLogin = await checkMasUserExistsByEmail(oidcExternalUserWitoutInvit.email); expect(existsBeforeLogin).toBe(false); - + // Perform the OIDC login flow await performOidcLogin(page, oidcExternalUserWitoutInvit, screenshot_path); - + // Get error - await expect(page.locator('text=invitation_missing')).toBeVisible();; - + await expect(page.locator('text=invitation_missing')).toBeVisible(); + // Take a screenshot of the authenticated state await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-error-no-invit.png` }); - - + // Double-check with the API const existsAfterLogin = await checkMasUserExistsByEmail(oidcExternalUserWitoutInvit.email); expect(existsAfterLogin).toBe(false); - - console.log(`Successfully authenticated and verified user ${oidcExternalUserWitoutInvit.username} (${oidcExternalUserWitoutInvit.email})`); + + console.log( + `Successfully authenticated and verified user ${oidcExternalUserWitoutInvit.username} (${oidcExternalUserWitoutInvit.email})` + ); }); - test('mas register oidc - with extern with invit', async ({ page, oidcExternalUserWithInvit: oidcExternalUserWithInvit }) => { - const screenshot_path = test.info().title.replace(" ", "_"); + test('mas register oidc - with extern with invit', async ({ + page, + oidcExternalUserWithInvit, + }) => { + const screenshot_path = test.info().title.replace(' ', '_'); // Verify the test user doesn't exist in MAS yet const existsBeforeLogin = await checkMasUserExistsByEmail(oidcExternalUserWithInvit.email); expect(existsBeforeLogin).toBe(false); - + // Perform the OIDC login flow await performOidcLogin(page, oidcExternalUserWithInvit, screenshot_path); - + // Verify we're successfully logged in await expect(page.locator('text=Connecté')).toBeVisible(); - + // Take a screenshot of the authenticated state - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-authenticated-external.png` }); - + await page.screenshot({ + path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-authenticated-external.png`, + }); + // Verify the user was created in MAS await verifyUserInMas(oidcExternalUserWithInvit); - + // Double-check with the API const existsAfterLogin = await checkMasUserExistsByEmail(oidcExternalUserWithInvit.email); expect(existsAfterLogin).toBe(true); - - console.log(`Successfully authenticated and verified external user ${oidcExternalUserWithInvit.username} (${oidcExternalUserWithInvit.email})`); + + console.log( + `Successfully authenticated and verified external user ${oidcExternalUserWithInvit.username} (${oidcExternalUserWithInvit.email})` + ); }); - test('mas register oidc - on wrong homeserver', async ({ page, oidcUserOnWrongServer: oidcUserOnWrongServer }) => { - const screenshot_path = test.info().title.replace(" ", "_"); + test('mas register oidc - on wrong homeserver', async ({ page, oidcUserOnWrongServer }) => { + const screenshot_path = test.info().title.replace(' ', '_'); // Verify the test user doesn't exist in MAS yet const existsBeforeLogin = await checkMasUserExistsByEmail(oidcUserOnWrongServer.email); expect(existsBeforeLogin).toBe(false); - + // Perform the OIDC login flow await performOidcLogin(page, oidcUserOnWrongServer, screenshot_path); - + // Get error await expect(page.locator('text=wrong_server')).toBeVisible(); - + // Take a screenshot of the authenticated state - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-error-wrong-server.png` }); - + await page.screenshot({ + path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-error-wrong-server.png`, + }); + // Double-check with the API const existsAfterLogin = await checkMasUserExistsByEmail(oidcUserOnWrongServer.email); expect(existsAfterLogin).toBe(false); - - console.log(`Successfully authenticated and verified user ${oidcUserOnWrongServer.username} (${oidcUserOnWrongServer.email})`); - }); + console.log( + `Successfully authenticated and verified user ${oidcUserOnWrongServer.username} (${oidcUserOnWrongServer.email})` + ); + }); }); diff --git a/tests/auth/oidc/tchap-login-oidc.spec.ts b/tests/auth/oidc/tchap-login-oidc.spec.ts index 30a37d2..f5582ca 100644 --- a/tests/auth/oidc/tchap-login-oidc.spec.ts +++ b/tests/auth/oidc/tchap-login-oidc.spec.ts @@ -1,45 +1,46 @@ import { test, expect } from '../../../fixtures/auth-fixture'; -import { - verifyUserInMas, - performOidcLoginFromTchap -} from '../../../utils/auth-helpers'; +import { verifyUserInMas, performOidcLoginFromTchap } from '../../../utils/auth-helpers'; import { checkMasUserExistsByEmail, createMasUserWithPassword } from '../../../utils/mas-admin'; import { SCREENSHOTS_DIR, TCHAP_LEGACY } from '../../../utils/config'; - //flaky on await expect(page.locator('text=Configuration')).toBeVisible({timeout: 20000}); test.describe('Tchap : Login via OIDC', () => { - - test('tchap match account by email', async ({ page, oidcUser: oidcUser }) => { - const screenshot_path = test.info().title.replace(" ", "_"); + test('tchap match account by email', async ({ page, oidcUser }) => { + const screenshot_path = test.info().title.replace(' ', '_'); - oidcUser.masId = await createMasUserWithPassword(oidcUser.username, oidcUser.email, oidcUser.password); + oidcUser.masId = await createMasUserWithPassword( + oidcUser.username, + oidcUser.email, + oidcUser.password + ); // Verify the test user doesn't exist in MAS yet const existsBeforeLogin = await checkMasUserExistsByEmail(oidcUser.email); expect(existsBeforeLogin).toBe(true); - + // Perform the OIDC login flow - await performOidcLoginFromTchap(page, oidcUser,screenshot_path, TCHAP_LEGACY); + await performOidcLoginFromTchap(page, oidcUser, screenshot_path, TCHAP_LEGACY); // Take a screenshot of the authenticated state await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/05-confirmation.png` }); - - await page.locator('button[type="submit"]').filter({hasText:'Continuer'}).click(); + + await page.locator('button[type="submit"]').filter({ hasText: 'Continuer' }).click(); //flaky condition - await expect(page.locator('text=Bienvenue')).toBeVisible({timeout: 20000}); + await expect(page.locator('text=Bienvenue')).toBeVisible({ timeout: 20000 }); // Take a screenshot of the authenticated state await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/06-auth-success.png` }); - + // Verify the user was created in MAS await verifyUserInMas(oidcUser); - + // Double-check with the API const existsAfterLogin = await checkMasUserExistsByEmail(oidcUser.email); expect(existsAfterLogin).toBe(true); - - console.log(`Successfully authenticated and verified user ${oidcUser.username} (${oidcUser.email})`); + + console.log( + `Successfully authenticated and verified user ${oidcUser.username} (${oidcUser.email})` + ); }); }); diff --git a/tests/auth/password/mas-legacy.spec.ts b/tests/auth/password/mas-legacy.spec.ts index 65dd8a2..1df29d6 100644 --- a/tests/auth/password/mas-legacy.spec.ts +++ b/tests/auth/password/mas-legacy.spec.ts @@ -2,7 +2,6 @@ import { test, expect } from '@playwright/test'; import { BASE_URL, MAS_URL } from '../../../utils/config'; test.describe('Tchap : Legacy SSO Flow', () => { - test('verify legacy SSO redirect chain for registration', async ({ request }) => { // The initial legacy SSO URL const email = 'user@exemple.com'; @@ -10,15 +9,15 @@ test.describe('Tchap : Legacy SSO Flow', () => { //login_hint is not taken into account in MAS for compat-sso flow console.log(`[Legacy SSO] Step 1: Calling initial URL: ${initialUrl}`); - + // Step 1: Call the initial URL without following redirects const response1 = await request.get(initialUrl, { maxRedirects: 0, }); - + console.log(`[Legacy SSO] Response 1 status: ${response1.status()}`); expect(response1.status()).toEqual(303); - + // Get the first redirect location (should be complete-compat-sso) const completeCompatSsoUrl = response1.headers()['location']; expect(completeCompatSsoUrl).toBeTruthy(); @@ -28,7 +27,7 @@ test.describe('Tchap : Legacy SSO Flow', () => { expect(completeCompatSsoUrl).toContain(`${MAS_URL}/complete-compat-sso/`); expect(completeCompatSsoUrl).toContain('action=register'); expect(completeCompatSsoUrl).toContain('org.matrix.msc3824.action=register'); - + // Step 2: Follow the first redirect to complete-compat-sso console.log(`[Legacy SSO] Step 2: Following redirect to complete-compat-sso`); const response2 = await request.get(completeCompatSsoUrl!, { @@ -41,7 +40,7 @@ test.describe('Tchap : Legacy SSO Flow', () => { const registerUrl = response2.headers()['location']; expect(registerUrl).toBeTruthy(); console.log(`[Legacy SSO] Second redirect to: ${registerUrl}`); - + // Verify this is the register URL with correct parameters expect(registerUrl).toContain(`/register`); expect(registerUrl).toContain('kind=continue_compat_sso_login'); diff --git a/tests/auth/password/mas-login-password.spec.ts b/tests/auth/password/mas-login-password.spec.ts index cc8fbe7..3db7562 100644 --- a/tests/auth/password/mas-login-password.spec.ts +++ b/tests/auth/password/mas-login-password.spec.ts @@ -1,32 +1,33 @@ import { test, expect } from '../../../fixtures/auth-fixture'; -import { - createMasTestUser, - cleanupMasTestUser, - performPasswordLogin, - TestUser +import { + createMasTestUser, + cleanupMasTestUser, + performPasswordLogin, + TestUser, } from '../../../utils/auth-helpers'; import { SCREENSHOTS_DIR } from '../../../utils/config'; test.describe('MAS login password', () => { - test('with allowed account', async ({ page }) => { - const screenshot_path = test.info().title.replace(" ", "_"); + const screenshot_path = test.info().title.replace(' ', '_'); // Create a test user with a password in MAS - const user = await createMasTestUser("exemple.com"); - + const user = await createMasTestUser('exemple.com'); + try { console.log(`Created test user in MAS: ${user.username} (${user.email})`); - + // Perform password login - await performPasswordLogin(page, user,screenshot_path); - + await performPasswordLogin(page, user, screenshot_path); + // Verify we're successfully logged in await expect(page.locator('text=Connecté')).toBeVisible(); - + // Take a screenshot of the authenticated state - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-password-auth-success.png` }); - + await page.screenshot({ + path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-password-auth-success.png`, + }); + console.log(`Successfully authenticated with password for user: ${user.username}`); } finally { // Clean up the test user diff --git a/tests/auth/password/mas-register-password.spec.ts b/tests/auth/password/mas-register-password.spec.ts index 698e1a7..090eb97 100644 --- a/tests/auth/password/mas-register-password.spec.ts +++ b/tests/auth/password/mas-register-password.spec.ts @@ -1,32 +1,33 @@ import { test, expect } from '../../../fixtures/auth-fixture'; - test.describe('MAS Register password', () => { + const PASSWORd = 'sdf78qsd!9090ssss'; - const PASSWORd = "sdf78qsd!9090ssss"; - - test.skip('without oauth2 session', async ({page, screenChecker: screen }) => { + test.skip('without oauth2 session', async ({ page, screenChecker: screen }) => { // This test is intentionally ignored. //the error page has been deactivated for the moment because the MAS unit tests must be fixed await page.goto('/register'); await page.getByRole('button').filter({ hasText: 'Continuer avec mon adresse mail' }).click(); - - //form is not submitted because no oauth2_authorization_grant - await screen(page, '/register/password'); - await expect(page.locator("h1", {hasText:"Unexpected error"})).toBeVisible(); - await expect(page.locator("p", {hasText:"Veuillez fermer cette fenêtre et relancer la création de compte depuis votre appareil Tchap"})).toBeVisible(); + //form is not submitted because no oauth2_authorization_grant + await screen(page, '/register/password'); + await expect(page.locator('h1', { hasText: 'Unexpected error' })).toBeVisible(); + await expect( + page.locator('p', { + hasText: + 'Veuillez fermer cette fenêtre et relancer la création de compte depuis votre appareil Tchap', + }) + ).toBeVisible(); }); - test('with JavaScript disabled', async ({ browser, userData: user, screenChecker: screen }) => { - + test('with JavaScript disabled', async ({ browser, userData: user, screenChecker: screen }) => { const ctx = await browser.newContext({ javaScriptEnabled: false }); const page = await ctx.newPage(); await page.goto('/register'); await page.getByRole('button').filter({ hasText: 'Continuer avec mon adresse mail' }).click(); - + await screen(page, '/register/password'); await page.locator('input[name="email"]').fill(user.email); await page.locator('input[name="password"]').fill(PASSWORd); @@ -36,6 +37,4 @@ test.describe('MAS Register password', () => { //form is submitted successfully await screen(page, '/verify-email'); }); - - }); diff --git a/tests/auth/password/tchap-login-password.spec.ts b/tests/auth/password/tchap-login-password.spec.ts index b7c588c..cd6c299 100644 --- a/tests/auth/password/tchap-login-password.spec.ts +++ b/tests/auth/password/tchap-login-password.spec.ts @@ -1,48 +1,54 @@ import { test, expect } from '../../../fixtures/auth-fixture'; -import { checkMasUserExistsByEmail, createMasUserWithPassword, deactivateMasUser } from '../../../utils/mas-admin'; +import { + checkMasUserExistsByEmail, + createMasUserWithPassword, + deactivateMasUser, +} from '../../../utils/mas-admin'; import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from '../../../utils/config'; import { Page } from '@playwright/test'; import { loginWithPassword } from '../../../utils/auth-helpers'; - test.describe('Tchap : Login password', () => { - - test('tchap login with password and login_hint', async ({ page, userData: userData, screenChecker }) => { - const screenshot_path = test.info().title.replace(" ", "_"); - - userData.masId = await createMasUserWithPassword(userData.username, userData.email, userData.password); + test('tchap login with password and login_hint', async ({ page, userData, screenChecker }) => { + const screenshot_path = test.info().title.replace(' ', '_'); + + userData.masId = await createMasUserWithPassword( + userData.username, + userData.email, + userData.password + ); const existsBeforeLogin = await checkMasUserExistsByEmail(userData.email); expect(existsBeforeLogin).toBe(true); - + await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); - - await screenChecker(page, `#/welcome`) - await page.getByRole('link').filter({hasText : "Se connecter"}).click(); - - await screenChecker(page, `#/email-precheck-sso`) + + await screenChecker(page, `#/welcome`); + await page.getByRole('link').filter({ hasText: 'Se connecter' }).click(); + + await screenChecker(page, `#/email-precheck-sso`); await page.locator('input').fill(userData.email); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - + //login - await screenChecker(page, `/login`) + await screenChecker(page, `/login`); await expect(page.locator('input[name="username"]')).toHaveValue(userData.email); await page.locator('input[name="password"]').fill(userData.password); await page.locator('button[type="submit"]').click(); - //consent - await screenChecker(page, `/consent`) + await screenChecker(page, `/consent`); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - //tchap - await expect(page.locator('text=Bienvenue')).toBeVisible({timeout: 20000}); + //tchap + await expect(page.locator('text=Bienvenue')).toBeVisible({ timeout: 20000 }); await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/05-auth-success.png` }); // Double-check with the API const existsAfterLogin = await checkMasUserExistsByEmail(userData.email); expect(existsAfterLogin).toBe(true); - - console.log(`Successfully authenticated and verified user ${userData.username} (${userData.email})`); - }); + console.log( + `Successfully authenticated and verified user ${userData.username} (${userData.email})` + ); + }); }); diff --git a/tests/auth/password/tchap-register-password.spec.ts b/tests/auth/password/tchap-register-password.spec.ts index ac2e4db..a518a7e 100644 --- a/tests/auth/password/tchap-register-password.spec.ts +++ b/tests/auth/password/tchap-register-password.spec.ts @@ -1,35 +1,40 @@ import { test, expect } from '../../../fixtures/auth-fixture'; -import { - createMasTestUser, - generateTestUserData -} from '../../../utils/auth-helpers'; +import { createMasTestUser, generateTestUserData } from '../../../utils/auth-helpers'; import { getMasUserByEmail } from '../../../utils/mas-admin'; -import {NOT_INVITED_EMAIL_DOMAIN, STANDARD_EMAIL_DOMAIN, WRONG_SERVER_EMAIL_DOMAIN } from '../../../utils/config'; +import { + NOT_INVITED_EMAIL_DOMAIN, + STANDARD_EMAIL_DOMAIN, + WRONG_SERVER_EMAIL_DOMAIN, +} from '../../../utils/config'; import { getLatestVerificationCode } from '../../../utils/mailpit'; - test.describe('Tchap : register with password', () => { - - - const PASSWORd = "sdf78qsd!9090ssss"; - - test('tchap register with oidc native', async ({ context, page, userData: user, screenChecker: screen, startTchapRegisterWithEmail }) => { - + const PASSWORd = 'sdf78qsd!9090ssss'; + + test('tchap register with oidc native', async ({ + context, + page, + userData: user, + screenChecker: screen, + startTchapRegisterWithEmail, + }) => { await startTchapRegisterWithEmail(page, user.email); await screen(page, '/register/password'); await expect(page.locator('input[name="email"]')).toHaveValue(user.email); - + await page.locator('input[name="password"]').fill(PASSWORd); await page.locator('input[name="password_confirm"]').fill(PASSWORd); //wait for password-confirm matching confirmation - await page.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field - await expect(page.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click({clickCount:2}); //2 clicks works better than one + await page.locator('body').click({ position: { x: 0, y: 0 } }); //unfocus field + await expect( + page.locator('span').filter({ hasText: 'Les mots de passe correspondent.' }) + ).toBeVisible(); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click({ clickCount: 2 }); //2 clicks works better than one await screen(page, '/verify-email'); - let verificationCode = await getLatestVerificationCode(user.email); + const verificationCode = await getLatestVerificationCode(user.email); await page.locator('input[name="code"]').fill(verificationCode); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); @@ -37,15 +42,21 @@ test.describe('Tchap : register with password', () => { await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); //await screen(page, '#/home'); does not work with waitFor "networkidle" - await expect(page.locator('h1').filter({ hasText: /Bienvenue.*\[Tchapgouv\]/ })).toBeVisible({ timeout: 20000 }); + await expect(page.locator('h1').filter({ hasText: /Bienvenue.*\[Tchapgouv\]/ })).toBeVisible({ + timeout: 20000, + }); const created_user = await getMasUserByEmail(user.email); //check created username fields expect(created_user.attributes.username).toContain(user.username); }); - test('with not invited email', async ({page, userData: user, screenChecker: screen, startTchapRegisterWithEmail }) => { - + test('with not invited email', async ({ + page, + userData: user, + screenChecker: screen, + startTchapRegisterWithEmail, + }) => { await startTchapRegisterWithEmail(page, user.email); const not_invited_user = generateTestUserData(NOT_INVITED_EMAIL_DOMAIN); @@ -59,19 +70,29 @@ test.describe('Tchap : register with password', () => { await page.locator('input[name="password_confirm"]').fill(PASSWORd); //wait for password-confirm matching confirmation - await page.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field - await expect(page.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click({clickCount:2}); + await page.locator('body').click({ position: { x: 0, y: 0 } }); //unfocus field + await expect( + page.locator('span').filter({ hasText: 'Les mots de passe correspondent.' }) + ).toBeVisible(); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click({ clickCount: 2 }); await screen(page, '/register/password'); await expect(page.locator('input[name="email"]')).toHaveValue(not_invited_user.email); //check that error message is visible - await expect(page.locator('div.cpd-form-message.cpd-form-error-message').filter({ hasText: 'Vous avez besoin d\'une invitation' })).toBeVisible(); + await expect( + page + .locator('div.cpd-form-message.cpd-form-error-message') + .filter({ hasText: "Vous avez besoin d'une invitation" }) + ).toBeVisible(); }); - test('with email on wrong server', async ({page, userData: user, screenChecker: screen, startTchapRegisterWithEmail }) => { - + test('with email on wrong server', async ({ + page, + userData: user, + screenChecker: screen, + startTchapRegisterWithEmail, + }) => { await startTchapRegisterWithEmail(page, user.email); const wrong_server_user = generateTestUserData(WRONG_SERVER_EMAIL_DOMAIN); @@ -85,21 +106,32 @@ test.describe('Tchap : register with password', () => { await page.locator('input[name="password_confirm"]').fill(PASSWORd); //wait for password-confirm matching confirmation - await page.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field - await expect(page.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click({clickCount:2}); + await page.locator('body').click({ position: { x: 0, y: 0 } }); //unfocus field + await expect( + page.locator('span').filter({ hasText: 'Les mots de passe correspondent.' }) + ).toBeVisible(); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click({ clickCount: 2 }); await screen(page, '/register/password'); //check that error message is visible - await expect(page.locator('div.cpd-form-message.cpd-form-error-message').filter({ hasText: 'Votre adresse mail est associée à un autre serveur' })).toBeVisible(); + await expect( + page + .locator('div.cpd-form-message.cpd-form-error-message') + .filter({ hasText: 'Votre adresse mail est associée à un autre serveur' }) + ).toBeVisible(); }); - test('when user already exists', async ({page, context, browser, screenChecker: screen, startTchapRegisterWithEmail }) => { - + test('when user already exists', async ({ + page, + context, + browser, + screenChecker: screen, + startTchapRegisterWithEmail, + }) => { // Create a test user with a password in MAS with API const user = await createMasTestUser(STANDARD_EMAIL_DOMAIN); - + // Create a test user with a password in MAS with web flow /* const user = generateTestUserData(STANDARD_EMAIL_DOMAIN); @@ -134,19 +166,21 @@ test.describe('Tchap : register with password', () => { await screen(page2, '/register/password'); await expect(page2.locator('input[name="email"]')).toHaveValue(user.email); - + await page2.locator('input[name="password"]').fill(PASSWORd); await page2.locator('input[name="password_confirm"]').fill(PASSWORd); - //wait for password-confirm matching confirmation - await page2.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field - await expect(page2.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); - await page2.getByRole('button').filter({ hasText: 'Continuer' }).click({clickCount:2}); + //wait for password-confirm matching confirmation + await page2.locator('body').click({ position: { x: 0, y: 0 } }); //unfocus field + await expect( + page2.locator('span').filter({ hasText: 'Les mots de passe correspondent.' }) + ).toBeVisible(); + await page2.getByRole('button').filter({ hasText: 'Continuer' }).click({ clickCount: 2 }); //form is submitted successfully await screen(page2, '/verify-email'); - let verificationCode2 = await getLatestVerificationCode(user.email); + const verificationCode2 = await getLatestVerificationCode(user.email); await page2.locator('input[name="code"]').fill(verificationCode2); await page2.getByRole('button').filter({ hasText: 'Continuer' }).click(); @@ -156,5 +190,4 @@ test.describe('Tchap : register with password', () => { await screen(page2, '/login'); }); - }); diff --git a/tests/auth/password/tchap-reset-password.spec.ts b/tests/auth/password/tchap-reset-password.spec.ts index e512bc3..daef8d0 100644 --- a/tests/auth/password/tchap-reset-password.spec.ts +++ b/tests/auth/password/tchap-reset-password.spec.ts @@ -1,54 +1,63 @@ import { test, expect } from '../../../fixtures/auth-fixture'; import { checkMasUserExistsByEmail, createMasUserWithPassword } from '../../../utils/mas-admin'; -import { ELEMENT_URL } from '../../../utils/config'; +import { ELEMENT_URL } from '../../../utils/config'; import { openResetPasswordEmail } from '../../../utils/auth-helpers'; - test.describe('Tchap : reset password', () => { - - test('tchap reset password', async ({ page, userData: userData,screenChecker }) => { - - userData.masId = await createMasUserWithPassword(userData.username, userData.email, userData.password); + test('tchap reset password', async ({ page, userData, screenChecker }) => { + userData.masId = await createMasUserWithPassword( + userData.username, + userData.email, + userData.password + ); const existsBeforeLogin = await checkMasUserExistsByEmail(userData.email); expect(existsBeforeLogin).toBe(true); - + await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); - - await screenChecker(page, '/welcome') - await page.getByRole('link').filter({hasText : "Se connecter"}).click(); + + await screenChecker(page, '/welcome'); + await page.getByRole('link').filter({ hasText: 'Se connecter' }).click(); await page.locator('input').fill(userData.email); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - //MAS login - await screenChecker(page, '/login') - await page.getByRole('link').filter({ hasText: "Mot de passe oublié" }).click(); - + await screenChecker(page, '/login'); + await page.getByRole('link').filter({ hasText: 'Mot de passe oublié' }).click(); + //MAS reset password await page.locator('input[name="email"]').fill(userData.email); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); //MAS reset password - verify - await screenChecker(page, '/recover/progress') + await screenChecker(page, '/recover/progress'); - const resetPwdPage = await openResetPasswordEmail(page.context(), screenChecker, userData.email); + const resetPwdPage = await openResetPasswordEmail( + page.context(), + screenChecker, + userData.email + ); - const newPassword = "monchienmangemapantoufle" + const newPassword = 'monchienmangemapantoufle'; await resetPwdPage.locator('input[name="new_password"]').fill(newPassword); await resetPwdPage.locator('input[name="new_password_again"]').fill(newPassword); - await resetPwdPage.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field - await expect(resetPwdPage.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); - await resetPwdPage.getByRole('button').filter({ hasText: 'Sauvegarder et continuer' }).click({clickCount:2}); + await resetPwdPage.locator('body').click({ position: { x: 0, y: 0 } }); //unfocus field + await expect( + resetPwdPage.locator('span').filter({ hasText: 'Les mots de passe correspondent.' }) + ).toBeVisible(); + await resetPwdPage + .getByRole('button') + .filter({ hasText: 'Sauvegarder et continuer' }) + .click({ clickCount: 2 }); //new tab is redirected back to MAS welcome page - await expect(resetPwdPage.getByRole('link').filter({ hasText: 'Continuer dans Tchap' })).toBeVisible(); - await screenChecker(resetPwdPage, '/') + await expect( + resetPwdPage.getByRole('link').filter({ hasText: 'Continuer dans Tchap' }) + ).toBeVisible(); + await screenChecker(resetPwdPage, '/'); //first tab is stuck back to recovery page - await screenChecker(page, '/recover/progress') - + await screenChecker(page, '/recover/progress'); }); - //TODO add reset password when user is connected -> show an error }); diff --git a/tests/email-account-validity/tchap-deactivated-account.spec.ts b/tests/email-account-validity/tchap-deactivated-account.spec.ts index 5ed936a..4a097bb 100644 --- a/tests/email-account-validity/tchap-deactivated-account.spec.ts +++ b/tests/email-account-validity/tchap-deactivated-account.spec.ts @@ -1,47 +1,60 @@ - - - import { test, expect } from '../../fixtures/auth-fixture'; -import { addUserEmail, checkMasUserExistsByEmail, createMasUserWithPassword, deactivateMasUser, deleteOauthLink , getMasUserByEmail, getOauthLinkBySubject, getUserEmail, oauthLinkExistsBySubject, reactivateMasUser } from '../../utils/mas-admin'; +import { + addUserEmail, + checkMasUserExistsByEmail, + createMasUserWithPassword, + deactivateMasUser, + deleteOauthLink, + getMasUserByEmail, + getOauthLinkBySubject, + getUserEmail, + oauthLinkExistsBySubject, + reactivateMasUser, +} from '../../utils/mas-admin'; import { SCREENSHOTS_DIR, ELEMENT_URL } from '../../utils/config'; import { performOidcLogin } from '../../utils/auth-helpers'; import { getLatestVerificationCode } from '../../utils/mailpit'; - test.describe('Tchap : Login password', () => { - - test('password login when account is deactivated displays "Identifiants Invalides"', - async ({ page, browser, userData, screenChecker }) => { - + test('password login when account is deactivated displays "Identifiants Invalides"', async ({ + page, + browser, + userData, + screenChecker, + }) => { //create user - userData.masId = await createMasUserWithPassword(userData.username, userData.email, userData.password); - + userData.masId = await createMasUserWithPassword( + userData.username, + userData.email, + userData.password + ); + //login await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); - await screenChecker(page, `#/welcome`) - await page.getByRole('link').filter({hasText : "Se connecter"}).click(); - await screenChecker(page, `#/email-precheck-sso`) + await screenChecker(page, `#/welcome`); + await page.getByRole('link').filter({ hasText: 'Se connecter' }).click(); + await screenChecker(page, `#/email-precheck-sso`); await page.locator('input').fill(userData.email); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - await screenChecker(page, `/login`) + await screenChecker(page, `/login`); await expect(page.locator('input[name="username"]')).toHaveValue(userData.email); await page.locator('input[name="password"]').fill(userData.password); await page.locator('button[type="submit"]').click(); //deactivate user await deactivateMasUser(userData.masId); - - //login with another browser + + //login with another browser page = await (await browser.newContext()).newPage(); //login await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); - await screenChecker(page, `#/welcome`) - await page.getByRole('link').filter({hasText : "Se connecter"}).click(); - await screenChecker(page, `#/email-precheck-sso`) + await screenChecker(page, `#/welcome`); + await page.getByRole('link').filter({ hasText: 'Se connecter' }).click(); + await screenChecker(page, `#/email-precheck-sso`); await page.locator('input').fill(userData.email); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - await screenChecker(page, `/login`) + await screenChecker(page, `/login`); await expect(page.locator('input[name="username"]')).toHaveValue(userData.email); await page.locator('input[name="password"]').fill(userData.password); await page.locator('button[type="submit"]').click(); @@ -49,70 +62,90 @@ test.describe('Tchap : Login password', () => { await expect(page.locator('text=Identifiants invalides')).toBeVisible(); }); - test('register when account is deactivated reactivates account silently', - async ({ page, userData, screenChecker, startTchapRegisterWithEmail }) => { - + test('register when account is deactivated reactivates account silently', async ({ + page, + userData, + screenChecker, + startTchapRegisterWithEmail, + }) => { //create user - userData.masId = await createMasUserWithPassword(userData.username, userData.email, userData.password); - + userData.masId = await createMasUserWithPassword( + userData.username, + userData.email, + userData.password + ); + //deactivate user await deactivateMasUser(userData.masId); - + //register await startTchapRegisterWithEmail(page, userData.email); await screenChecker(page, '/register/password'); - await expect(page.locator('input[name="email"]')).toHaveValue(userData.email); + await expect(page.locator('input[name="email"]')).toHaveValue(userData.email); await page.locator('input[name="password"]').fill(userData.password); await page.locator('input[name="password_confirm"]').fill(userData.password); - await page.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field - await expect(page.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click({clickCount:2}); //2 clicks works better than one + await page.locator('body').click({ position: { x: 0, y: 0 } }); //unfocus field + await expect( + page.locator('span').filter({ hasText: 'Les mots de passe correspondent.' }) + ).toBeVisible(); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click({ clickCount: 2 }); //2 clicks works better than one await screenChecker(page, '/verify-email'); - let verificationCode = await getLatestVerificationCode(userData.email); + const verificationCode = await getLatestVerificationCode(userData.email); await page.locator('input[name="code"]').fill(verificationCode); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); await screenChecker(page, '/consent'); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - await page.waitForSelector(".mx_MatrixChat", { timeout: 20000 }); - + await page.waitForSelector('.mx_MatrixChat', { timeout: 20000 }); + //same user, but reactivated const created_user = await getMasUserByEmail(userData.email); console.log(created_user); expect(created_user.id).toBe(userData.masId); expect(created_user.attributes.deactivated_at).toBeNull(); - }); - test('match account by email when former account is deactivated but another one is valid', - async ({page, oidcUser, screenChecker }) => { - const screenshot_path = test.info().title.replace(" ", "_"); + test('match account by email when former account is deactivated but another one is valid', async ({ + page, + oidcUser, + screenChecker, + }) => { + const screenshot_path = test.info().title.replace(' ', '_'); // Create a user in MAS with the same email as the Keycloak user console.log(`Creating MAS user with same email as Keycloak user: ${oidcUser.email}`); - const formerTchapAccountMasId = await createMasUserWithPassword(oidcUser.username, oidcUser.email, oidcUser.password); + const formerTchapAccountMasId = await createMasUserWithPassword( + oidcUser.username, + oidcUser.email, + oidcUser.password + ); await deactivateMasUser(formerTchapAccountMasId); - + const newTchapAccountWithIndex = { - username: oidcUser.username + "2", + username: oidcUser.username + '2', email: oidcUser.email, password: oidcUser.password, - masId: "", - } + masId: '', + }; //create another user with same email but different username - newTchapAccountWithIndex.masId = await createMasUserWithPassword(newTchapAccountWithIndex.username, newTchapAccountWithIndex.email, newTchapAccountWithIndex.password); - + newTchapAccountWithIndex.masId = await createMasUserWithPassword( + newTchapAccountWithIndex.username, + newTchapAccountWithIndex.email, + newTchapAccountWithIndex.password + ); + try { - // Perform the OIDC login flow with KC Account(=userLegacy) await performOidcLogin(page, oidcUser, screenshot_path); // Since the account already exists, we should be automatically logged in // Verify we're successfully logged in await expect(page.locator('text=Connecté')).toBeVisible(); - + // Take a screenshot of the authenticated state - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); - + await page.screenshot({ + path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png`, + }); + // Verify the user in MAS is linked to the indexed account const userAfterLogin = await getMasUserByEmail(newTchapAccountWithIndex.email); expect(userAfterLogin.id).toBe(newTchapAccountWithIndex.masId); @@ -120,75 +153,90 @@ test.describe('Tchap : Login password', () => { await expect(page.locator(`text=${newTchapAccountWithIndex.username}`)).toBeVisible(); expect(await oauthLinkExistsBySubject(oidcUser.username)).toBe(true); - console.log(`Successfully verified account linking for user with email: ${newTchapAccountWithIndex.email}`); - + console.log( + `Successfully verified account linking for user with email: ${newTchapAccountWithIndex.email}` + ); } finally { // Clean up the MAS user await deactivateMasUser(newTchapAccountWithIndex.masId); console.log(`Cleaned up MAS user: ${newTchapAccountWithIndex.username}`); } }); - - test('oidc login match account by email when account was deactivated, reactivate account', - async ({page, browser, oidcUser, screenChecker }) => { - test.setTimeout(30000); + test('oidc login match account by email when account was deactivated, reactivate account', async ({ + page, + browser, + oidcUser, + screenChecker, + }) => { + test.setTimeout(30000); - const screenshot_path = test.info().title.replace(" ", "_"); - - //create user - oidcUser.masId = await createMasUserWithPassword(oidcUser.username, oidcUser.email, oidcUser.password); + const screenshot_path = test.info().title.replace(' ', '_'); - //login into account - await performOidcLogin(page, oidcUser, screenshot_path); + //create user + oidcUser.masId = await createMasUserWithPassword( + oidcUser.username, + oidcUser.email, + oidcUser.password + ); - //deactivate, email is unset - await deactivateMasUser(oidcUser.masId); - try{ - //user has no email when deactivated - await getMasUserByEmail(oidcUser.email); - } catch(e){ - expect(e).toBeDefined(); - } - - //login with another browser context - page = await (await browser.newContext()).newPage(); + //login into account + await performOidcLogin(page, oidcUser, screenshot_path); + //deactivate, email is unset + await deactivateMasUser(oidcUser.masId); + try { + //user has no email when deactivated + await getMasUserByEmail(oidcUser.email); + } catch (e) { + expect(e).toBeDefined(); + } - // Perform the OIDC login flow - await performOidcLogin(page, oidcUser, screenshot_path); - await screenChecker(page, "/") - await expect(page.locator('text=Connecté')).toBeVisible(); + //login with another browser context + page = await (await browser.newContext()).newPage(); + + // Perform the OIDC login flow + await performOidcLogin(page, oidcUser, screenshot_path); + await screenChecker(page, '/'); + await expect(page.locator('text=Connecté')).toBeVisible(); - //mas user is reactivated and email is set - const created_user = await getMasUserByEmail(oidcUser.email); - expect(created_user.id).toBe(oidcUser.masId); - expect(created_user.attributes.deactivated_at).toBeNull(); - + //mas user is reactivated and email is set + const created_user = await getMasUserByEmail(oidcUser.email); + expect(created_user.id).toBe(oidcUser.masId); + expect(created_user.attributes.deactivated_at).toBeNull(); }); - //legacy + //legacy //skip it - test.skip('match account by email when account was deactivated but is reactivated by support', async ({browser, page, oidcUser }) => { - const screenshot_path = test.info().title.replace(" ", "_"); + test.skip('match account by email when account was deactivated but is reactivated by support', async ({ + browser, + page, + oidcUser, + }) => { + const screenshot_path = test.info().title.replace(' ', '_'); // Create a user in MAS with the same email as the Keycloak user console.log(`Creating MAS user with same email as Keycloak user: ${oidcUser.email}`); - - oidcUser.masId = await createMasUserWithPassword(oidcUser.username, oidcUser.email, oidcUser.password); - + + oidcUser.masId = await createMasUserWithPassword( + oidcUser.username, + oidcUser.email, + oidcUser.password + ); + try { - // Perform the OIDC login flow await performOidcLogin(page, oidcUser, screenshot_path); // Since the account already exists, we should be automatically logged in // Verify we're successfully logged in await expect(page.locator('text=Connecté')).toBeVisible(); - + // Take a screenshot of the authenticated state - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png` }); - + await page.screenshot({ + path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-linked-account.png`, + }); + // Verify the user in MAS is still the same (account was linked, not created new) const userAfterLogin = await getMasUserByEmail(oidcUser.email); expect(userAfterLogin.id).toBe(oidcUser.masId); @@ -202,9 +250,9 @@ test.describe('Tchap : Login password', () => { // SUPPORT PROCESS PERFORMED BY BOT ADMIN const links = await getOauthLinkBySubject(oidcUser.username); - await deleteOauthLink(links[0]['id']) + await deleteOauthLink(links[0]['id']); await reactivateMasUser(oidcUser.masId); - await addUserEmail(oidcUser.masId, oidcUser.email) + await addUserEmail(oidcUser.masId, oidcUser.email); // END OF SUPPORT PROCESS // Create a new incognito browser context @@ -215,26 +263,28 @@ test.describe('Tchap : Login password', () => { //RESTART ANOTHER OIDC LOGIN await page2.goto('/login'); - - const screenshot_path_2 = screenshot_path + "_2" + + const screenshot_path_2 = screenshot_path + '_2'; // Take a screenshot of the login page await page2.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path_2}/01-login-page.png` }); - + // Perform the OIDC login flow await performOidcLogin(page2, oidcUser, screenshot_path_2); - await page2.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path_2}/04-linked-account.png` }); + await page2.screenshot({ + path: `${SCREENSHOTS_DIR}/${screenshot_path_2}/04-linked-account.png`, + }); // Since the account already exists, we should be automatically logged in // Verify we're successfully logged in await expect(page2.locator('text=Connecté')).toBeVisible(); - + // Take a screenshot of the authenticated state - await page2.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path_2}/05-connected-account.png` }); - - - console.log(`Successfully verified account linking for user with email: ${oidcUser.email}`); + await page2.screenshot({ + path: `${SCREENSHOTS_DIR}/${screenshot_path_2}/05-connected-account.png`, + }); + console.log(`Successfully verified account linking for user with email: ${oidcUser.email}`); // Verify the user in MAS is still the same (account was linked, not created new) const userAfterLogin2 = await getMasUserByEmail(oidcUser.email); @@ -243,12 +293,10 @@ test.describe('Tchap : Login password', () => { expect(await oauthLinkExistsBySubject(oidcUser.username)).toBe(true); console.log(`Successfully verified account linking for user with email: ${oidcUser.email}`); - } finally { // Clean up the MAS user await deactivateMasUser(oidcUser.masId); console.log(`Cleaned up MAS user: ${oidcUser.username}`); } }); - -}) \ No newline at end of file +}); diff --git a/tests/email-account-validity/tchap-expiration.spec.ts b/tests/email-account-validity/tchap-expiration.spec.ts index fade22d..b466688 100644 --- a/tests/email-account-validity/tchap-expiration.spec.ts +++ b/tests/email-account-validity/tchap-expiration.spec.ts @@ -1,8 +1,8 @@ import { test, expect } from '../../fixtures/auth-fixture'; -import { +import { createMasTestUser, loginWithPassword, - openRenewAccountEmail + openRenewAccountEmail, } from '../../utils/auth-helpers'; import { getMasUserByEmail } from '../../utils/mas-admin'; import { STANDARD_EMAIL_DOMAIN } from '../../utils/config'; @@ -10,101 +10,102 @@ import { setAccountExpiration } from '../../utils/synapse-admin'; test.describe('Tchap : account expiration', () => { // Define the password for test users - const PASSWORD = "Test123456sdksdfkljfs222!"; + const PASSWORD = 'Test123456sdksdfkljfs222!'; - test('should show expiration message when account is expired', async ({ + test('should show expiration message when account is expired', async ({ context, page, request, - screenChecker: screen + screenChecker: screen, }) => { // Create a new user with password authentication const user = await createMasTestUser(STANDARD_EMAIL_DOMAIN); console.log(`Created test user: ${user.username} with email: ${user.email}`); - + // First, log in normally to verify the account works await loginWithPassword(page, user, screen); - + // Wait for successful login and navigation to home page - await page.waitForSelector(".mx_MatrixChat", { timeout: 20000 }); + await page.waitForSelector('.mx_MatrixChat', { timeout: 20000 }); console.log('User successfully logged in'); - + // Get the user's Matrix ID const masUser = await getMasUserByEmail(user.email); const matrixId = `@${masUser.attributes.username}:dev01.tchap.incubateur.net`; console.log(`User Matrix ID: ${matrixId}`); - + // Set an expiration timestamp in the past to make the account expired const expirationTs = Math.floor(Date.now()) - 3600000; // 1 hour in the past console.log(`Setting expiration timestamp to: ${expirationTs} (1 hour in the past)`); - + // Call the Synapse API to set account expiration await setAccountExpiration(request, matrixId, expirationTs, true); - + await page.getByLabel('Avatar').click(); //await screenChecker(page, `/`) await page.getByRole('button', { name: 'Se déconnecter' }).click(); // Wait a moment for the expiration to take effect - await new Promise(resolve => setTimeout(resolve, 2000)); - - + await new Promise((resolve) => setTimeout(resolve, 2000)); + // Try to log in again with the expired account await loginWithPassword(page, user, screen); - + // Verify that an expiration message is shown await expect(page.getByText('Une erreur s’est produite')).toBeVisible(); - + console.log('Verified that the expired account shows expiration message'); - + // Clean up await page.close(); }); - - test('should show expiration message inside app when account is expired', async ({ + test('should show expiration message inside app when account is expired', async ({ context, page, request, - screenChecker: screen + screenChecker: screen, }) => { - test.setTimeout(180_000); // Create a new user with password authentication const user = await createMasTestUser(STANDARD_EMAIL_DOMAIN); console.log(`Created test user: ${user.username} with email: ${user.email}`); - + // First, log in normally to verify the account works await loginWithPassword(page, user, screen); - + // Wait for successful login and navigation to home page - await page.waitForSelector(".mx_MatrixChat", { timeout: 20000 }); + await page.waitForSelector('.mx_MatrixChat', { timeout: 20000 }); console.log('User successfully logged in'); - + // Get the user's Matrix ID const masUser = await getMasUserByEmail(user.email); const matrixId = `@${masUser.attributes.username}:dev01.tchap.incubateur.net`; console.log(`User Matrix ID: ${matrixId}`); - + // Set an expiration timestamp in the past to make the account expired const expirationTs = Math.floor(Date.now()) - 3600000; // 1 hour in the past console.log(`Setting expiration timestamp to: ${expirationTs} (1 hour in the past)`); - + // Call the Synapse API to set account expiration await setAccountExpiration(request, matrixId, expirationTs, true); - + //trigger the expiration panel by searching a forum await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); await page.getByRole('menuitem', { name: 'Rejoindre un forum', exact: true }).click(); - await page.getByRole('textbox', { name: 'Rechercher' }).fill("any"); + await page.getByRole('textbox', { name: 'Rechercher' }).fill('any'); - await expect(page.getByRole('heading', { name: 'Votre compte Tchap a expiré' })).toBeVisible({timeout:20000}); + await expect(page.getByRole('heading', { name: 'Votre compte Tchap a expiré' })).toBeVisible({ + timeout: 20000, + }); console.log(`Account has been expired`); await page.getByRole('button', { name: 'Envoyer un nouvel email' }).click(); - await expect(page.getByText('Un nouvel email de renouvellement vous a été adressé')).toBeVisible(); + await expect( + page.getByText('Un nouvel email de renouvellement vous a été adressé') + ).toBeVisible(); await page.getByRole('button', { name: 'Envoyer un nouvel email' }).click(); await expect(page.getByText('Attendez')).toBeVisible(); @@ -114,10 +115,7 @@ test.describe('Tchap : account expiration', () => { await expect(page.getByRole('heading', { name: 'Votre compte Tchap a été' })).toBeVisible(); await page.getByRole('button', { name: 'Rafraîchir la page' }).click(); - await page.waitForSelector(".mx_MatrixChat", { timeout: 20000 }); + await page.waitForSelector('.mx_MatrixChat', { timeout: 20000 }); await expect(page.getByRole('heading', { name: 'Votre compte Tchap a été' })).not.toBeVisible(); - - }); - -}); \ No newline at end of file +}); diff --git a/tests/minimal/minimal-scenario.spec.ts b/tests/minimal/minimal-scenario.spec.ts index 06a24a7..e818ddd 100644 --- a/tests/minimal/minimal-scenario.spec.ts +++ b/tests/minimal/minimal-scenario.spec.ts @@ -1,81 +1,56 @@ - -import { test, expect } from "../../fixtures/auth-fixture"; -import { generateRoomName, generateTestUserData, openCreateAccountLegacyLink, openResetPasswordEmailLegacy, } from "../../utils/auth-helpers"; -import { ELEMENT_URL, INVITED_EMAIL_DOMAIN, STANDARD_EMAIL_DOMAIN, USE_MAS } from "../../utils/config"; -import { getLatestVerificationCode, waitForMessage } from "../../utils/mailpit"; -import path from "path"; +import { test, expect } from '../../fixtures/auth-fixture'; +import { + generateRoomName, + generateTestUserData, + openResetPasswordEmail, +} from '../../utils/auth-helpers'; +import { + ELEMENT_URL, + INVITED_EMAIL_DOMAIN, + STANDARD_EMAIL_DOMAIN, + USE_MAS, +} from '../../utils/config'; +import { getLatestVerificationCode, waitForMessage } from '../../utils/mailpit'; +import path from 'path'; //this scenario is one big test to cover all the scenario on a not MAS synapse (dev02 - a) and one MAS synapse (ext01 - e) +test.describe + .serial('Minimal scenario', () => { + test.setTimeout(120_000); - - - -test.describe.serial("Minimal scenario", () => { - - test.setTimeout(180_000); - - const external_user = generateTestUserData(INVITED_EMAIL_DOMAIN); - const agent_user = generateTestUserData(STANDARD_EMAIL_DOMAIN); -/* - * tested: - * creer compte agent - * creer salon privé - * creer forum public - * creer salon privé ouverts aux externes - * inviter un externe - * envoyer fichier, fichier vérolé - * activer la sauvegarde - * vérifier qu'un externe ne peut pas créer de salon, ne peut pas chercher un forum - * se déconnecter - * reset mot de passe - * TODO : A. exporter les participants de la room - * TODO : A. expirer le compte, vérifier que les clients affichent un truc cohérent, - */ - - - - test("test all", async ({ - page, - context, - screenChecker, - browser - }) => { - - - - const invitee1_search_name = "olivier test1";// TODO : ensure that invitee exists in the environment - const invitee1_display_name = "Olivier Test1";// TODO : ensure that invitee exists in the environment - - const invitee2_email = "testeur@agent2.tchap.incubateur.net"; // TODO : ensure that invitee exists in the environment - const invitee2_display_name = "Testeur [Incubateur]";// TODO : ensure that invitee exists in the environment - - const public_room_name = generateRoomName("Forum"); - const room_name = generateRoomName("Salon Privé_"); - - // Grant clipboard permissions to browser context - await context.grantPermissions(["clipboard-read", "clipboard-write"]); - - //creer compte agent without MAS - if(!USE_MAS){ - console.log("do not use MAS") - - await page.goto(ELEMENT_URL); - await page.getByRole('link', { name: 'Créer un compte' }).click(); - await page.getByRole('textbox', { name: 'Votre adresse mail' }).fill(agent_user.email); - await page.getByRole('button', { name: 'Continuer' }).click(); - await page.getByRole('textbox', { name: 'Adresse mail' }).fill(agent_user.email); - await page.getByRole('textbox', { name: 'Mot de passe', exact: true }).fill(agent_user.password); - await page.getByRole('textbox', { name: 'Confirmer le mot de passe' }).fill(agent_user.password); - await page.getByRole('button', { name: 'S’inscrire' }).click(); - - //cliquer sur le lien du mail pour valider l'inscription - const tab = await openCreateAccountLegacyLink(context, screenChecker, agent_user.email); - await expect(tab.getByText('Votre email a été validé')).toBeVisible(); - await tab.close(); - }else{ - console.log("use MAS") - + const external_user = generateTestUserData(INVITED_EMAIL_DOMAIN); + const agent_user = generateTestUserData(STANDARD_EMAIL_DOMAIN); + /* + * tested: + * creer compte agent + * creer salon privé + * creer forum public + * creer salon privé ouverts aux externes + * inviter un externe + * envoyer fichier, fichier vérolé + * activer la sauvegarde + * vérifier qu'un externe ne peut pas créer de salon, ne peut pas chercher un forum + * se déconnecter + * reset mot de passe + * TODO : A. exporter les participants de la room + * TODO : A. expirer le compte, vérifier que les clients affichent un truc cohérent, + */ + + test('test all', async ({ page, context, screenChecker, browser }) => { + const invitee1_search_name = 'olivier test1'; // TODO : ensure that invitee exists in the environment + const invitee1_display_name = 'Olivier Test1'; // TODO : ensure that invitee exists in the environment + + const invitee2_email = 'testeur@agent2.tchap.incubateur.net'; // TODO : ensure that invitee exists in the environment + const invitee2_display_name = 'Testeur [Incubateur]'; // TODO : ensure that invitee exists in the environment + + const public_room_name = generateRoomName('Forum'); + const room_name = generateRoomName('Salon Privé_'); + + // Grant clipboard permissions to browser context + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + + //create account await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'load' }); await screenChecker(page, '#/welcome'); await page.getByRole('link').filter({ hasText: 'Créer un compte' }).click(); @@ -88,231 +63,249 @@ test.describe.serial("Minimal scenario", () => { await page.getByRole('button').filter({ hasText: 'Continuer avec mon adresse mail' }).click(); await screenChecker(page, '/register/password'); await expect(page.locator('input[name="email"]')).toHaveValue(agent_user.email); - + await page.locator('input[name="password"]').fill(agent_user.password); await page.locator('input[name="password_confirm"]').fill(agent_user.password); //wait for password-confirm matching confirmation - await page.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field + await page.locator('body').click({ position: { x: 0, y: 0 } }); //unfocus field //await expect(page.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click({clickCount:3}); //2 clicks works better than one + await page.getByRole('button').filter({ hasText: 'Continuer' }).click({ clickCount: 3 }); //2 clicks works better than one await screenChecker(page, '/verify-email'); - let verificationCode = await getLatestVerificationCode(agent_user.email); - await page.locator('input[name="code"]').fill(verificationCode); + const verificationCode3 = await getLatestVerificationCode(agent_user.email); + await page.locator('input[name="code"]').fill(verificationCode3); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); await screenChecker(page, '/consent'); await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - } - - - //await expect(page.locator('text=Bienvenue')).toBeVisible({timeout: 20000}); - await page.waitForSelector(".mx_MatrixChat", { timeout: 20000 }); - - //configurer la sauvegarde - await screenChecker(page, "#/home") - - //configurer la sauvegarde (only possible at first connexion) - await page.getByRole('button', { name: 'Avatar' }).click(); - await page.getByRole('tab', { name: 'Messages chiffrés' }).click(); - await page.getByRole('button', { name: 'Configurer la sauvegarde' }).click(); - await page.getByRole('button', { name: 'Continuer' }).click(); - await page.getByRole('button', { name: 'Copier et continuer' }).click(); - - const handle = await page.evaluateHandle(() => navigator.clipboard.readText()); - const clipboardContent = await handle.jsonValue(); - console.log(`verification code : ${clipboardContent}`); - const verificationCode = clipboardContent; - - //fill the recupration code from the clipboard content - await page.getByRole('textbox', { name: 'Saisir le code de vérification' }).fill(verificationCode); - await page.getByRole('button', { name: 'Terminer la configuration' }).click(); - await page.getByRole('button', { name: 'Terminé' }).click(); - await page.getByRole('button', { name: 'Fermer la boîte de dialogue' }).click(); - - await screenChecker(page, "#/home") - - //await page.getByRole('button', { name: 'OK' }).click(); - //await page.getByRole('button', { name: 'OK' }).click(); - - - //creer salon public - await page.getByRole("button", { name: "Ajouter", exact: true }).click(); - await page.getByRole("menuitem", { name: "Nouveau salon", exact: true }).click(); - const dialog = page.locator(".tc_TchapCreateRoomDialog"); - await page.getByRole('textbox', { name: 'Nom' }).fill(public_room_name); - await dialog - .locator(".tc_TchapRoomTypeSelector_RadioButton_title") - .getByText("Forum") - .click(); - await dialog.getByRole("button", { name: "Créer un nouveau salon" }).click(); - await expect(page.locator('button').filter({ hasText: public_room_name })).toBeVisible(); - - //ecrire dans le salon public - await page.locator('.mx_BasicMessageComposer') - .getByRole('textbox') - .fill('message non chiffré'); - await page.getByRole('button', { name: 'Envoyer le message' }).click(); - await expect(page.getByRole('status', { name: 'Votre message a été envoyé' })).toBeVisible(); - - - - //chercher salon public - /* - bug in preprod - await page.getByRole("button", { name: "Ajouter", exact: true }).click(); - await page.getByRole("menuitem", { name: "Rejoindre un forum", exact: true }).click(); - await page.getByRole('textbox', { name: 'Rechercher' }).fill(public_room_name); - await expect(page.getByLabel('Suggestions').getByText(public_room_name)).toBeVisible(); - await page.getByRole('textbox', { name: 'Rechercher' }).press('Escape'); - */ - - //creer salon privé - await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); - await page.getByText('Nouveau salon').click(); - await page.getByRole('textbox', { name: 'Nom' }).fill(room_name); - await page.getByRole('button', { name: 'Créer un nouveau salon' }).click(); - - //ecrire dans le salon privé - await page.locator('.mx_BasicMessageComposer') - .getByRole('textbox') - .fill('message chiffré'); - await page.getByRole('button', { name: 'Envoyer le message' }).click(); - await expect(page.getByRole('status', { name: 'Votre message a été envoyé' })).toBeVisible(); - - //vérfier les parametres du salon privé - await page.locator('button').filter({ hasText: room_name }).click(); - await page.getByRole('menuitem', { name: 'Paramètres' }).click(); - await page.getByText('Vie privée').click(); - await expect(page.getByRole('switch', { name: 'Chiffré' })).toBeDisabled(); - await page.getByRole('button', { name: 'Fermer la boîte de dialogue' }).click(); - - //inviter agents by name - await page.getByRole('button', { name: 'Personnes' }).click(); - await page.getByRole('button', { name: 'Inviter', exact: true }).click(); - await page.getByRole('textbox', { name: 'Rechercher' }).fill(invitee1_search_name); - await page.getByText(invitee1_display_name).first().click(); - await page.getByRole('button', { name: 'Inviter' }).click(); - await expect(page.getByTestId('virtuoso-item-list').getByText(invitee1_display_name)).toBeVisible(); - - //inviter agents by email - await page.getByRole('button', { name: 'Inviter dans ce salon' }).click(); - await page.getByRole('textbox', { name: 'Rechercher' }).fill(invitee2_email); - await page.getByRole('button', { name: 'Inviter' }).click(); - await expect(page.getByTestId('virtuoso-item-list').getByText(invitee2_display_name)).toBeVisible(); - - //envoyer fichier png - await page - .locator(".mx_MessageComposer_actions input[type='file']") - .setInputFiles(path.join(__dirname, "../../sample-files/element.png")); - - await page.getByRole("button", { name: "Envoyer" }).click() - await page.getByRole('link', { name: 'element.png' }).click(); - await page.getByRole('button', { name: 'Fermer' }).click(); - - //envoyer fichier vérolé - await page - .locator(".mx_MessageComposer_actions input[type='file']") - .setInputFiles(path.join(__dirname, "../../sample-files/eicar.com")); - await page.getByRole('button', { name: 'Envoyer' }).click(); - await page.getByRole('listitem').filter({ hasText: /^Contenu bloqué$/ }) - - //creer salon privé ouvert aux externes - await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); - await page.getByText('Nouveau salon').click(); - await page.getByLabel('Créer un salon').click(); - await page.getByRole('radio', { name: 'Salon ouvert aux externes' }); - await page.getByRole('textbox', { name: 'Nom' }).fill('Salon ouvert aux externes'); - await page.getByRole('button', { name: 'Créer un nouveau salon' }).click(); - - //inviter agent externe - await page.getByRole('button', { name: 'Personnes' }).click(); - await page.getByRole('button', { name: 'Inviter dans ce salon' }).click(); - await page.getByRole('textbox', { name: 'Rechercher' }).fill(external_user.email); - await page.getByRole('button', { name: 'Inviter' }).click(); - //await expect(page.getByTestId('virtuoso-item-list').getByText(external_user.username)).toBeVisible(); - - //disconnect - await page.getByRole('button', { name: 'Avatar' }).click(); - await page.getByRole('button', { name: 'Se déconnecter' }).click(); - await page.locator('#mx_Dialog_Container').getByRole('button', { name: 'Se déconnecter' }).click(); - //reset passsword - await page.getByRole('link', { name: 'Se connecter' }).click(); - await page.getByRole('textbox', { name: 'Votre adresse mail' }).fill(agent_user.email); - await page.getByRole('button', { name: 'Continuer' }).click(); - await page.getByRole('button', { name: 'Mot de passe oublié ?' }).click(); - await page.getByRole('textbox', { name: 'Adresse mail' }).fill(agent_user.email); - await page.getByRole('button', { name: 'Envoyer le mail' }).click(); - - await openResetPasswordEmailLegacy(context, screenChecker, agent_user.email); - - const new_password = agent_user.password + '4'; - await page.getByRole('button', { name: 'Suivant' }).click(); - await page.getByRole('textbox', { name: 'Nouveau mot de passe', exact: true }).fill(new_password); - await page.getByRole('textbox', { name: 'Confirmer le nouveau mot de' }).fill(new_password); - await page.getByRole('button', { name: 'Réinitialiser le mot de passe' }).click(); - await page.getByRole('button', { name: 'Retourner à l’écran de' }).click();//back to login - - - const context_ext = await browser.newContext(); - const page_ext = await context_ext.newPage(); - - //invitation takes time to be available - const {message, content} = await waitForMessage(external_user.email , 20000, "Invitation"); - - //register user on ext01 (with MAS) - await page_ext.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'load' }); - await screenChecker(page_ext, '#/welcome'); - await page_ext.getByRole('link').filter({ hasText: 'Créer un compte' }).click(); - - await screenChecker(page_ext, '#/email-precheck-sso'); - await page_ext.locator('input').fill(external_user.email); - await page_ext.getByRole('button').filter({ hasText: 'Continuer' }).click(); - - await screenChecker(page_ext, '/register'); - await page_ext.getByRole('button').filter({ hasText: 'Continuer avec mon adresse mail' }).click(); - - await expect(page_ext.locator('input[name="email"]')).toHaveValue(external_user.email); - - await page_ext.locator('input[name="password"]').fill(external_user.password); - await page_ext.locator('input[name="password_confirm"]').fill(external_user.password); - - //wait for password-confirm matching confirmation - await page_ext.locator("body").click({ position: { x: 0, y: 0 } }); //unfocus field - await expect(page_ext.locator('span').filter({ hasText: 'Les mots de passe correspondent.' })).toBeVisible(); - await page_ext.getByRole('button').filter({ hasText: 'Continuer' }).click({clickCount:2}); //2 clicks works better than one - - const verificationCode2 = await getLatestVerificationCode(external_user.email); - await page_ext.locator('input[name="code"]').fill(verificationCode2); - await page_ext.getByRole('button').filter({ hasText: 'Continuer' }).click(); - await page_ext.getByRole('button').filter({ hasText: 'Continuer' }).click(); - - await page_ext.waitForSelector(".mx_MatrixChat", { timeout: 20000 }); - - //rejoindre le salon - await page_ext.locator('div').filter({ hasText: /^Salon ouvert aux externes$/ }).first().click(); - await page_ext.getByRole('button', { name: 'Accepter' }).click(); - await expect(await page_ext.getByText('a créé ce salon. C’est le début de')).toBeVisible(); - - //ne peut pas créer de salon - await page_ext.getByRole('button', { name: 'Ajouter', exact: true }).click(); - await page_ext.getByText('Nouveau salon').click(); - await page_ext.getByRole('textbox', { name: 'Nom' }).fill('test'); - await screenChecker(page_ext, '#/room') - await page_ext.getByRole('button', { name: 'Créer un nouveau salon' }).click(); - await screenChecker(page_ext, '#/room') - await expect(await page_ext.getByText('En tant qu\'invité externe,')).toBeVisible(); - await page_ext.getByRole('button', { name: 'OK' }).click(); - - - //ne peut pas trouver le salon public - /* - await page_ext.getByRole('button', { name: 'Ajouter', exact: true }).click(); - await page_ext.getByRole('menuitem', { name: 'Rejoindre un forum', exact: true }).click(); - await page_ext.getByRole('textbox', { name: 'Rechercher' }).fill(public_room_name); - await expect(page_ext.getByLabel('Suggestions').getByText(public_room_name)).toHaveCount(0); - await page_ext.getByRole('textbox', { name: 'Rechercher' }).press('Escape'); - */ - }) -}) \ No newline at end of file + + //await expect(page.locator('text=Bienvenue')).toBeVisible({timeout: 20000}); + await page.waitForSelector('.mx_MatrixChat', { timeout: 20000 }); + + //configurer la sauvegarde + await screenChecker(page, '#/home'); + + //configurer la sauvegarde (only possible at first connexion) + await page.getByRole('button', { name: 'Avatar' }).click(); + await page.getByRole('tab', { name: 'Messages chiffrés' }).click(); + await page.getByRole('button', { name: 'Configurer la sauvegarde' }).click(); + await page.getByRole('button', { name: 'Continuer' }).click(); + await page.getByRole('button', { name: 'Copier et continuer' }).click(); + + const handle = await page.evaluateHandle(() => navigator.clipboard.readText()); + const clipboardContent = await handle.jsonValue(); + console.log(`verification code : ${clipboardContent}`); + const verificationCode = clipboardContent; + + //fill the recupration code from the clipboard content + await page + .getByRole('textbox', { name: 'Saisir le code de vérification' }) + .fill(verificationCode); + await page.getByRole('button', { name: 'Terminer la configuration' }).click(); + await page.getByRole('button', { name: 'Terminé' }).click(); + await page.getByRole('button', { name: 'Fermer la boîte de dialogue' }).click(); + + await screenChecker(page, '#/home'); + + //await page.getByRole('button', { name: 'OK' }).click(); + //await page.getByRole('button', { name: 'OK' }).click(); + + //creer salon public + await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Nouveau salon', exact: true }).click(); + const dialog = page.locator('.tc_TchapCreateRoomDialog'); + await page.getByRole('textbox', { name: 'Nom' }).fill(public_room_name); + await dialog + .locator('.tc_TchapRoomTypeSelector_RadioButton_title') + .getByText('Forum') + .click(); + await dialog.getByRole('button', { name: 'Créer un nouveau salon' }).click(); + await expect(page.locator('button').filter({ hasText: public_room_name })).toBeVisible(); + + //ecrire dans le salon public + await page + .locator('.mx_BasicMessageComposer') + .getByRole('textbox') + .fill('message non chiffré'); + await page.getByRole('button', { name: 'Envoyer le message' }).click(); + await expect(page.getByRole('status', { name: 'Votre message a été envoyé' })).toBeVisible(); + + //chercher salon public + await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Rejoindre un forum', exact: true }).click(); + await page.getByRole('textbox', { name: 'Rechercher' }).fill(public_room_name); + await expect(page.getByLabel('Suggestions').getByText(public_room_name)).toBeVisible(); + await page.getByRole('textbox', { name: 'Rechercher' }).press('Escape'); + + //creer salon privé + await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); + await page.getByText('Nouveau salon').click(); + await page.getByRole('textbox', { name: 'Nom' }).fill(room_name); + await page.getByRole('button', { name: 'Créer un nouveau salon' }).click(); + + //ecrire dans le salon privé + await page.locator('.mx_BasicMessageComposer').getByRole('textbox').fill('message chiffré'); + await page.getByRole('button', { name: 'Envoyer le message' }).click(); + await expect(page.getByRole('status', { name: 'Votre message a été envoyé' })).toBeVisible(); + + //vérfier les parametres du salon privé + await page.locator('button').filter({ hasText: room_name }).click(); + await page.getByRole('menuitem', { name: 'Paramètres' }).click(); + await page.getByText('Vie privée').click(); + await expect(page.getByRole('radio', { name: 'salon privé' })).toBeChecked(); + await page.getByRole('button', { name: 'Fermer la boîte de dialogue' }).click(); + + //inviter agents by name + await page.getByRole('button', { name: 'Personnes' }).click(); + await page.getByRole('button', { name: 'Inviter', exact: true }).click(); + await page.getByRole('textbox', { name: 'Rechercher' }).fill(invitee1_search_name); + await page.getByText(invitee1_display_name).first().click(); + await page.getByRole('button', { name: 'Inviter' }).click(); + await expect( + page.getByTestId('virtuoso-item-list').getByText(invitee1_display_name) + ).toBeVisible(); + + //inviter agents by email + await page.getByRole('button', { name: 'Inviter dans ce salon' }).click(); + await page.getByRole('textbox', { name: 'Rechercher' }).fill(invitee2_email); + await page.getByRole('button', { name: 'Inviter' }).click(); + await expect( + page.getByTestId('virtuoso-item-list').getByText(invitee2_display_name) + ).toBeVisible(); + + //envoyer fichier png + await page + .locator(".mx_MessageComposer_actions input[type='file']") + .setInputFiles(path.join(__dirname, '../../sample-files/element.png')); + + await page.getByRole('button', { name: 'Envoyer' }).click(); + await page.getByRole('link', { name: 'element.png' }).click(); + await page.getByRole('button', { name: 'Fermer' }).click(); + + //envoyer fichier vérolé + await page + .locator(".mx_MessageComposer_actions input[type='file']") + .setInputFiles(path.join(__dirname, '../../sample-files/eicar.com')); + await page.getByRole('button', { name: 'Envoyer' }).click(); + await page.getByRole('listitem').filter({ hasText: /^Contenu bloqué$/ }); + + //creer salon privé ouvert aux externes + await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); + await page.getByText('Nouveau salon').click(); + await page.getByLabel('Créer un salon').click(); + await page.getByRole('radio', { name: 'Salon ouvert aux externes' }); + await page.getByRole('textbox', { name: 'Nom' }).fill('Salon ouvert aux externes'); + await page.getByRole('button', { name: 'Créer un nouveau salon' }).click(); + + //inviter agent externe + await page.getByRole('button', { name: 'Personnes' }).click(); + await page.getByRole('button', { name: 'Inviter dans ce salon' }).click(); + await page.getByRole('textbox', { name: 'Rechercher' }).fill(external_user.email); + await page.getByRole('button', { name: 'Inviter' }).click(); + //await expect(page.getByTestId('virtuoso-item-list').getByText(external_user.username)).toBeVisible(); + await expect( + page.getByRole('option', { name: external_user.email.split('@')[0] }) + ).toBeVisible(); //just the localpart of the emailawait page.getByRole('option', { name: 'user.local_1775742819448_6984' }).click(); + + //disconnect + await page.getByRole('button', { name: 'Avatar' }).click(); + await page.getByRole('button', { name: 'Se déconnecter' }).click(); + await page + .locator('#mx_Dialog_Container') + .getByRole('button', { name: 'Se déconnecter' }) + .click(); + //reset passsword + await page.getByRole('link', { name: 'Se connecter' }).click(); + await page.getByRole('textbox', { name: 'Votre adresse mail' }).fill(agent_user.email); + await page.getByRole('button', { name: 'Continuer' }).click(); + await page.getByRole('link', { name: 'Mot de passe oublié' }).click(); + await page.getByRole('textbox', { name: 'Adresse mail' }).fill(agent_user.email); + await page.getByRole('button', { name: 'Continuer' }).click(); + + //await openResetPasswordEmailLegacy(context, screenChecker, agent_user.email); + const resetPwdPage = await openResetPasswordEmail(context, screenChecker, agent_user.email); + + const newPassword = agent_user.password + '4'; + await resetPwdPage.locator('input[name="new_password"]').fill(newPassword); + await resetPwdPage.locator('input[name="new_password_again"]').fill(newPassword); + await resetPwdPage.locator('body').click({ position: { x: 0, y: 0 } }); //unfocus field + await expect( + resetPwdPage.locator('span').filter({ hasText: 'Les mots de passe correspondent.' }) + ).toBeVisible(); + await resetPwdPage + .getByRole('button') + .filter({ hasText: 'Sauvegarder et continuer' }) + .click({ clickCount: 2 }); + + //new tab is redirected back to MAS welcome page + await expect( + resetPwdPage.getByRole('link').filter({ hasText: 'Continuer dans Tchap' }) + ).toBeVisible(); + + const context_ext = await browser.newContext(); + const page_ext = await context_ext.newPage(); + + //invitation takes time to be available + const { message, content } = await waitForMessage(external_user.email, 40000, 'Invitation'); + console.log('Invitation received for user, but sydent is a bit slow, so wait 10 seconds'); + await page_ext.waitForTimeout(10000); + + //register user on ext01 (with MAS) + await page_ext.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'load' }); + await screenChecker(page_ext, '#/welcome'); + await page_ext.getByRole('link').filter({ hasText: 'Créer un compte' }).click(); + + await screenChecker(page_ext, '#/email-precheck-sso'); + await page_ext.locator('input').fill(external_user.email); + await page_ext.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + await screenChecker(page_ext, '/register'); + await page_ext + .getByRole('button') + .filter({ hasText: 'Continuer avec mon adresse mail' }) + .click(); + + await expect(page_ext.locator('input[name="email"]')).toHaveValue(external_user.email); + + await page_ext.locator('input[name="password"]').fill(external_user.password); + await page_ext.locator('input[name="password_confirm"]').fill(external_user.password); + + //wait for password-confirm matching confirmation + await page_ext.locator('body').click({ position: { x: 0, y: 0 } }); //unfocus field + await expect( + page_ext.locator('span').filter({ hasText: 'Les mots de passe correspondent.' }) + ).toBeVisible(); + await page_ext.getByRole('button').filter({ hasText: 'Continuer' }).click({ clickCount: 2 }); //2 clicks works better than one + + await screenChecker(page_ext, '/verify-email'); + const verificationCode2 = await getLatestVerificationCode(external_user.email); + await page_ext.locator('input[name="code"]').fill(verificationCode2); + await page_ext.getByRole('button').filter({ hasText: 'Continuer' }).click(); + await page_ext.getByRole('button').filter({ hasText: 'Continuer' }).click(); + + await page_ext.waitForSelector('.mx_MatrixChat', { timeout: 20000 }); + + //rejoindre le salon + await page_ext + .locator('div') + .filter({ hasText: /^Salon ouvert aux externes$/ }) + .first() + .click(); + await page_ext.getByRole('button', { name: 'Accepter' }).click(); + await expect(await page_ext.getByText('a créé ce salon. C’est le début de')).toBeVisible(); + + //ne peut pas créer de salon + await page_ext.getByRole('button', { name: 'Ajouter', exact: true }).click(); + await page_ext.getByText('Nouveau salon').click(); + await page_ext.getByRole('textbox', { name: 'Nom' }).fill('test'); + await screenChecker(page_ext, '#/room'); + await page_ext.getByRole('button', { name: 'Créer un nouveau salon' }).click(); + await screenChecker(page_ext, '#/room'); + await expect(await page_ext.getByText("En tant qu'invité externe,")).toBeVisible(); + await page_ext.getByRole('button', { name: 'OK' }).click(); + + //ne peut pas trouver le salon public + //TODO + }); + }); diff --git a/tests/web/create-room.spec.ts b/tests/web/create-room.spec.ts index d60f710..768a7c7 100644 --- a/tests/web/create-room.spec.ts +++ b/tests/web/create-room.spec.ts @@ -1,140 +1,126 @@ -import { test, expect } from "../../fixtures/auth-fixture"; -import { env } from "../../utils/config"; +import { test, expect } from '../../fixtures/auth-fixture'; +import { env } from '../../utils/config'; -test.describe("Create Room", () => { - - test("should allow us to create a public room with name", async ({ - page, - authenticatedUser - }) => { +test.describe('Create Room', () => { + test('should allow us to create a public room with name', async ({ page, authenticatedUser }) => { // Listen for all console logs - page.on("console", (msg) => console.log(msg.text())); + page.on('console', (msg) => console.log(msg.text())); - const name = "Test room public 1"; + const name = 'Test room public 1'; - await page.getByRole("button", { name: "Ajouter", exact: true }).click(); + await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); - await page.getByRole("menuitem", { name: "Nouveau salon", exact: true }).click(); - const dialog = page.locator(".tc_TchapCreateRoomDialog"); + await page.getByRole('menuitem', { name: 'Nouveau salon', exact: true }).click(); + const dialog = page.locator('.tc_TchapCreateRoomDialog'); // Fill name - await dialog.getByRole("textbox").fill(name); + await dialog.getByRole('textbox').fill(name); // Select public room option - await dialog - .locator(".tc_TchapRoomTypeSelector_RadioButton_title") - .getByText("Forum") - .click(); + await dialog.locator('.tc_TchapRoomTypeSelector_RadioButton_title').getByText('Forum').click(); // Submit - await dialog.getByRole("button", { name: "Créer un nouveau salon" }).click(); + await dialog.getByRole('button', { name: 'Créer un nouveau salon' }).click(); // In local test An error dialog should appear first complaining about wss socket and SSL certificate error // So not really working locally - if (env == "local") { - await page.getByRole("button").getByText("OK").click(); + if (env == 'local') { + await page.getByRole('button').getByText('OK').click(); } else { // Takes some time to appear - await page.waitForSelector(".mx_NewRoomIntro", { timeout: 10000 }); + await page.waitForSelector('.mx_NewRoomIntro', { timeout: 10000 }); await expect(page).toHaveURL( - new RegExp( - `/#/room/#test-room-public-1:${authenticatedUser.homeServer}` - ) + new RegExp(`/#/room/#test-room-public-1:${authenticatedUser.homeServer}`) ); - const header = page.locator(".mx_RoomHeader"); + const header = page.locator('.mx_RoomHeader'); await expect(header).toContainText(name); - await expect(header).toHaveClass(".mx_DecoratedRoomAvatar_icon_forum"); + await expect(header).toHaveClass('.mx_DecoratedRoomAvatar_icon_forum'); } }); - test("should allow us to create a private room with name", async ({ + test('should allow us to create a private room with name', async ({ page, - authenticatedUser + authenticatedUser, }) => { - console.log("authenticatedUser", authenticatedUser); - const name = "Test room private 1"; + console.log('authenticatedUser', authenticatedUser); + const name = 'Test room private 1'; - await page.getByRole("button", { name: "Ajouter", exact: true }).click(); + await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); - await page.getByRole("menuitem", { name: "Nouveau salon", exact: true }).click(); - const dialog = page.locator(".tc_TchapCreateRoomDialog"); + await page.getByRole('menuitem', { name: 'Nouveau salon', exact: true }).click(); + const dialog = page.locator('.tc_TchapCreateRoomDialog'); // Fill name - await dialog.getByRole("textbox").fill(name); + await dialog.getByRole('textbox').fill(name); // Select public room option await dialog - .locator(".tc_TchapRoomTypeSelector_RadioButton_title") - .getByText("Salon", { exact: true }) + .locator('.tc_TchapRoomTypeSelector_RadioButton_title') + .getByText('Salon', { exact: true }) .click(); // Submit - await dialog.getByRole("button", { name: "Créer un nouveau salon" }).click(); + await dialog.getByRole('button', { name: 'Créer un nouveau salon' }).click(); // In local test An error dialog should appear first complaining about wss socket and SSL certificate error // So not really working locally - if (env == "local") { - await page.getByRole("button").getByText("OK").click(); + if (env == 'local') { + await page.getByRole('button').getByText('OK').click(); } else { // Takes some time - await page.waitForSelector(".mx_NewRoomIntro", { timeout: 10000 }); + await page.waitForSelector('.mx_NewRoomIntro', { timeout: 10000 }); await expect(page).toHaveURL( - new RegExp( - `/#/room/#test-room-private-1:${authenticatedUser.homeServer}` - ) + new RegExp(`/#/room/#test-room-private-1:${authenticatedUser.homeServer}`) ); - const header = page.locator(".mx_RoomHeader"); + const header = page.locator('.mx_RoomHeader'); await expect(header).toContainText(name); - await expect(header).toHaveClass(".mx_DecoratedRoomAvatar_icon_private"); + await expect(header).toHaveClass('.mx_DecoratedRoomAvatar_icon_private'); } }); - - test("should allow us to create a private room with external with name", async ({ + + test('should allow us to create a private room with external with name', async ({ page, - authenticatedUser + authenticatedUser, }) => { - console.log("authenticatedUser", authenticatedUser); - const name = "Test room private external 1"; + console.log('authenticatedUser', authenticatedUser); + const name = 'Test room private external 1'; - await page.getByRole("button", { name: "Ajouter", exact: true }).click(); + await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); - await page.getByRole("menuitem", { name: "Nouveau salon", exact: true }).click(); - const dialog = page.locator(".tc_TchapCreateRoomDialog"); + await page.getByRole('menuitem', { name: 'Nouveau salon', exact: true }).click(); + const dialog = page.locator('.tc_TchapCreateRoomDialog'); // Fill name - await dialog.getByRole("textbox").fill(name); + await dialog.getByRole('textbox').fill(name); // Select public room option await dialog - .locator(".tc_TchapRoomTypeSelector_RadioButton_title") - .getByText("Salon ouvert aux externes") + .locator('.tc_TchapRoomTypeSelector_RadioButton_title') + .getByText('Salon ouvert aux externes') .click(); // Submit - await dialog.getByRole("button", { name: "Créer un nouveau salon" }).click(); + await dialog.getByRole('button', { name: 'Créer un nouveau salon' }).click(); // In local test An error dialog should appear first complaining about wss socket and SSL certificate error // So not really working locally - if (env == "local") { - await page.getByRole("button").getByText("OK").click(); + if (env == 'local') { + await page.getByRole('button').getByText('OK').click(); } else { // Takes some time - await page.waitForSelector(".mx_NewRoomIntro", { timeout: 10000 }); + await page.waitForSelector('.mx_NewRoomIntro', { timeout: 10000 }); await expect(page).toHaveURL( - new RegExp( - `/#/room/#test-room-private-external-1:${authenticatedUser.homeServer}` - ) + new RegExp(`/#/room/#test-room-private-external-1:${authenticatedUser.homeServer}`) ); - const header = page.locator(".mx_RoomHeader"); + const header = page.locator('.mx_RoomHeader'); await expect(header).toContainText(name); - await expect(header).toHaveClass(".mx_DecoratedRoomAvatar_icon_external"); + await expect(header).toHaveClass('.mx_DecoratedRoomAvatar_icon_external'); } }); }); - diff --git a/utils/TchapAppPage.ts b/utils/TchapAppPage.ts index 07cf6d5..60dcafc 100644 --- a/utils/TchapAppPage.ts +++ b/utils/TchapAppPage.ts @@ -6,186 +6,186 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { type Locator, type Page, expect } from "@playwright/test"; +import { type Locator, type Page, expect } from '@playwright/test'; /** * A set of utility methods for interacting with the Element-Web UI. */ export class TchapAppPage { - public constructor(public readonly page: Page) {} - - /** - * Open room creation dialog. - */ - public async openCreateRoomDialog(): Promise { - await this.page.getByRole("button", { name: "Add room", exact: true }).click(); - await this.page.getByRole("menuitem", { name: "New room", exact: true }).click(); - return this.page.locator(".mx_CreateRoomDialog"); - } - - public async getClipboard(): Promise { - return await this.page.evaluate(() => navigator.clipboard.readText()); - } - - /** - * Opens the given room by name. The room must be visible in the - * room list, but the room list may be folded horizontally, and the - * room may contain unread messages. - * - * @param name The exact room name to find and click on/open. - */ - public async viewRoomByName(name: string): Promise { - // We look for the room inside the room list, which is a tree called Rooms. - // - // There are 3 cases: - // - the room list is folded: - // then the aria-label on the room tile is the name (with nothing extra) - // - the room list is unfolder and the room has messages: - // then the aria-label contains the unread count, but the title of the - // div inside the titleContainer equals the room name - // - the room list is unfolded and the room has no messages: - // then the aria-label is the name and so is the title of a div - // - // So by matching EITHER title=name OR aria-label=name we find this exact - // room in all three cases. - return this.page - .getByRole("tree", { name: "Rooms" }) - .locator(`[title="${name}"],[aria-label="${name}"]`) - .first() - .click(); - } - - public async viewRoomById(roomId: string): Promise { - await this.page.goto(`/#/room/${roomId}`); - } - - /** - * Get the composer element - * @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer - */ - public getComposer(isRightPanel?: boolean): Locator { - const panelClass = isRightPanel ? ".mx_RightPanel" : ".mx_RoomView_body"; - return this.page.locator(`${panelClass} .mx_MessageComposer`); - } - - /** - * Get the composer input field - * @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer - */ - public getComposerField(isRightPanel?: boolean): Locator { - return this.getComposer(isRightPanel).locator("div[contenteditable]"); - } - - /** - * Open the message composer kebab menu - * @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer - */ - public async openMessageComposerOptions(isRightPanel?: boolean): Promise { - const composer = this.getComposer(isRightPanel); - await composer.getByRole("button", { name: "More options", exact: true }).click(); - return this.page.getByRole("menu"); - } - - /** - * Returns the space panel space button based on a name. The space - * must be visible in the space panel - * @param name The space name to find - */ - public async getSpacePanelButton(name: string): Promise { - const button = this.page.getByRole("button", { name: name }); - await expect(button).toHaveClass(/mx_SpaceButton/); - return button; - } - - /** - * Opens the given space home by name. The space must be visible in - * the space list. - * @param name The space name to find and click on/open. - */ - public async viewSpaceHomeByName(name: string): Promise { - const button = await this.getSpacePanelButton(name); - return button.dblclick(); - } - - /** - * Opens the given space by name. The space must be visible in the - * space list. - * @param name The space name to find and click on/open. - */ - public async viewSpaceByName(name: string): Promise { - const button = await this.getSpacePanelButton(name); - return button.click(); - } - - /** - * Opens/closes the room info panel - * @returns locator to the right panel - */ - public async toggleRoomInfoPanel(): Promise { - await this.page.getByRole("button", { name: "Room info" }).first().click(); - return this.page.locator(".mx_RightPanel"); - } - - /** - * Opens/closes the memberlist panel - * @returns locator to the memberlist panel - */ - public async toggleMemberlistPanel(): Promise { - const locator = this.page.locator(".mx_FacePile"); - await locator.click(); - const memberlist = this.page.locator(".mx_MemberListView"); - await memberlist.waitFor(); - return memberlist; - } - - /** - * Get a locator for the tooltip associated with an element - * @param e The element with the tooltip - * @returns Locator to the tooltip - */ - public async getTooltipForElement(e: Locator): Promise { - const [labelledById, describedById] = await Promise.all([ - e.getAttribute("aria-labelledby"), - e.getAttribute("aria-describedby"), - ]); - if (!labelledById && !describedById) { - throw new Error( - "Element has no aria-labelledby or aria-describedy attributes! The tooltip should have added either one of these.", - ); - } - return this.page.locator(`id=${labelledById ?? describedById}`); - } - - /** - * Close the notification toast - */ - public closeNotificationToast(): Promise { - // Dismiss "Notification" toast - return this.page - .locator(".mx_Toast_toast", { hasText: "Notifications" }) - .getByRole("button", { name: "Dismiss" }) - .click(); - } - - /** - * Scroll an infinite list to the bottom. - * @param list The element to scroll - */ - public async scrollListToBottom(list: Locator): Promise { - // First hover the mouse over the element that we want to scroll - await list.hover(); - - const needsScroll = async () => { - // From https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled - const fullyScrolled = await list.evaluate( - (e) => Math.abs(e.scrollHeight - e.clientHeight - e.scrollTop) <= 1, - ); - return !fullyScrolled; - }; - - // Scroll the element until we detect that it is fully scrolled - do { - await this.page.mouse.wheel(0, 1000); - } while (await needsScroll()); + public constructor(public readonly page: Page) {} + + /** + * Open room creation dialog. + */ + public async openCreateRoomDialog(): Promise { + await this.page.getByRole('button', { name: 'Add room', exact: true }).click(); + await this.page.getByRole('menuitem', { name: 'New room', exact: true }).click(); + return this.page.locator('.mx_CreateRoomDialog'); + } + + public async getClipboard(): Promise { + return await this.page.evaluate(() => navigator.clipboard.readText()); + } + + /** + * Opens the given room by name. The room must be visible in the + * room list, but the room list may be folded horizontally, and the + * room may contain unread messages. + * + * @param name The exact room name to find and click on/open. + */ + public async viewRoomByName(name: string): Promise { + // We look for the room inside the room list, which is a tree called Rooms. + // + // There are 3 cases: + // - the room list is folded: + // then the aria-label on the room tile is the name (with nothing extra) + // - the room list is unfolder and the room has messages: + // then the aria-label contains the unread count, but the title of the + // div inside the titleContainer equals the room name + // - the room list is unfolded and the room has no messages: + // then the aria-label is the name and so is the title of a div + // + // So by matching EITHER title=name OR aria-label=name we find this exact + // room in all three cases. + return this.page + .getByRole('tree', { name: 'Rooms' }) + .locator(`[title="${name}"],[aria-label="${name}"]`) + .first() + .click(); + } + + public async viewRoomById(roomId: string): Promise { + await this.page.goto(`/#/room/${roomId}`); + } + + /** + * Get the composer element + * @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer + */ + public getComposer(isRightPanel?: boolean): Locator { + const panelClass = isRightPanel ? '.mx_RightPanel' : '.mx_RoomView_body'; + return this.page.locator(`${panelClass} .mx_MessageComposer`); + } + + /** + * Get the composer input field + * @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer + */ + public getComposerField(isRightPanel?: boolean): Locator { + return this.getComposer(isRightPanel).locator('div[contenteditable]'); + } + + /** + * Open the message composer kebab menu + * @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer + */ + public async openMessageComposerOptions(isRightPanel?: boolean): Promise { + const composer = this.getComposer(isRightPanel); + await composer.getByRole('button', { name: 'More options', exact: true }).click(); + return this.page.getByRole('menu'); + } + + /** + * Returns the space panel space button based on a name. The space + * must be visible in the space panel + * @param name The space name to find + */ + public async getSpacePanelButton(name: string): Promise { + const button = this.page.getByRole('button', { name: name }); + await expect(button).toHaveClass(/mx_SpaceButton/); + return button; + } + + /** + * Opens the given space home by name. The space must be visible in + * the space list. + * @param name The space name to find and click on/open. + */ + public async viewSpaceHomeByName(name: string): Promise { + const button = await this.getSpacePanelButton(name); + return button.dblclick(); + } + + /** + * Opens the given space by name. The space must be visible in the + * space list. + * @param name The space name to find and click on/open. + */ + public async viewSpaceByName(name: string): Promise { + const button = await this.getSpacePanelButton(name); + return button.click(); + } + + /** + * Opens/closes the room info panel + * @returns locator to the right panel + */ + public async toggleRoomInfoPanel(): Promise { + await this.page.getByRole('button', { name: 'Room info' }).first().click(); + return this.page.locator('.mx_RightPanel'); + } + + /** + * Opens/closes the memberlist panel + * @returns locator to the memberlist panel + */ + public async toggleMemberlistPanel(): Promise { + const locator = this.page.locator('.mx_FacePile'); + await locator.click(); + const memberlist = this.page.locator('.mx_MemberListView'); + await memberlist.waitFor(); + return memberlist; + } + + /** + * Get a locator for the tooltip associated with an element + * @param e The element with the tooltip + * @returns Locator to the tooltip + */ + public async getTooltipForElement(e: Locator): Promise { + const [labelledById, describedById] = await Promise.all([ + e.getAttribute('aria-labelledby'), + e.getAttribute('aria-describedby'), + ]); + if (!labelledById && !describedById) { + throw new Error( + 'Element has no aria-labelledby or aria-describedy attributes! The tooltip should have added either one of these.' + ); } + return this.page.locator(`id=${labelledById ?? describedById}`); + } + + /** + * Close the notification toast + */ + public closeNotificationToast(): Promise { + // Dismiss "Notification" toast + return this.page + .locator('.mx_Toast_toast', { hasText: 'Notifications' }) + .getByRole('button', { name: 'Dismiss' }) + .click(); + } + + /** + * Scroll an infinite list to the bottom. + * @param list The element to scroll + */ + public async scrollListToBottom(list: Locator): Promise { + // First hover the mouse over the element that we want to scroll + await list.hover(); + + const needsScroll = async () => { + // From https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled + const fullyScrolled = await list.evaluate( + (e) => Math.abs(e.scrollHeight - e.clientHeight - e.scrollTop) <= 1 + ); + return !fullyScrolled; + }; + + // Scroll the element until we detect that it is fully scrolled + do { + await this.page.mouse.wheel(0, 1000); + } while (await needsScroll()); + } } diff --git a/utils/api.ts b/utils/api.ts index 7488428..e0d2599 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -5,117 +5,135 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ -import { type APIRequestContext } from "@playwright/test"; +import type { APIRequestContext } from '@playwright/test'; -export type Verb = "GET" | "POST" | "PUT" | "DELETE"; +export type Verb = 'GET' | 'POST' | 'PUT' | 'DELETE'; /** * A generic API client. */ export class Api { - private _request?: APIRequestContext; - - public constructor(private readonly baseUrl: string) {} - - /** - * Set the request context to use for making requests. - * @param request - The request context to use. - */ - public setRequest(request: APIRequestContext): void { - this._request = request; + private _request?: APIRequestContext; + + public constructor(private readonly baseUrl: string) {} + + /** + * Set the request context to use for making requests. + * @param request - The request context to use. + */ + public setRequest(request: APIRequestContext): void { + this._request = request; + } + + /** + * Make a request to the API. + * @param verb - The HTTP verb to use. + * @param path - The path to request. + * @param token - The access token to use for the request. + * @param data - The data to send with the request. + */ + public async request( + verb: 'GET', + path: string, + token?: string, + data?: never + ): Promise; + public async request( + verb: Verb, + path: string, + token?: string, + data?: object + ): Promise; + public async request( + verb: Verb, + path: string, + token?: string, + data?: object + ): Promise { + if (!this._request) { + throw new Error('No request context set'); } - /** - * Make a request to the API. - * @param verb - The HTTP verb to use. - * @param path - The path to request. - * @param token - The access token to use for the request. - * @param data - The data to send with the request. - */ - public async request(verb: "GET", path: string, token?: string, data?: never): Promise; - public async request(verb: Verb, path: string, token?: string, data?: object): Promise; - public async request(verb: Verb, path: string, token?: string, data?: object): Promise { - if (!this._request) { - throw new Error("No request context set"); - } - - const url = `${this.baseUrl}${path}`; - const res = await this._request.fetch(url, { - data, - method: verb, - headers: token - ? { - Authorization: `Bearer ${token}`, - } - : undefined, - }); - - if (!res.ok()) { - const json = await res.json(); - console.log("res.json", json); - throw new Error( - `Request to ${url} failed with status ${res.status()}: ${JSON.stringify(json)}`, - ); - } - - return res.json(); + const url = `${this.baseUrl}${path}`; + const res = await this._request.fetch(url, { + data, + method: verb, + headers: token + ? { + Authorization: `Bearer ${token}`, + } + : undefined, + }); + + if (!res.ok()) { + const json = await res.json(); + console.log('res.json', json); + throw new Error( + `Request to ${url} failed with status ${res.status()}: ${JSON.stringify(json)}` + ); } + + return res.json(); + } } /** * Credentials for a user. */ export interface Credentials { - /** The base URL of the homeserver's CS API. */ - homeserverBaseUrl: string; + /** The base URL of the homeserver's CS API. */ + homeserverBaseUrl: string; - accessToken: string; - userId: string; - deviceId: string; + accessToken: string; + userId: string; + deviceId: string; - /** The domain part of the user's matrix ID. */ - homeServer: string; + /** The domain part of the user's matrix ID. */ + homeServer: string; - password: string | null; // null for password-less users - username: string; // the localpart of the userId + password: string | null; // null for password-less users + username: string; // the localpart of the userId } /** * A client-server API for interacting with a Matrix homeserver. */ export class ClientServerApi extends Api { - public constructor(baseUrl: string, request: APIRequestContext) { - super(`${baseUrl}/_matrix/client`); - this.setRequest(request); - } - - /** - * Register a user on the homeserver. - * @param userId - The user ID to register. - * @param password - The password to use for the user. - */ - public async loginUser(userId: string, password: string): Promise> { - const json = await this.request<{ - access_token: string; - user_id: string; - device_id: string; - home_server: string; - }>("POST", "/v3/login", undefined, { - type: "m.login.password", - identifier: { - type: "m.id.user", - user: userId, - }, - password: password, - }); - - return { - password, - accessToken: json.access_token, - userId: json.user_id, - deviceId: json.device_id, - homeServer: json.home_server || json.user_id.split(":").slice(1).join(":"), - username: userId.slice(1).split(":")[0], - }; - } + public constructor(baseUrl: string, request: APIRequestContext) { + super(`${baseUrl}/_matrix/client`); + this.setRequest(request); + } + + /** + * Register a user on the homeserver. + * @param userId - The user ID to register. + * @param password - The password to use for the user. + */ + public async loginUser( + userId: string, + password: string + ): Promise> { + const json = await this.request<{ + access_token: string; + user_id: string; + device_id: string; + home_server: string; + }>('POST', '/v3/login', undefined, { + type: 'm.login.password', + identifier: { + type: 'm.id.user', + user: userId, + }, + password: password, + }); + + return { + password, + accessToken: json.access_token, + userId: json.user_id, + deviceId: json.device_id, + homeServer: json.home_server || json.user_id.split(':').slice(1).join(':'), + username: userId.slice(1).split(':')[0], + }; + } } diff --git a/utils/auth-helpers.ts b/utils/auth-helpers.ts index 660bc0b..db1b73b 100644 --- a/utils/auth-helpers.ts +++ b/utils/auth-helpers.ts @@ -1,15 +1,25 @@ -import { BrowserContext, expect, Frame, Page } from '@playwright/test'; +import { type BrowserContext, expect, Frame, type Page } from '@playwright/test'; import { createKeycloakUser, deleteKeycloakUser } from './keycloak-admin'; import { waitForMasUser, createMasUserWithPassword, deactivateMasUser } from './mas-admin'; -import { ELEMENT_URL, KEYCLOAK_URL, MAS_URL, SCREENSHOTS_DIR, TEST_USER_PASSWORD, TEST_USER_PREFIX } from './config'; -import { Credentials } from './api'; -import { ScreenCheckerFixture } from '../fixtures/auth-fixture'; -import { getCreateAccountLegacyLink, getExpirationAccountLink as getRenewAccountLink, getPasswordResetLink, getPasswordResetLinkLegacy } from './mailpit.js'; +import { + ELEMENT_URL, + KEYCLOAK_URL, + MAS_URL, + SCREENSHOTS_DIR, + TEST_USER_PASSWORD, + TEST_USER_PREFIX, +} from './config'; +import type { Credentials } from './api'; +import type { ScreenCheckerFixture } from '../fixtures/auth-fixture'; +import { + getExpirationAccountLink as getRenewAccountLink, + getPasswordResetLink, +} from './mailpit.js'; /** * Test user type */ export interface TestUser { - username: string; + username: string; //username in tchap in form generated from email email: string; password: string; keycloakId?: string; @@ -30,7 +40,7 @@ export enum TypeUser { /** * Create a test user in Keycloak */ -export async function createKeycloakTestUser(user:TestUser): Promise { +export async function createKeycloakTestUser(user: TestUser): Promise { const keycloakId = await createKeycloakUser(user.username, user.email, user.password); return { ...user, keycloakId }; } @@ -52,51 +62,71 @@ export async function cleanupKeycloakTestUser(user: TestUser): Promise { * 3. Fill in credentials on the Keycloak login page * 4. Wait for successful authentication and redirect back to MAS */ -export async function performOidcLogin(page: Page, user: TestUser, screenshot_path:string): Promise { +export async function performOidcLogin( + page: Page, + user: TestUser, + screenshot_path: string +): Promise { // Navigate to the login page await page.goto('/login'); - + // Take a screenshot of the login page - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-login-page.png`, fullPage:true }); - + await page.screenshot({ + path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-login-page.png`, + fullPage: true, + }); + // Find and click the OIDC provider button (adjust the selector as needed) // This is based on the login.html template which shows provider buttons //const oidcButton = page.locator('a.cpd-button[href*="/upstream/authorize/"]'); //catch proconnect button with class proconnect-button const oidcButton = page.locator('button.proconnect-button'); await oidcButton.click(); - + // Wait for navigation to Keycloak - await page.waitForURL(url => url.toString().includes(KEYCLOAK_URL)); - + await page.waitForURL((url) => url.toString().includes(KEYCLOAK_URL)); + // Take a screenshot of the Keycloak login page - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-keycloak-login.png`, fullPage:true }); - + await page.screenshot({ + path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-keycloak-login.png`, + fullPage: true, + }); + // Fill in the username and password await page.locator('#username').fill(user.username); await page.locator('#password').fill(user.password); - + // Click the login button await page.locator('button[type="submit"]').click(); - + // Wait for redirect back to MAS - await page.waitForURL(url => url.toString().includes(MAS_URL)); - + await page.waitForURL((url) => url.toString().includes(MAS_URL)); + // Take a screenshot after successful login - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-after-keycloak-login.png`, fullPage:true }); + await page.screenshot({ + path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-after-keycloak-login.png`, + fullPage: true, + }); } /** * Perform OIDC login starting from Element client */ -export async function performOidcLoginFromTchap(page: Page, user: TestUser, screenshot_path: string, tchap_legacy:boolean=false): Promise { - +export async function performOidcLoginFromTchap( + page: Page, + user: TestUser, + screenshot_path: string, + tchap_legacy: boolean = false +): Promise { //we go to the welcome and then to the login page because sometimes the email field disapears await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-tchap-login-page.png`, fullPage:true }); - - await page.getByRole('link').filter({hasText : "Se connecter"}).click(); + await page.screenshot({ + path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-tchap-login-page.png`, + fullPage: true, + }); + + await page.getByRole('link').filter({ hasText: 'Se connecter' }).click(); await page.locator('input').fill(user.email); @@ -104,35 +134,44 @@ export async function performOidcLoginFromTchap(page: Page, user: TestUser, scre await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); // Wait for navigation to MAS - await page.waitForURL(url => url.toString().includes(MAS_URL)); - + await page.waitForURL((url) => url.toString().includes(MAS_URL)); + // Take a screenshot of the MAS login page - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-mas-login-page.png`, fullPage:true }); - + await page.screenshot({ + path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-mas-login-page.png`, + fullPage: true, + }); + // Find and click the OIDC provider button //const oidcButton = page.locator('a.cpd-button[href*="/upstream/authorize/"]'); const oidcButton = page.locator('button.proconnect-button'); await oidcButton.click(); - + // Wait for navigation to Keycloak - await page.waitForURL(url => url.toString().includes(KEYCLOAK_URL)); - + await page.waitForURL((url) => url.toString().includes(KEYCLOAK_URL)); + // Take a screenshot of the Keycloak login page - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-keycloak-login.png`, fullPage:true }); - + await page.screenshot({ + path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-keycloak-login.png`, + fullPage: true, + }); + // Fill in the username and password await page.locator('#username').fill(user.username); await page.locator('#password').fill(user.password); - + // Click the login button await page.locator('button[type="submit"]').click(); - + // Wait for redirect back to MAS - await page.waitForURL(url => url.toString().includes(MAS_URL)); - + await page.waitForURL((url) => url.toString().includes(MAS_URL)); + // Take a screenshot after successful login - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-after-login.png` , fullPage:true }); + await page.screenshot({ + path: `${SCREENSHOTS_DIR}/${screenshot_path}/04-after-login.png`, + fullPage: true, + }); } /** @@ -146,7 +185,7 @@ export async function verifyUserInMas(user: TestUser): Promise { /** * Create a test user directly in MAS with password */ -export async function createMasTestUser(domain:string): Promise { +export async function createMasTestUser(domain: string): Promise { const user = generateTestUserData(domain); const masId = await createMasUserWithPassword(user.username, user.email, user.password); return { ...user, masId }; @@ -169,124 +208,107 @@ export async function cleanupMasTestUser(user: TestUser): Promise { * 3. Submit the form * 4. Wait for successful authentication */ -export async function performPasswordLogin(page: Page, user: TestUser, screenshot_path:string): Promise { +export async function performPasswordLogin( + page: Page, + user: TestUser, + screenshot_path: string +): Promise { console.log(`[Auth] Performing password login for user: ${user.username}`); - + // Navigate to the login page await page.goto('/login'); - + // Take a screenshot of the login page - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-password-login-page.png` }); - + await page.screenshot({ + path: `${SCREENSHOTS_DIR}/${screenshot_path}/01-password-login-page.png`, + }); + // Fill in the username and password await page.locator('input[name="username"]').fill(user.username); await page.locator('input[name="password"]').fill(user.password); - + // Take a screenshot before submitting - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-password-login-filled.png` }); - + await page.screenshot({ + path: `${SCREENSHOTS_DIR}/${screenshot_path}/02-password-login-filled.png`, + }); + // Click the login button (submit the form) await page.locator('button[type="submit"]').click(); - - // Wait for successful login (redirect to dashboard or home page) - await page.waitForURL(url => !url.toString().includes('/login')); - - // Take a screenshot after successful login - await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-password-login-success.png` }); - - console.log(`[Auth] Password login successful for user: ${user.username}`); -} -// open reset password screen from email -export async function openResetPasswordEmail(context: BrowserContext, screenChecker:ScreenCheckerFixture, userEmail: string): Promise { - // Use Mailpit API to get password reset link instead of browser automation - const resetLink = await getPasswordResetLink(userEmail); - - // Create a new page and navigate directly to the reset link - const resetPasswordPage = await context.newPage(); - await resetPasswordPage.goto(resetLink); + // Wait for successful login (redirect to dashboard or home page) + await page.waitForURL((url) => !url.toString().includes('/login')); - await screenChecker(resetPasswordPage, 'account/password/recovery'); + // Take a screenshot after successful login + await page.screenshot({ + path: `${SCREENSHOTS_DIR}/${screenshot_path}/03-password-login-success.png`, + }); - return resetPasswordPage; + console.log(`[Auth] Password login successful for user: ${user.username}`); } // open reset password screen from email -export async function openRenewAccountEmail(context: BrowserContext, screenChecker:ScreenCheckerFixture, userEmail: string): Promise { - // Use Mailpit API to get password reset link instead of browser automation - const renewLink = await getRenewAccountLink(userEmail); +export async function openResetPasswordEmail( + context: BrowserContext, + screenChecker: ScreenCheckerFixture, + userEmail: string +): Promise { + // Use Mailpit API to get password reset link instead of browser automation + const resetLink = await getPasswordResetLink(userEmail); - // Create a new page and navigate directly to the reset link - const resetPasswordPage = await context.newPage(); - await resetPasswordPage.goto(renewLink); + // Create a new page and navigate directly to the reset link + const resetPasswordPage = await context.newPage(); + await resetPasswordPage.goto(resetLink); - await screenChecker(resetPasswordPage, 'email_account_validity'); + await screenChecker(resetPasswordPage, 'account/password/recovery'); - return resetPasswordPage; + return resetPasswordPage; } - // open reset password screen from email -export async function openResetPasswordEmailLegacy(context: BrowserContext, screenChecker:ScreenCheckerFixture, userEmail: string): Promise { - // Use Mailpit API to get password reset link instead of browser automation - const resetLink = await getPasswordResetLinkLegacy(userEmail); - - // Create a new page and navigate directly to the reset link - const resetPasswordPage = await context.newPage(); - await resetPasswordPage.goto(resetLink); +export async function openRenewAccountEmail( + context: BrowserContext, + screenChecker: ScreenCheckerFixture, + userEmail: string +): Promise { + // Use Mailpit API to get password reset link instead of browser automation + const renewLink = await getRenewAccountLink(userEmail); - await screenChecker(resetPasswordPage, 'password_reset'); + // Create a new page and navigate directly to the reset link + const resetPasswordPage = await context.newPage(); + await resetPasswordPage.goto(renewLink); - await resetPasswordPage.getByRole('button', {name:'Continuer la réinitialisation'}).click(); - await resetPasswordPage.getByText('La vérification de votre adresse email est réussie!'); + await screenChecker(resetPasswordPage, 'email_account_validity'); - return resetPasswordPage; + return resetPasswordPage; } +export async function loginWithPassword( + page: Page, + userData: { email: string; password: string }, + screenChecker: Function +) { + await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); -// clik on Create Account Legacy Link -export async function openCreateAccountLegacyLink(context: BrowserContext, screenChecker:ScreenCheckerFixture, userEmail: string): Promise { - // Use Mailpit API to get password reset link instead of browser automation - const resetLink = await getCreateAccountLegacyLink(userEmail); + // Welcome page + await screenChecker(page, `#/welcome`); + await page.getByRole('link').filter({ hasText: 'Se connecter' }).click(); - // Create a new page and navigate directly to the reset link - const resetPasswordPage = await context.newPage(); - await resetPasswordPage.goto(resetLink); + // Email precheck + await screenChecker(page, `#/email-precheck-sso`); + await page.locator('input').fill(userData.email); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - await screenChecker(resetPasswordPage, 'unstable/registration'); + // Login page + await screenChecker(page, `/login`); + await expect(page.locator('input[name="username"]')).toHaveValue(userData.email); + await page.locator('input[name="password"]').fill(userData.password); + await page.locator('button[type="submit"]').click(); - return resetPasswordPage; + // Consent page + await screenChecker(page, `/consent`); + await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); } - -export async function loginWithPassword( - page: Page, - userData: { email: string; password: string }, - screenChecker: Function - ) { - await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); - - // Welcome page - await screenChecker(page, `#/welcome`); - await page.getByRole('link').filter({hasText : "Se connecter"}).click(); - - // Email precheck - await screenChecker(page, `#/email-precheck-sso`); - await page.locator('input').fill(userData.email); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - - // Login page - await screenChecker(page, `/login`); - await expect(page.locator('input[name="username"]')).toHaveValue(userData.email); - await page.locator('input[name="password"]').fill(userData.password); - await page.locator('button[type="submit"]').click(); - - // Consent page - await screenChecker(page, `/consent`); - await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); - - } - /** * Same as performPasswordLogin but without the screenshots */ @@ -298,7 +320,7 @@ export async function performSimplePasswordLogin( console.log(`[Auth] Performing password login for user: ${user.username}`); // Navigate to the login page - await page.goto("/login"); + await page.goto('/login'); // Fill in the username and password await page.locator('input[name="username"]').fill(user.username); @@ -308,27 +330,26 @@ export async function performSimplePasswordLogin( await page.locator('button[type="submit"]').click(); // Wait for successful login (redirect to dashboard or home page) - await page.waitForURL((url) => !url.toString().includes("/login")); + await page.waitForURL((url) => !url.toString().includes('/login')); console.log(`[Auth] Password login successful for user: ${user.username}`); } -export function generateRoomName(prefix:string){ +export function generateRoomName(prefix: string) { const timestamp = new Date().getTime(); const randomSuffix = Math.floor(Math.random() * 10000); - return `prefix_${timestamp}_${randomSuffix}` + return `prefix_${timestamp}_${randomSuffix}`; } - // Generate a unique username and email for testing -export function generateTestUserData(domain:string):TestUser { +export function generateTestUserData(domain: string): TestUser { const timestamp = new Date().getTime(); const randomSuffix = Math.floor(Math.random() * 10000); const username = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}`; const localpart = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}-${domain}`; const email = `${username}@${domain}`; - - console.log("Using email: ", email); + + console.log('Using email: ', email); return { username: localpart, @@ -338,30 +359,29 @@ export function generateTestUserData(domain:string):TestUser { }; } - // Taken from element-mmodules /** Adds an initScript to the given page which will populate localStorage appropriately so that Element will use the given credentials. */ export async function populateLocalStorageWithCredentials(page: Page, credentials: Credentials) { await page.addInitScript( - ({ credentials }) => { - window.localStorage.setItem("mx_hs_url", credentials.homeserverBaseUrl); - window.localStorage.setItem("mx_user_id", credentials.userId); - window.localStorage.setItem("mx_access_token", credentials.accessToken); - window.localStorage.setItem("mx_device_id", credentials.deviceId); - window.localStorage.setItem("mx_is_guest", "false"); - window.localStorage.setItem("mx_has_pickle_key", "false"); - window.localStorage.setItem("mx_has_access_token", "true"); - - window.localStorage.setItem( - "mx_local_settings", - JSON.stringify({ - // Retain any other settings which may have already been set - ...JSON.parse(window.localStorage.getItem("mx_local_settings") ?? "{}"), - // Ensure the language is set to a consistent value - language: "fr", - }), - ); - }, - { credentials }, + ({ credentials }) => { + window.localStorage.setItem('mx_hs_url', credentials.homeserverBaseUrl); + window.localStorage.setItem('mx_user_id', credentials.userId); + window.localStorage.setItem('mx_access_token', credentials.accessToken); + window.localStorage.setItem('mx_device_id', credentials.deviceId); + window.localStorage.setItem('mx_is_guest', 'false'); + window.localStorage.setItem('mx_has_pickle_key', 'false'); + window.localStorage.setItem('mx_has_access_token', 'true'); + + window.localStorage.setItem( + 'mx_local_settings', + JSON.stringify({ + // Retain any other settings which may have already been set + ...JSON.parse(window.localStorage.getItem('mx_local_settings') ?? '{}'), + // Ensure the language is set to a consistent value + language: 'fr', + }) + ); + }, + { credentials } ); } diff --git a/utils/config.ts b/utils/config.ts index 877d89e..186463a 100644 --- a/utils/config.ts +++ b/utils/config.ts @@ -1,7 +1,6 @@ import dotenv from 'dotenv'; import path from 'path'; - // Determine which environment to use export const env = process.env.ENV || 'local'; console.log(`Loading environment configuration for: ${env}`); @@ -13,16 +12,16 @@ dotenv.config({ path: path.resolve(__dirname, `../.env.${env}`) }); //dotenv.config(); // URLs -export const MAS_URL = process.env.MAS_URL|| ""; -export const KEYCLOAK_URL = process.env.KEYCLOAK_URL || ""; -export const ELEMENT_URL = process.env.ELEMENT_URL || ""; -export const BASE_URL = process.env.BASE_URL|| ""; -export const MAIL_URL = process.env.MAIL_URL || ""; +export const MAS_URL = process.env.MAS_URL || ''; +export const KEYCLOAK_URL = process.env.KEYCLOAK_URL || ''; +export const ELEMENT_URL = process.env.ELEMENT_URL || ''; +export const BASE_URL = process.env.BASE_URL || ''; +export const MAIL_URL = process.env.MAIL_URL || ''; -export const MAILPIT_USER = process.env.MAILPIT_USER || ""; -export const MAILPIT_PWD = process.env.MAILPIT_PWD || ""; +export const MAILPIT_USER = process.env.MAILPIT_USER || ''; +export const MAILPIT_PWD = process.env.MAILPIT_PWD || ''; -export const TCHAP_LEGACY:boolean = Boolean(process.env.TCHAP_LEGACY); +export const TCHAP_LEGACY: boolean = Boolean(process.env.TCHAP_LEGACY); // Keycloak Admin Credentials export const KEYCLOAK_ADMIN_USERNAME = process.env.KEYCLOAK_ADMIN_USERNAME || 'admin'; @@ -31,18 +30,19 @@ export const KEYCLOAK_REALM = process.env.KEYCLOAK_REALM || 'proconnect-mock'; // MAS Admin API Credentials export const MAS_ADMIN_CLIENT_ID = process.env.MAS_ADMIN_CLIENT_ID || '01J44RKQYM4G3TNVANTMTDYTX6'; -export const MAS_ADMIN_CLIENT_SECRET = process.env.MAS_ADMIN_CLIENT_SECRET || 'phoo8ahneir3ohY2eigh4xuu6Oodaewi'; +export const MAS_ADMIN_CLIENT_SECRET = + process.env.MAS_ADMIN_CLIENT_SECRET || 'phoo8ahneir3ohY2eigh4xuu6Oodaewi'; // Test User Credentials export const TEST_USER_PREFIX = process.env.TEST_USER_PREFIX || 'user.test'; export const TEST_USER_PASSWORD = process.env.TEST_USER_PASSWORD || 'Test@123456'; // Email domains for test users -export const STANDARD_EMAIL_DOMAIN = process.env.STANDARD_EMAIL_DOMAIN || ""; -export const INVITED_EMAIL_DOMAIN = process.env.INVITED_EMAIL_DOMAIN || ""; -export const NOT_INVITED_EMAIL_DOMAIN = process.env.NOT_INVITED_EMAIL_DOMAIN|| ""; -export const WRONG_SERVER_EMAIL_DOMAIN = process.env.WRONG_SERVER_EMAIL_DOMAIN || ""; -export const NUMERIQUE_EMAIL_DOMAIN = process.env.NUMERIQUE_EMAIL_DOMAIN || ""; +export const STANDARD_EMAIL_DOMAIN = process.env.STANDARD_EMAIL_DOMAIN || ''; +export const INVITED_EMAIL_DOMAIN = process.env.INVITED_EMAIL_DOMAIN || ''; +export const NOT_INVITED_EMAIL_DOMAIN = process.env.NOT_INVITED_EMAIL_DOMAIN || ''; +export const WRONG_SERVER_EMAIL_DOMAIN = process.env.WRONG_SERVER_EMAIL_DOMAIN || ''; +export const NUMERIQUE_EMAIL_DOMAIN = process.env.NUMERIQUE_EMAIL_DOMAIN || ''; // Screenshots directory export const SCREENSHOTS_DIR = process.env.SCREENSHOTS_DIR || 'playwright-results'; @@ -54,8 +54,8 @@ export const USE_MAS = process.env.USE_MAS === 'true' || false; // TODO Move all below to env file // Fixed tests data, that can be used across environement -export const FIX_USER_USERNAME = "Michelle_test"; -export const FIX_USER_PASSWORD = "Michelle1313!"; -export const FIX_USER_EMAIL = "Michelle1313!"; +export const FIX_USER_USERNAME = 'Michelle_test'; +export const FIX_USER_PASSWORD = 'Michelle1313!'; +export const FIX_USER_EMAIL = 'Michelle1313!'; -export const SYNAPSE_ADMIN_TOKEN = process.env.SYNAPSE_ADMIN_TOKEN|| ""; +export const SYNAPSE_ADMIN_TOKEN = process.env.SYNAPSE_ADMIN_TOKEN || ''; diff --git a/utils/keycloak-admin.ts b/utils/keycloak-admin.ts index 5db4dac..b331515 100644 --- a/utils/keycloak-admin.ts +++ b/utils/keycloak-admin.ts @@ -1,9 +1,9 @@ -import { APIRequestContext, request } from '@playwright/test'; -import { - KEYCLOAK_URL, - KEYCLOAK_ADMIN_USERNAME, +import { type APIRequestContext, request } from '@playwright/test'; +import { + KEYCLOAK_URL, + KEYCLOAK_ADMIN_USERNAME, KEYCLOAK_ADMIN_PASSWORD, - KEYCLOAK_REALM + KEYCLOAK_REALM, } from './config'; // Create a reusable API request context @@ -14,7 +14,7 @@ async function getApiContext(): Promise { //console.log(`[Keycloak API] Creating new API context with baseURL: ${KEYCLOAK_URL}`); apiContext = await request.newContext({ baseURL: KEYCLOAK_URL, - ignoreHTTPSErrors: true + ignoreHTTPSErrors: true, }); } return apiContext; @@ -26,15 +26,15 @@ async function getApiContext(): Promise { export async function getKeycloakAdminToken(): Promise { console.log(`[Keycloak API] Requesting admin token with username: ${KEYCLOAK_ADMIN_USERNAME}`); const apiRequestContext = await getApiContext(); - + const response = await apiRequestContext.post('/realms/master/protocol/openid-connect/token', { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, form: { grant_type: 'password', client_id: 'admin-cli', username: KEYCLOAK_ADMIN_USERNAME, - password: KEYCLOAK_ADMIN_PASSWORD - } + password: KEYCLOAK_ADMIN_PASSWORD, + }, }); if (!response.ok()) { @@ -43,7 +43,7 @@ export async function getKeycloakAdminToken(): Promise { throw new Error(`Failed to get Keycloak admin token: ${response.status()} - ${errorText}`); } - const data = await response.json() as { access_token: string }; + const data = (await response.json()) as { access_token: string }; //console.log(`[Keycloak API] Successfully obtained admin token`); return data.access_token; } @@ -51,17 +51,21 @@ export async function getKeycloakAdminToken(): Promise { /** * Create a user in Keycloak */ -export async function createKeycloakUser(username: string, email: string, password: string): Promise { +export async function createKeycloakUser( + username: string, + email: string, + password: string +): Promise { console.log(`[Keycloak API] Creating user: ${username} with email: ${email}`); const token = await getKeycloakAdminToken(); const apiRequestContext = await getApiContext(); - + // First, create the user console.log(`[Keycloak API] Creating user in realm: ${KEYCLOAK_REALM}`); const createResponse = await apiRequestContext.post(`/admin/realms/${KEYCLOAK_REALM}/users`, { headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` + Authorization: `Bearer ${token}`, }, data: { username, @@ -71,14 +75,16 @@ export async function createKeycloakUser(username: string, email: string, passwo firstName: username, lastName: username, attributes: { - idp_id: username - } - } + idp_id: username, + }, + }, }); if (!createResponse.ok()) { const errorText = await createResponse.text(); - console.error(`[Keycloak API] Failed to create user: ${createResponse.status()} - ${errorText}`); + console.error( + `[Keycloak API] Failed to create user: ${createResponse.status()} - ${errorText}` + ); throw new Error(`Failed to create Keycloak user: ${createResponse.status()} - ${errorText}`); } console.log(`[Keycloak API] User created successfully`); @@ -89,8 +95,8 @@ export async function createKeycloakUser(username: string, email: string, passwo `/admin/realms/${KEYCLOAK_REALM}/users?username=${encodeURIComponent(username)}`, { headers: { - 'Authorization': `Bearer ${token}` - } + Authorization: `Bearer ${token}`, + }, } ); @@ -100,7 +106,7 @@ export async function createKeycloakUser(username: string, email: string, passwo throw new Error(`Failed to get Keycloak user: ${usersResponse.status()} - ${errorText}`); } - const users = await usersResponse.json() as Array<{ id: string }>; + const users = (await usersResponse.json()) as Array<{ id: string }>; if (users.length === 0) { console.error(`[Keycloak API] User ${username} not found after creation`); throw new Error(`User ${username} not found after creation`); @@ -116,20 +122,24 @@ export async function createKeycloakUser(username: string, email: string, passwo { headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` + Authorization: `Bearer ${token}`, }, data: { type: 'password', value: password, - temporary: false - } + temporary: false, + }, } ); if (!passwordResponse.ok()) { const errorText = await passwordResponse.text(); - console.error(`[Keycloak API] Failed to set password: ${passwordResponse.status()} - ${errorText}`); - throw new Error(`Failed to set Keycloak user password: ${passwordResponse.status()} - ${errorText}`); + console.error( + `[Keycloak API] Failed to set password: ${passwordResponse.status()} - ${errorText}` + ); + throw new Error( + `Failed to set Keycloak user password: ${passwordResponse.status()} - ${errorText}` + ); } console.log(`[Keycloak API] Password set successfully for user: ${username}`); @@ -143,13 +153,13 @@ export async function deleteKeycloakUser(userId: string): Promise { console.log(`[Keycloak API] Deleting user with ID: ${userId}`); const token = await getKeycloakAdminToken(); const apiRequestContext = await getApiContext(); - + const response = await apiRequestContext.delete( `/admin/realms/${KEYCLOAK_REALM}/users/${userId}`, { headers: { - 'Authorization': `Bearer ${token}` - } + Authorization: `Bearer ${token}`, + }, } ); @@ -168,13 +178,13 @@ export async function checkKeycloakUserExists(username: string): Promise; + const users = (await response.json()) as Array<{ id: string }>; const exists = users.length > 0; console.log(`[Keycloak API] User ${username} exists: ${exists}`); return exists; diff --git a/utils/mailpit.ts b/utils/mailpit.ts index 399be5d..c8c8935 100644 --- a/utils/mailpit.ts +++ b/utils/mailpit.ts @@ -1,7 +1,6 @@ import { MailpitClient } from 'mailpit-api'; import { MAIL_URL, MAILPIT_PWD, MAILPIT_USER } from './config'; - /** * Extract verification code from email content * @param content - The email content (text or HTML) @@ -16,13 +15,15 @@ function extractCodeFromContent(content: string): string { return codeMatch[1]; } -export async function getMailpitClient(){ - const mailpit = await new MailpitClient(MAIL_URL, MAILPIT_USER != "" ? {username: MAILPIT_USER, password : MAILPIT_PWD} : undefined); +export async function getMailpitClient() { + const mailpit = await new MailpitClient( + MAIL_URL, + MAILPIT_USER != '' ? { username: MAILPIT_USER, password: MAILPIT_PWD } : undefined + ); await mailpit.getInfo(); return mailpit; } - /** * Get the most recent verification code from Mailpit for a specific recipient * @param toEmail - The recipient email address to filter messages @@ -30,8 +31,11 @@ export async function getMailpitClient(){ */ export async function getLatestVerificationCode(toEmail: string): Promise { try { - - const {message, content} = await waitForMessage(toEmail , 20000, "Votre code de vérification est"); + const { message, content } = await waitForMessage( + toEmail, + 40000, + 'Votre code de vérification est' + ); console.log('[Mailpit] Email content preview:', content.substring(0, 200)); @@ -62,40 +66,13 @@ function extractResetLinkFromContent(content: string): string { return urlMatch[1]; } - -function extractResetLinkLegacyFromContent(content: string): string { - // Match URLs that contain password/recovery or similar patterns - // Look for full URLs in the email - console.log(content); - - const urlMatch = content.match(/(https?:\/\/[^\s<>"]+(?:password_reset)[^\s<>"]*)/i); - if (!urlMatch) { - throw new Error('Unable to extract password reset link from email content'); - } - return urlMatch[1]; -} - -export async function getPasswordResetLinkLegacy(toEmail: string): Promise { - try { - - const {message, content} = await waitForMessage(toEmail , 30000, "Changement de mot de passe"); - - console.log('[Mailpit] Email content preview:', content.substring(0, 300)); - - const resetLink = extractResetLinkLegacyFromContent(content); - console.log(`[Mailpit] Extracted password reset link: ${resetLink}`); - - return resetLink; - } catch (error) { - console.error('[Mailpit] Error fetching password reset link:', error); - throw error; - } -} - export async function getPasswordResetLink(toEmail: string): Promise { try { - - const {message, content} = await waitForMessage(toEmail , 20000, "Réinitialisez le mot de passe"); + const { message, content } = await waitForMessage( + toEmail, + 40000, + 'Réinitialisez le mot de passe' + ); console.log('[Mailpit] Email content preview:', content.substring(0, 300)); @@ -109,41 +86,13 @@ export async function getPasswordResetLink(toEmail: string): Promise { } } -/** - * Get the password reset link from the most recent email for a specific recipient - * @param toEmail - The recipient email address to filter messages - * @returns The password reset URL from the most recent email - */ -export async function getCreateAccountLegacyLink(toEmail: string): Promise { - try { - const {message, content} = await waitForMessage(toEmail , 25000, "Vérifiez votre adresse email"); - - const resetLink = extractCreateAccountLegacyLink(content); - console.log(`[Mailpit] Extracted password reset link: ${resetLink}`); - - return resetLink; - } catch (error) { - console.error('[Mailpit] Error fetching password reset link:', error); - throw error; - } -} - -function extractCreateAccountLegacyLink(content: string): string { - // Match URLs that contain password/recovery or similar patterns - // Look for full URLs in the email - console.log(content); - - const urlMatch = content.match(/(https?:\/\/[^\s<>"]+(?:email\/submit_token)[^\s<>"]*)/i); - if (!urlMatch) { - throw new Error('Unable to extract Create Account Legacy Link from email content'); - } - return urlMatch[1]; -} - export async function getExpirationAccountLink(toEmail: string): Promise { try { - - const {message, content} = await waitForMessage(toEmail , 20000, "Renouvelez votre compte Tchap"); + const { message, content } = await waitForMessage( + toEmail, + 20000, + 'Renouvelez votre compte Tchap' + ); console.log('[Mailpit] Email content preview:', content.substring(0, 300)); @@ -151,7 +100,7 @@ export async function getExpirationAccountLink(toEmail: string): Promise if (!urlMatch) { throw new Error('Unable to extract expiration account link from email content'); } - const resetLink = urlMatch[1]; + const resetLink = urlMatch[1]; console.log(`[Mailpit] Extracted expiration account link: ${resetLink}`); return resetLink; @@ -161,12 +110,14 @@ export async function getExpirationAccountLink(toEmail: string): Promise } } - /** * Search for messages and get content with retry */ -export async function waitForMessage(toEmail: string, maxWaitTimeMs = 10000, subject:string): Promise<{message: any, content: string}> { - +export async function waitForMessage( + toEmail: string, + maxWaitTimeMs = 10000, + subject: string +): Promise<{ message: any; content: string }> { const mailpit = await getMailpitClient(); const startTime = Date.now(); @@ -183,33 +134,37 @@ export async function waitForMessage(toEmail: string, maxWaitTimeMs = 10000, sub if (messages.messages && messages.messages.length > 0) { const latestMessage = messages.messages[0]; - console.log(`[Mailpit] Found email for ${toEmail}: ${latestMessage.Subject} (ID: ${latestMessage.ID})`); - + console.log( + `[Mailpit] Found email for ${toEmail}: ${latestMessage.Subject} (ID: ${latestMessage.ID})` + ); + // Get the full message content const message = await mailpit.getMessageSummary(latestMessage.ID); - let content = message.Text; - + const content = message.Text; + if (!content) { throw new Error('Email content is empty'); } console.log('[Mailpit] Email content preview:', content.substring(0, 300)); - + return { message, content }; } // No message found, retry retryCount++; - console.log(`[Mailpit] Waiting for emails found for ${toEmail} with subject ${subject} , retrying... (${retryCount})`); - await new Promise(resolve => setTimeout(resolve, retryDelay)); + console.log( + `[Mailpit] Waiting for emails found for ${toEmail} with subject ${subject} , retrying... (${retryCount})` + ); + await new Promise((resolve) => setTimeout(resolve, retryDelay)); } catch (error) { // Error occurred, retry retryCount++; console.log(`[Mailpit] Error searching for emails:`, error); - await new Promise(resolve => setTimeout(resolve, retryDelay)); + await new Promise((resolve) => setTimeout(resolve, retryDelay)); } } // Max time reached without success throw new Error(`No emails found for ${toEmail} after ${maxWaitTimeMs}ms`); -} \ No newline at end of file +} diff --git a/utils/mas-admin.ts b/utils/mas-admin.ts index 0cd9793..728dce9 100644 --- a/utils/mas-admin.ts +++ b/utils/mas-admin.ts @@ -1,9 +1,5 @@ -import { APIRequestContext, request } from '@playwright/test'; -import { - MAS_URL, - MAS_ADMIN_CLIENT_ID, - MAS_ADMIN_CLIENT_SECRET -} from './config'; +import { type APIRequestContext, request } from '@playwright/test'; +import { MAS_URL, MAS_ADMIN_CLIENT_ID, MAS_ADMIN_CLIENT_SECRET } from './config'; // Create a reusable API request context let apiContext: APIRequestContext | null = null; @@ -13,7 +9,7 @@ async function getApiContext(): Promise { //console.log(`[MAS API] Creating new API context with baseURL: ${MAS_URL}`); apiContext = await request.newContext({ baseURL: MAS_URL, - ignoreHTTPSErrors: true + ignoreHTTPSErrors: true, }); } return apiContext; @@ -25,17 +21,19 @@ async function getApiContext(): Promise { export async function getMasAdminToken(): Promise { //console.log(`[MAS API] Requesting admin token with client ID: ${MAS_ADMIN_CLIENT_ID}`); const apiRequestContext = await getApiContext(); - const authHeader = Buffer.from(`${MAS_ADMIN_CLIENT_ID}:${MAS_ADMIN_CLIENT_SECRET}`).toString('base64'); - + const authHeader = Buffer.from(`${MAS_ADMIN_CLIENT_ID}:${MAS_ADMIN_CLIENT_SECRET}`).toString( + 'base64' + ); + const response = await apiRequestContext.post('/oauth2/token', { headers: { 'Content-Type': 'application/x-www-form-urlencoded', - 'Authorization': `Basic ${authHeader}` + Authorization: `Basic ${authHeader}`, }, form: { grant_type: 'client_credentials', - scope: 'urn:mas:admin' - } + scope: 'urn:mas:admin', + }, }); if (!response.ok()) { @@ -44,7 +42,7 @@ export async function getMasAdminToken(): Promise { throw new Error(`Failed to get MAS admin token: ${response.status()} - ${errorText}`); } - const data = await response.json() as { access_token: string }; + const data = (await response.json()) as { access_token: string }; //console.log(`[MAS API] Successfully obtained admin token ${data.access_token}`); return data.access_token; } @@ -56,14 +54,14 @@ export async function getMasUserByEmail(email: string): Promise { //console.log(`[MAS API] Getting user details for email: ${email}`); const token = await getMasAdminToken(); const apiRequestContext = await getApiContext(); - + // Step 1: Get user ID from email const emailResponse = await apiRequestContext.get( `/api/admin/v1/user-emails?filter[email]=${encodeURIComponent(email)}`, { headers: { - 'Authorization': `Bearer ${token}` - } + Authorization: `Bearer ${token}`, + }, } ); @@ -77,13 +75,11 @@ export async function getMasUserByEmail(email: string): Promise { if (emailResult.data.length === 0) { console.log(`[MAS API] No user found with email: ${email}`); throw new Error(`[MAS API] No user found with email: ${email}`); - } if (emailResult.data.length > 1) { console.log(`[MAS API] Multiple users found with email: ${email}`); throw new Error(`[MAS API] Multiple users found with email: ${email}`); - } // Extract user_id from the attributes @@ -91,14 +87,11 @@ export async function getMasUserByEmail(email: string): Promise { //console.log(`[MAS API] Found user ID: ${userId} for email: ${email}`); // Step 2: Get complete user details using the user ID - const userResponse = await apiRequestContext.get( - `/api/admin/v1/users/${userId}`, - { - headers: { - 'Authorization': `Bearer ${token}` - } - } - ); + const userResponse = await apiRequestContext.get(`/api/admin/v1/users/${userId}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); if (!userResponse.ok()) { const errorText = await userResponse.text(); @@ -108,10 +101,12 @@ export async function getMasUserByEmail(email: string): Promise { const userResult = await userResponse.json(); const user = userResult.data; - + //console.log(`[MAS API] User found: Yes`); - console.log(`[MAS API] User found : ID: ${user.id}, Username: ${user.attributes.username || 'N/A'}`); - + console.log( + `[MAS API] User found : ID: ${user.id}, Username: ${user.attributes.username || 'N/A'}` + ); + return user; } @@ -136,8 +131,14 @@ export async function checkMasUserExistsByEmail(email: string): Promise * This is useful after OIDC authentication, as there might be a slight delay * before the user is fully created in MAS */ -export async function waitForMasUser(email: string, maxAttempts = 10, delayMs = 1000): Promise { - console.log(`[MAS API] Waiting for user with email ${email} to be created (max ${maxAttempts} attempts)`); +export async function waitForMasUser( + email: string, + maxAttempts = 10, + delayMs = 1000 +): Promise { + console.log( + `[MAS API] Waiting for user with email ${email} to be created (max ${maxAttempts} attempts)` + ); for (let attempt = 0; attempt < maxAttempts; attempt++) { console.log(`[MAS API] Attempt ${attempt + 1}/${maxAttempts} to find user`); try { @@ -146,15 +147,17 @@ export async function waitForMasUser(email: string, maxAttempts = 10, delayMs = console.log(`[MAS API] User found on attempt ${attempt + 1}`); return user; } - console.log(`[MAS API] User not found on attempt ${attempt + 1}, waiting ${delayMs}ms before next attempt`); + console.log( + `[MAS API] User not found on attempt ${attempt + 1}, waiting ${delayMs}ms before next attempt` + ); } catch (error) { console.warn(`[MAS API] Attempt ${attempt + 1}/${maxAttempts} failed: ${error}`); } - + // Wait before the next attempt - await new Promise(resolve => setTimeout(resolve, delayMs)); + await new Promise((resolve) => setTimeout(resolve, delayMs)); } - + const errorMsg = `User with email ${email} not found in MAS after ${maxAttempts} attempts`; console.error(`[MAS API] ${errorMsg}`); throw new Error(errorMsg); @@ -163,22 +166,28 @@ export async function waitForMasUser(email: string, maxAttempts = 10, delayMs = /** * Create a user in MAS with a password */ -export async function createMasUserWithPassword(username: string, email: string, password: string): Promise { - console.log(`[MAS API] Creating user with username:${username}, email:${email}, password:${password}`); +export async function createMasUserWithPassword( + username: string, + email: string, + password: string +): Promise { + console.log( + `[MAS API] Creating user with username:${username}, email:${email}, password:${password}` + ); const token = await getMasAdminToken(); const apiRequestContext = await getApiContext(); - + const response = await apiRequestContext.post('/api/admin/v1/users', { headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` + Authorization: `Bearer ${token}`, }, data: { - "username": username, - "skip_homeserver_check": false - } + username: username, + skip_homeserver_check: false, + }, }); - + if (!response.ok()) { const errorText = await response.text(); console.error(`[MAS API] Failed to create user: ${response.status()} - ${errorText}`); @@ -192,42 +201,46 @@ export async function createMasUserWithPassword(username: string, email: string, const responsePwd = await apiRequestContext.post(`/api/admin/v1/users/${userId}/set-password`, { headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` + Authorization: `Bearer ${token}`, }, data: { - "password": password, - "skip_password_check": true - } + password: password, + skip_password_check: true, + }, }); if (!responsePwd.ok()) { const errorText = await responsePwd.text(); - console.error(`[MAS API] Failed to set password for user: ${responsePwd.status()} - ${errorText}`); + console.error( + `[MAS API] Failed to set password for user: ${responsePwd.status()} - ${errorText}` + ); throw new Error(`Failed to set password for user: ${responsePwd.status()} - ${errorText}`); } const responseEmail = await apiRequestContext.post(`/api/admin/v1/user-emails`, { headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` + Authorization: `Bearer ${token}`, }, data: { - "user_id": userId, - "email": email - } + user_id: userId, + email: email, + }, }); if (!responseEmail.ok()) { const errorText = await responseEmail.text(); - console.error(`[MAS API] Failed to set email for user: ${responseEmail.status()} - ${errorText}`); + console.error( + `[MAS API] Failed to set email for user: ${responseEmail.status()} - ${errorText}` + ); throw new Error(`Failed to set email for user: ${responseEmail.status()} - ${errorText}`); } // Verify the user exists in MAS const existsBeforeLogin = await checkMasUserExistsByEmail(email); - + console.log(`[MAS API] User created successfully with ID: ${userId}`); - return existsBeforeLogin ? userId : "error"; + return existsBeforeLogin ? userId : 'error'; } /** @@ -237,19 +250,19 @@ export async function deactivateMasUser(userId: string): Promise { console.log(`[MAS API] Deleting user with ID: ${userId}`); const token = await getMasAdminToken(); const apiRequestContext = await getApiContext(); - + const response = await apiRequestContext.post(`/api/admin/v1/users/${userId}/deactivate`, { headers: { - 'Authorization': `Bearer ${token}` - } + Authorization: `Bearer ${token}`, + }, }); - + if (!response.ok()) { const errorText = await response.text(); console.error(`[MAS API] Failed to deactivate user: ${response.status()} - ${errorText}`); throw new Error(`Failed to deactivate MAS user: ${response.status()} - ${errorText}`); } - + console.log(`[MAS API] User deactivated successfully`); } @@ -259,13 +272,16 @@ export async function deactivateMasUser(userId: string): Promise { export async function oauthLinkExistsByUserId(userId: string): Promise { const token = await getMasAdminToken(); const apiRequestContext = await getApiContext(); - - const response = await apiRequestContext.get(`/api/admin/v1/upstream-oauth-links?filter[user]=${userId}`, { - headers: { - 'Authorization': `Bearer ${token}` + + const response = await apiRequestContext.get( + `/api/admin/v1/upstream-oauth-links?filter[user]=${userId}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, } - }); - + ); + if (!response.ok()) { const errorText = await response.text(); console.error(`[MAS API] Failed to delete user: ${response.status()} - ${errorText}`); @@ -275,7 +291,7 @@ export async function oauthLinkExistsByUserId(userId: string): Promise //console.log(data.data) const links = data.data; console.log(`[MAS API] Oauth links for user ${userId} : ${JSON.stringify(links)}`); - return links.length == 1 + return links.length == 1; } /** @@ -284,13 +300,16 @@ export async function oauthLinkExistsByUserId(userId: string): Promise export async function oauthLinkExistsBySubject(subject: string): Promise { const token = await getMasAdminToken(); const apiRequestContext = await getApiContext(); - - const response = await apiRequestContext.get(`/api/admin/v1/upstream-oauth-links?filter[subject]=${subject}`, { - headers: { - 'Authorization': `Bearer ${token}` + + const response = await apiRequestContext.get( + `/api/admin/v1/upstream-oauth-links?filter[subject]=${subject}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, } - }); - + ); + if (!response.ok()) { const errorText = await response.text(); console.error(`[MAS API] Failed to delete user: ${response.status()} - ${errorText}`); @@ -300,7 +319,7 @@ export async function oauthLinkExistsBySubject(subject: string): Promise { } } - /** * Check if a oauth link exists */ export async function getOauthLinkByUserId(userId: string): Promise { const token = await getMasAdminToken(); const apiRequestContext = await getApiContext(); - - const response = await apiRequestContext.get(`/api/admin/v1/upstream-oauth-links?filter[user]=${userId}`, { - headers: { - 'Authorization': `Bearer ${token}` + + const response = await apiRequestContext.get( + `/api/admin/v1/upstream-oauth-links?filter[user]=${userId}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, } - }); - + ); + if (!response.ok()) { const errorText = await response.text(); console.error(`[MAS API] Failed to delete user: ${response.status()} - ${errorText}`); @@ -340,20 +361,22 @@ export async function getOauthLinkByUserId(userId: string): Promise { //console.log(data.data) const links = data.data; console.log(`[MAS API] Oauth links for user ${userId} : ${JSON.stringify(links)}`); - return links + return links; } - export async function getOauthLinkBySubject(subject: string): Promise { const token = await getMasAdminToken(); const apiRequestContext = await getApiContext(); - - const response = await apiRequestContext.get(`/api/admin/v1/upstream-oauth-links?filter[subject]=${subject}`, { - headers: { - 'Authorization': `Bearer ${token}` + + const response = await apiRequestContext.get( + `/api/admin/v1/upstream-oauth-links?filter[subject]=${subject}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, } - }); - + ); + if (!response.ok()) { const errorText = await response.text(); console.error(`[MAS API] Failed to delete user: ${response.status()} - ${errorText}`); @@ -363,42 +386,43 @@ export async function getOauthLinkBySubject(subject: string): Promise { //console.log(data.data) const links = data.data; console.log(`[MAS API] Oauth links for user ${subject} : ${JSON.stringify(links)}`); - return links + return links; } - export async function deleteOauthLink(id: string): Promise { const token = await getMasAdminToken(); const apiRequestContext = await getApiContext(); - + const response = await apiRequestContext.delete(`/api/admin/v1/upstream-oauth-links/${id}`, { headers: { - 'Authorization': `Bearer ${token}` - } + Authorization: `Bearer ${token}`, + }, }); - + if (!response.ok()) { const errorText = await response.text(); console.error(`[MAS API] Failed to delete user: ${response.status()} - ${errorText}`); throw new Error(`Failed to delete MAS user: ${response.status()} - ${errorText}`); } - console.log(`[MAS API] Oauth links deleted for id:${id}, response : ${JSON.stringify(response)} `); + console.log( + `[MAS API] Oauth links deleted for id:${id}, response : ${JSON.stringify(response)} ` + ); return; } export async function addUserEmail(userId: string, email: string): Promise { const token = await getMasAdminToken(); const apiRequestContext = await getApiContext(); - + const response = await apiRequestContext.post(`/api/admin/v1/user-emails`, { headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` + Authorization: `Bearer ${token}`, }, data: { - "user_id": userId, - "email": email - } + user_id: userId, + email: email, + }, }); if (!response.ok()) { @@ -406,20 +430,25 @@ export async function addUserEmail(userId: string, email: string): Promise console.error(`[MAS API] Failed to set email for user: ${response.status()} - ${errorText}`); throw new Error(`Failed to set email for user: ${response.status()} - ${errorText}`); } - console.log(`[MAS API] User email added for user_id:${userId}, response : ${JSON.stringify(response)} `); + console.log( + `[MAS API] User email added for user_id:${userId}, response : ${JSON.stringify(response)} ` + ); return; } export async function getUserEmail(userId: string, email: string): Promise { const token = await getMasAdminToken(); const apiRequestContext = await getApiContext(); - - const response = await apiRequestContext.get(`/api/admin/v1/user-emails?filter[user]=${userId}&filter[email]=${email}`, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` + + const response = await apiRequestContext.get( + `/api/admin/v1/user-emails?filter[user]=${userId}&filter[email]=${email}`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, } - }); + ); if (!response.ok()) { const errorText = await response.text(); @@ -428,20 +457,21 @@ export async function getUserEmail(userId: string, email: string): Promise } const json = await response.json(); const user_email = json.data[0]; - console.log(`[MAS API] User email retrieved for userId:${userId}, user_email : ${JSON.stringify(user_email)} `); + console.log( + `[MAS API] User email retrieved for userId:${userId}, user_email : ${JSON.stringify(user_email)} ` + ); return user_email; } - export async function deleteUserEmail(id: string): Promise { const token = await getMasAdminToken(); const apiRequestContext = await getApiContext(); - + const response = await apiRequestContext.delete(`/api/admin/v1/user-emails/${id}`, { headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - } + Authorization: `Bearer ${token}`, + }, }); if (!response.ok()) { @@ -449,11 +479,12 @@ export async function deleteUserEmail(id: string): Promise { console.error(`[MAS API] Failed to set email for user: ${response.status()} - ${errorText}`); throw new Error(`Failed to set email for user: ${response.status()} - ${errorText}`); } - console.log(`[MAS API] User email deleted for user-emails:${id}, response : ${JSON.stringify(response)} `); + console.log( + `[MAS API] User email deleted for user-emails:${id}, response : ${JSON.stringify(response)} ` + ); return; } - /** * Reactivate a user from MAS */ @@ -461,19 +492,18 @@ export async function reactivateMasUser(userId: string): Promise { console.log(`[MAS API] Deleting user with ID: ${userId}`); const token = await getMasAdminToken(); const apiRequestContext = await getApiContext(); - + const response = await apiRequestContext.post(`/api/admin/v1/users/${userId}/reactivate`, { headers: { - 'Authorization': `Bearer ${token}` - } + Authorization: `Bearer ${token}`, + }, }); - + if (!response.ok()) { const errorText = await response.text(); console.error(`[MAS API] Failed to reactivate user: ${response.status()} - ${errorText}`); throw new Error(`Failed to reactivate MAS user: ${response.status()} - ${errorText}`); } - + console.log(`[MAS API] User reactivated successfully`); } - diff --git a/utils/synapse-admin.ts b/utils/synapse-admin.ts index aaf8ec8..260d405 100644 --- a/utils/synapse-admin.ts +++ b/utils/synapse-admin.ts @@ -1,9 +1,9 @@ -import { APIRequestContext } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; import { SYNAPSE_ADMIN_TOKEN, BASE_URL } from './config'; /** * Helper function to set account expiration using the Synapse admin API - * + * * @param request - Playwright API request context * @param userId - Matrix user ID (e.g. @username:domain) * @param expirationTs - Expiration timestamp (in seconds since epoch) @@ -17,29 +17,28 @@ export async function setAccountExpiration( enableRenewalEmails: boolean = true ): Promise { console.log(`[Synapse API] Setting expiration for user: ${userId} to timestamp: ${expirationTs}`); - - const response = await request.post( - `${BASE_URL}/_synapse/client/email_account_validity/admin`, - { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${SYNAPSE_ADMIN_TOKEN}` - }, - data: { - user_id: userId, - expiration_ts: expirationTs, - enable_renewal_emails: enableRenewalEmails - } - } - ); + + const response = await request.post(`${BASE_URL}/_synapse/client/email_account_validity/admin`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${SYNAPSE_ADMIN_TOKEN}`, + }, + data: { + user_id: userId, + expiration_ts: expirationTs, + enable_renewal_emails: enableRenewalEmails, + }, + }); if (!response.ok()) { const errorText = await response.text(); - console.error(`[Synapse API] Failed to set account expiration: ${response.status()} - ${errorText}`); + console.error( + `[Synapse API] Failed to set account expiration: ${response.status()} - ${errorText}` + ); throw new Error(`Failed to set account expiration: ${response.status()} - ${errorText}`); } const result = await response.json(); console.log(`[Synapse API] Account expiration set successfully: ${JSON.stringify(result)}`); return result; -} \ No newline at end of file +} From 6cdd3c0c285ace44175866b1b0f250ed66f42843 Mon Sep 17 00:00:00 2001 From: olivierdelcroix Date: Fri, 10 Apr 2026 12:21:09 +0200 Subject: [PATCH 61/71] add doc for docker --- README.md | 91 +++++++++------------------------------------------- package.json | 2 +- 2 files changed, 16 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index e8f0f50..4e7a006 100644 --- a/README.md +++ b/README.md @@ -2,34 +2,17 @@ Ce projet contient des tests Playwright pour tester le scénario d'authentification OIDC avec Keycloak dans le service d'authentification Matrix (MAS). -## Prérequis +Cela contient également un script complet avec les tests minimaux Tchap -- Node.js 16 ou supérieur -- npm ou yarn -- Docker pour exécuter Keycloak et PostgreSQL -- Keycloak accessible sur `sso.tchapgouv.com` -- Matrix Authentication Service accessible sur `auth.tchapgouv.com` +## Démarrage des services en local +TODO -### Démarrage des services +## Executer les tests minimaux avec docker -Avant d'exécuter les tests, vous devez démarrer les services Keycloak et Matrix Authentication Service : +`docker run -it --rm --ipc=host -v .:/app -w /app mcr.microsoft.com/playwright:v1.51.1-noble npm run test:dev01` +`docker run -it --rm --ipc=host -v .:/app -w /app mcr.microsoft.com/playwright:v1.51.1-noble npm run test:preprod` -```bash -# Démarrer Keycloak et PostgreSQL -./tchap/start-local-stack.sh - -# Démarrer le service Matrix Authentication Service -./tchap/start-local-mas.sh -``` - -Ces scripts démarrent les services nécessaires pour les tests : -- PostgreSQL pour le stockage des données -- Keycloak avec le realm `proconnect-mock` préconfiguré -- Matrix Authentication Service configuré pour utiliser Keycloak comme fournisseur OIDC - -Les entrees DNS doivent etre présentes dans le fichier hosts - -## Installation +## Installation local Pour initialiser rapidement le projet, utilisez le script d'initialisation : @@ -41,66 +24,22 @@ chmod +x init.sh ./init.sh ``` -Ce script va : -- Créer le répertoire pour les résultats des tests -- Installer les dépendances npm -- Installer les navigateurs Playwright - - -Vous pouvez également effectuer ces étapes manuellement : - -```bash -# Installer les dépendances -npm install - -# Installer les navigateurs Playwright -npx playwright install -``` - ## Configuration Les tests utilisent un fichier `.env` pour la configuration. Vous pouvez modifier ce fichier pour adapter les tests à votre environnement. -``` -# URLs -MAS_URL=https://auth.tchapgouv.com -KEYCLOAK_URL=https://sso.tchapgouv.com - -# Keycloak Admin Credentials -KEYCLOAK_ADMIN_USERNAME=admin -KEYCLOAK_ADMIN_PASSWORD=admin -KEYCLOAK_REALM=proconnect-mock - -# MAS Admin API Credentials -MAS_ADMIN_CLIENT_ID=01J44RKQYM4G3TNVANTMTDYTX6 -MAS_ADMIN_CLIENT_SECRET=phoo8ahneir3ohY2eigh4xuu6Oodaewi - -# Test User Credentials -TEST_USER_PREFIX=playwright_test_user -TEST_USER_PASSWORD=Test@123456 -``` +Requis pour dev01 et preprod: +`MAILPIT_PWD=` mailpit password -## Exécution des tests +## Exécution les tests ```bash -# Exécuter tous les tests -npm test +# Exécuter tous les tests MAS en local +ENV=local npm run test tests/auth --retries=2 -# Exécuter les tests avec l'interface utilisateur visible -npm run test:headed -# Exécuter les tests en mode debug -npm run test:debug - -# Exécuter les tests avec l'interface utilisateur de Playwright -npm run test:ui +# Exécuter les tests minimaux sur dev ou preprod +ENV=preprod npm run test tests/minimal +ENV=dev01 npm run test tests/minimal ``` -## Captures d'écran - -Les tests génèrent des captures d'écran à chaque étape importante du processus d'authentification. Ces captures sont enregistrées dans le répertoire `playwright-results/`. - - -## Executer avec docker - -`docker run -it --rm --ipc=host -v .:/app -w /app mcr.microsoft.com/playwright:v1.51.1-noble npm run test:dev02` \ No newline at end of file diff --git a/package.json b/package.json index 8f7c033..14efd3d 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "test:debug": "playwright test --debug", "test:ui": "playwright test --ui", "test:local": "ENV=local playwright test", - "test:dev02": "ENV=dev02 playwright test ./tests/minimal/", + "test:dev01": "ENV=dev01 playwright test ./tests/minimal/", "test:preprod": "ENV=preprod playwright test ./tests/minimal/", "lint": "biome lint .", "lint:fix": "biome lint . --fix", From 78764fad5e79ea6785c03dcd3a821935597b3b44 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Fri, 10 Apr 2026 12:22:38 +0200 Subject: [PATCH 62/71] Update README.md --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4e7a006..d0037da 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,9 @@ TODO ## Executer les tests minimaux avec docker -`docker run -it --rm --ipc=host -v .:/app -w /app mcr.microsoft.com/playwright:v1.51.1-noble npm run test:dev01` -`docker run -it --rm --ipc=host -v .:/app -w /app mcr.microsoft.com/playwright:v1.51.1-noble npm run test:preprod` +```docker run -it --rm --ipc=host -v .:/app -w /app mcr.microsoft.com/playwright:v1.51.1-noble npm run test:dev01``` + +```docker run -it --rm --ipc=host -v .:/app -w /app mcr.microsoft.com/playwright:v1.51.1-noble npm run test:preprod``` ## Installation local From f3b1b0b06343d970f0ad96e29be7115bc4fbe40c Mon Sep 17 00:00:00 2001 From: Olivier D Date: Fri, 10 Apr 2026 12:23:03 +0200 Subject: [PATCH 63/71] Update README.md --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d0037da..b8c0f31 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,12 @@ TODO ## Executer les tests minimaux avec docker -```docker run -it --rm --ipc=host -v .:/app -w /app mcr.microsoft.com/playwright:v1.51.1-noble npm run test:dev01``` +```bash +docker run -it --rm --ipc=host -v .:/app -w /app mcr.microsoft.com/playwright:v1.51.1-noble npm run test:dev01 + -```docker run -it --rm --ipc=host -v .:/app -w /app mcr.microsoft.com/playwright:v1.51.1-noble npm run test:preprod``` +docker run -it --rm --ipc=host -v .:/app -w /app mcr.microsoft.com/playwright:v1.51.1-noble npm run test:preprod +``` ## Installation local From c8cfe105615b31f08eb8bc195c2df30ae341df02 Mon Sep 17 00:00:00 2001 From: Marc Date: Mon, 20 Apr 2026 15:02:05 +0200 Subject: [PATCH 64/71] Update deps, needs to use mcr.microsoft.com/playwright:v1.59.1-noble (#38) --- README.md | 4 ++-- package-lock.json | 55 ++++++++++++++++++++++++++--------------------- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index b8c0f31..8b42677 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,10 @@ TODO ## Executer les tests minimaux avec docker ```bash -docker run -it --rm --ipc=host -v .:/app -w /app mcr.microsoft.com/playwright:v1.51.1-noble npm run test:dev01 +docker run -it --rm --ipc=host -v .:/app -w /app mcr.microsoft.com/playwright:v1.59.1-noble npm run test:dev01 -docker run -it --rm --ipc=host -v .:/app -w /app mcr.microsoft.com/playwright:v1.51.1-noble npm run test:preprod +docker run -it --rm --ipc=host -v .:/app -w /app mcr.microsoft.com/playwright:v1.59.1-noble npm run test:preprod ``` ## Installation local diff --git a/package-lock.json b/package-lock.json index 4ed4618..2d45061 100644 --- a/package-lock.json +++ b/package-lock.json @@ -181,12 +181,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.51.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.51.1.tgz", - "integrity": "sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "playwright": "1.51.1" + "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" @@ -212,15 +213,15 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz", + "integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==", "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" } }, "node_modules/call-bind-apply-helpers": { @@ -337,9 +338,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -380,6 +381,7 @@ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -539,12 +541,13 @@ } }, "node_modules/playwright": { - "version": "1.51.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.1.tgz", - "integrity": "sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.51.1" + "playwright-core": "1.59.1" }, "bin": { "playwright": "cli.js" @@ -557,10 +560,11 @@ } }, "node_modules/playwright-core": { - "version": "1.51.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.1.tgz", - "integrity": "sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "dev": true, + "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, @@ -569,11 +573,14 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/typescript": { "version": "5.8.3", From 0eef49fd3ca04ab5679efaf44af76acd448e42ad Mon Sep 17 00:00:00 2001 From: Olivier D Date: Thu, 23 Apr 2026 17:18:39 +0200 Subject: [PATCH 65/71] Fix room creation (#40) * remove TCHAP_LEGACY env * fix room creation * renme preprod to int01 * handle expired certificate by default * fix biome errors * fix fixture * fix select room type --- .env.dev01 | 3 + .env.dev02 | 4 + .env.preprod => .env.int01 | 9 +- .env.sample | 3 - README.md | 10 +- fixtures/auth-fixture.ts | 8 +- package.json | 2 +- playwright.config.ts | 4 +- tests/auth/oidc/mas-login-oidc.spec.ts | 2 +- tests/auth/oidc/tchap-login-oidc.spec.ts | 4 +- tests/auth/password/mas-legacy.spec.ts | 6 +- .../auth/password/mas-login-password.spec.ts | 1 - .../password/tchap-login-password.spec.ts | 10 +- .../password/tchap-register-password.spec.ts | 3 - .../tchap-deactivated-account.spec.ts | 11 +- .../tchap-expiration.spec.ts | 4 - tests/minimal/minimal-scenario.spec.ts | 173 ++++++++++++------ tests/web/create-room.spec.ts | 6 +- utils/TchapAppPage.ts | 27 +++ utils/auth-helpers.ts | 17 +- utils/config.ts | 4 +- utils/keycloak-admin.ts | 4 +- utils/mailpit.ts | 2 +- utils/mas-admin.ts | 4 +- 24 files changed, 197 insertions(+), 124 deletions(-) rename .env.preprod => .env.int01 (81%) diff --git a/.env.dev01 b/.env.dev01 index 00b591a..2737d39 100644 --- a/.env.dev01 +++ b/.env.dev01 @@ -38,4 +38,7 @@ SCREENSHOTS_DIR=playwright-results/dev01 #Run tests in parallel TEST_IN_PARALLEL=false +#disable tls verification in nodeJS (required for axios to work with self signed certificate) +NODE_TLS_REJECT_UNAUTHORIZED = '0' + SYNAPSE_ADMIN_TOKEN= \ No newline at end of file diff --git a/.env.dev02 b/.env.dev02 index d8531e5..d5f2e04 100644 --- a/.env.dev02 +++ b/.env.dev02 @@ -36,7 +36,11 @@ BROWSER_LOCALE=fr-FR # Screenshots Directory SCREENSHOTS_DIR=playwright-results/dev02 + #Run tests in parallel TEST_IN_PARALLEL=false +#disable tls verification in nodeJS (required for axios to work with self signed certificate) +NODE_TLS_REJECT_UNAUTHORIZED = '0' + SYNAPSE_ADMIN_TOKEN= \ No newline at end of file diff --git a/.env.preprod b/.env.int01 similarity index 81% rename from .env.preprod rename to .env.int01 index 14bbf84..6393bbd 100644 --- a/.env.preprod +++ b/.env.int01 @@ -1,3 +1,5 @@ +#int01 (preprod) + # URLs MAS_URL=https://auth.i.tchap.gouv.fr #KEYCLOAK_URL=https://identite-sandbox.proconnect.gouv.fr/users/start-sign-in @@ -19,7 +21,7 @@ MAS_ADMIN_CLIENT_ID= MAS_ADMIN_CLIENT_SECRET= # Test User Credentials -TEST_USER_PREFIX=user.preprod +TEST_USER_PREFIX=user.int01 TEST_USER_PASSWORD=Test@123456sdksdfkljfs222 # Email domains for test users @@ -33,11 +35,12 @@ NUMERIQUE_EMAIL_DOMAIN=numerique.gouv.fr BROWSER_LOCALE=fr-FR # Screenshots Directory -SCREENSHOTS_DIR=playwright-results/preprod +SCREENSHOTS_DIR=playwright-results/int01 #Run tests in parallel TEST_IN_PARALLEL=false - +#disable tls verification in nodeJS (required for axios to work with self signed certificate) +NODE_TLS_REJECT_UNAUTHORIZED = '0' SYNAPSE_ADMIN_TOKEN= \ No newline at end of file diff --git a/.env.sample b/.env.sample index cea7a19..dddb604 100644 --- a/.env.sample +++ b/.env.sample @@ -9,9 +9,6 @@ MAIL_URL=https://mail.tchapgouv.com MAILPIT_USER = MAILPIT_PWD = -# old login page flow -TCHAP_LEGACY = false - # Keycloak Admin Credentials KEYCLOAK_ADMIN_USERNAME=admin KEYCLOAK_ADMIN_PASSWORD= diff --git a/README.md b/README.md index 8b42677..1e94478 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,7 @@ TODO ```bash docker run -it --rm --ipc=host -v .:/app -w /app mcr.microsoft.com/playwright:v1.59.1-noble npm run test:dev01 - - -docker run -it --rm --ipc=host -v .:/app -w /app mcr.microsoft.com/playwright:v1.59.1-noble npm run test:preprod +docker run -it --rm --ipc=host -v .:/app -w /app mcr.microsoft.com/playwright:v1.59.1-noble npm run test:int01 ``` ## Installation local @@ -32,7 +30,7 @@ chmod +x init.sh Les tests utilisent un fichier `.env` pour la configuration. Vous pouvez modifier ce fichier pour adapter les tests à votre environnement. -Requis pour dev01 et preprod: +Requis pour dev01 et int01 (preprod): `MAILPIT_PWD=` mailpit password ## Exécution les tests @@ -42,8 +40,8 @@ Requis pour dev01 et preprod: ENV=local npm run test tests/auth --retries=2 -# Exécuter les tests minimaux sur dev ou preprod -ENV=preprod npm run test tests/minimal +# Exécuter les tests minimaux sur dev ou int01 (preprod) +ENV=int01 npm run test tests/minimal ENV=dev01 npm run test tests/minimal ``` diff --git a/fixtures/auth-fixture.ts b/fixtures/auth-fixture.ts index dd1cfac..5d1ccab 100644 --- a/fixtures/auth-fixture.ts +++ b/fixtures/auth-fixture.ts @@ -1,4 +1,4 @@ -import { test as base, Browser, type Page, type TestInfo } from '@playwright/test'; +import { test as base, type Page, type TestInfo } from '@playwright/test'; import { createKeycloakTestUser, cleanupKeycloakTestUser, @@ -14,8 +14,8 @@ import { waitForMasUser, } from '../utils/mas-admin'; import { generateTestUserData } from '../utils/auth-helpers'; -import fs from 'fs'; -import path from 'path'; +import fs from 'node:fs'; +import path from 'node:path'; import { SCREENSHOTS_DIR } from '../utils/config'; import { @@ -77,7 +77,7 @@ export type AuthenticatedUserFixture = ( ) => Promise; async function screenCheckerFixture( - {}: {}, + {}, use: (screenChecker: ScreenCheckerFixture) => Promise, testInfo: TestInfo ) { diff --git a/package.json b/package.json index 14efd3d..633a29b 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "test:ui": "playwright test --ui", "test:local": "ENV=local playwright test", "test:dev01": "ENV=dev01 playwright test ./tests/minimal/", - "test:preprod": "ENV=preprod playwright test ./tests/minimal/", + "test:int01": "ENV=int01 playwright test ./tests/minimal/", "lint": "biome lint .", "lint:fix": "biome lint . --fix", "format": "biome format .", diff --git a/playwright.config.ts b/playwright.config.ts index b809b32..fc4d0ec 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ /* Maximum time one test can run for */ timeout: 15 * 1000, /* Run tests in files in parallel */ - fullyParallel: process.env.TEST_IN_PARALLEL === 'true' ? true : false, + fullyParallel: process.env.TEST_IN_PARALLEL === 'true', /* Define how many workers */ // Limit the number of workers on CI, use default locally @@ -22,7 +22,7 @@ export default defineConfig({ /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, + retries: process.env.CI ? 2 : 2, /* Reporter to use */ reporter: 'html', /* Shared settings for all the projects below */ diff --git a/tests/auth/oidc/mas-login-oidc.spec.ts b/tests/auth/oidc/mas-login-oidc.spec.ts index 7b76dcc..6ddd2ed 100644 --- a/tests/auth/oidc/mas-login-oidc.spec.ts +++ b/tests/auth/oidc/mas-login-oidc.spec.ts @@ -111,7 +111,7 @@ test.describe('MAS Login OIDC', () => { ); oidcUser.masId = await createMasUserWithPassword( - oidcUser.username + 'different_from_email', + `${oidcUser.username}different_from_email`, old_email, oidcUser.password ); diff --git a/tests/auth/oidc/tchap-login-oidc.spec.ts b/tests/auth/oidc/tchap-login-oidc.spec.ts index f5582ca..774b02c 100644 --- a/tests/auth/oidc/tchap-login-oidc.spec.ts +++ b/tests/auth/oidc/tchap-login-oidc.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from '../../../fixtures/auth-fixture'; import { verifyUserInMas, performOidcLoginFromTchap } from '../../../utils/auth-helpers'; import { checkMasUserExistsByEmail, createMasUserWithPassword } from '../../../utils/mas-admin'; -import { SCREENSHOTS_DIR, TCHAP_LEGACY } from '../../../utils/config'; +import { SCREENSHOTS_DIR } from '../../../utils/config'; //flaky on await expect(page.locator('text=Configuration')).toBeVisible({timeout: 20000}); test.describe('Tchap : Login via OIDC', () => { @@ -19,7 +19,7 @@ test.describe('Tchap : Login via OIDC', () => { expect(existsBeforeLogin).toBe(true); // Perform the OIDC login flow - await performOidcLoginFromTchap(page, oidcUser, screenshot_path, TCHAP_LEGACY); + await performOidcLoginFromTchap(page, oidcUser, screenshot_path); // Take a screenshot of the authenticated state await page.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path}/05-confirmation.png` }); diff --git a/tests/auth/password/mas-legacy.spec.ts b/tests/auth/password/mas-legacy.spec.ts index 1df29d6..2a5e278 100644 --- a/tests/auth/password/mas-legacy.spec.ts +++ b/tests/auth/password/mas-legacy.spec.ts @@ -19,7 +19,7 @@ test.describe('Tchap : Legacy SSO Flow', () => { expect(response1.status()).toEqual(303); // Get the first redirect location (should be complete-compat-sso) - const completeCompatSsoUrl = response1.headers()['location']; + const completeCompatSsoUrl = response1.headers().location; expect(completeCompatSsoUrl).toBeTruthy(); console.log(`[Legacy SSO] First redirect to: ${completeCompatSsoUrl}`); @@ -30,14 +30,14 @@ test.describe('Tchap : Legacy SSO Flow', () => { // Step 2: Follow the first redirect to complete-compat-sso console.log(`[Legacy SSO] Step 2: Following redirect to complete-compat-sso`); - const response2 = await request.get(completeCompatSsoUrl!, { + const response2 = await request.get(completeCompatSsoUrl, { maxRedirects: 0, }); console.log(`[Legacy SSO] Response 2 status: ${response2.status()}`); expect(response2.status()).toEqual(303); // Get the second redirect location (should be register page) - const registerUrl = response2.headers()['location']; + const registerUrl = response2.headers().location; expect(registerUrl).toBeTruthy(); console.log(`[Legacy SSO] Second redirect to: ${registerUrl}`); diff --git a/tests/auth/password/mas-login-password.spec.ts b/tests/auth/password/mas-login-password.spec.ts index 3db7562..723e365 100644 --- a/tests/auth/password/mas-login-password.spec.ts +++ b/tests/auth/password/mas-login-password.spec.ts @@ -3,7 +3,6 @@ import { createMasTestUser, cleanupMasTestUser, performPasswordLogin, - TestUser, } from '../../../utils/auth-helpers'; import { SCREENSHOTS_DIR } from '../../../utils/config'; diff --git a/tests/auth/password/tchap-login-password.spec.ts b/tests/auth/password/tchap-login-password.spec.ts index cd6c299..63c8815 100644 --- a/tests/auth/password/tchap-login-password.spec.ts +++ b/tests/auth/password/tchap-login-password.spec.ts @@ -1,12 +1,6 @@ import { test, expect } from '../../../fixtures/auth-fixture'; -import { - checkMasUserExistsByEmail, - createMasUserWithPassword, - deactivateMasUser, -} from '../../../utils/mas-admin'; -import { SCREENSHOTS_DIR, ELEMENT_URL, MAS_URL } from '../../../utils/config'; -import { Page } from '@playwright/test'; -import { loginWithPassword } from '../../../utils/auth-helpers'; +import { checkMasUserExistsByEmail, createMasUserWithPassword } from '../../../utils/mas-admin'; +import { SCREENSHOTS_DIR, ELEMENT_URL } from '../../../utils/config'; test.describe('Tchap : Login password', () => { test('tchap login with password and login_hint', async ({ page, userData, screenChecker }) => { diff --git a/tests/auth/password/tchap-register-password.spec.ts b/tests/auth/password/tchap-register-password.spec.ts index a518a7e..1aad532 100644 --- a/tests/auth/password/tchap-register-password.spec.ts +++ b/tests/auth/password/tchap-register-password.spec.ts @@ -12,7 +12,6 @@ test.describe('Tchap : register with password', () => { const PASSWORd = 'sdf78qsd!9090ssss'; test('tchap register with oidc native', async ({ - context, page, userData: user, screenChecker: screen, @@ -123,8 +122,6 @@ test.describe('Tchap : register with password', () => { }); test('when user already exists', async ({ - page, - context, browser, screenChecker: screen, startTchapRegisterWithEmail, diff --git a/tests/email-account-validity/tchap-deactivated-account.spec.ts b/tests/email-account-validity/tchap-deactivated-account.spec.ts index 4a097bb..7f7a04e 100644 --- a/tests/email-account-validity/tchap-deactivated-account.spec.ts +++ b/tests/email-account-validity/tchap-deactivated-account.spec.ts @@ -1,13 +1,11 @@ import { test, expect } from '../../fixtures/auth-fixture'; import { addUserEmail, - checkMasUserExistsByEmail, createMasUserWithPassword, deactivateMasUser, deleteOauthLink, getMasUserByEmail, getOauthLinkBySubject, - getUserEmail, oauthLinkExistsBySubject, reactivateMasUser, } from '../../utils/mas-admin'; @@ -107,7 +105,6 @@ test.describe('Tchap : Login password', () => { test('match account by email when former account is deactivated but another one is valid', async ({ page, oidcUser, - screenChecker, }) => { const screenshot_path = test.info().title.replace(' ', '_'); @@ -121,7 +118,7 @@ test.describe('Tchap : Login password', () => { await deactivateMasUser(formerTchapAccountMasId); const newTchapAccountWithIndex = { - username: oidcUser.username + '2', + username: `${oidcUser.username}2`, email: oidcUser.email, password: oidcUser.password, masId: '', @@ -149,7 +146,7 @@ test.describe('Tchap : Login password', () => { // Verify the user in MAS is linked to the indexed account const userAfterLogin = await getMasUserByEmail(newTchapAccountWithIndex.email); expect(userAfterLogin.id).toBe(newTchapAccountWithIndex.masId); - expect(userAfterLogin.attributes['username']).toBe(newTchapAccountWithIndex.username); + expect(userAfterLogin.attributes.username).toBe(newTchapAccountWithIndex.username); await expect(page.locator(`text=${newTchapAccountWithIndex.username}`)).toBeVisible(); expect(await oauthLinkExistsBySubject(oidcUser.username)).toBe(true); @@ -250,7 +247,7 @@ test.describe('Tchap : Login password', () => { // SUPPORT PROCESS PERFORMED BY BOT ADMIN const links = await getOauthLinkBySubject(oidcUser.username); - await deleteOauthLink(links[0]['id']); + await deleteOauthLink(links[0].id); await reactivateMasUser(oidcUser.masId); await addUserEmail(oidcUser.masId, oidcUser.email); // END OF SUPPORT PROCESS @@ -264,7 +261,7 @@ test.describe('Tchap : Login password', () => { //RESTART ANOTHER OIDC LOGIN await page2.goto('/login'); - const screenshot_path_2 = screenshot_path + '_2'; + const screenshot_path_2 = `${screenshot_path}_2`; // Take a screenshot of the login page await page2.screenshot({ path: `${SCREENSHOTS_DIR}/${screenshot_path_2}/01-login-page.png` }); diff --git a/tests/email-account-validity/tchap-expiration.spec.ts b/tests/email-account-validity/tchap-expiration.spec.ts index b466688..cbb219a 100644 --- a/tests/email-account-validity/tchap-expiration.spec.ts +++ b/tests/email-account-validity/tchap-expiration.spec.ts @@ -9,11 +9,7 @@ import { STANDARD_EMAIL_DOMAIN } from '../../utils/config'; import { setAccountExpiration } from '../../utils/synapse-admin'; test.describe('Tchap : account expiration', () => { - // Define the password for test users - const PASSWORD = 'Test123456sdksdfkljfs222!'; - test('should show expiration message when account is expired', async ({ - context, page, request, screenChecker: screen, diff --git a/tests/minimal/minimal-scenario.spec.ts b/tests/minimal/minimal-scenario.spec.ts index e818ddd..d69ff00 100644 --- a/tests/minimal/minimal-scenario.spec.ts +++ b/tests/minimal/minimal-scenario.spec.ts @@ -4,28 +4,115 @@ import { generateTestUserData, openResetPasswordEmail, } from '../../utils/auth-helpers'; -import { - ELEMENT_URL, - INVITED_EMAIL_DOMAIN, - STANDARD_EMAIL_DOMAIN, - USE_MAS, -} from '../../utils/config'; +import { ELEMENT_URL, INVITED_EMAIL_DOMAIN, STANDARD_EMAIL_DOMAIN } from '../../utils/config'; import { getLatestVerificationCode, waitForMessage } from '../../utils/mailpit'; -import path from 'path'; +import { TchapAppPage } from '../../utils/TchapAppPage'; +import path from 'node:path'; +import type { Page } from '@playwright/test'; //this scenario is one big test to cover all the scenario on a not MAS synapse (dev02 - a) and one MAS synapse (ext01 - e) +// Helper function to create a public room +async function createPublicRoom(page: Page, roomName: string): Promise { + const appPage = new TchapAppPage(page); + await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Nouveau salon', exact: true }).click(); + await page.getByRole('textbox', { name: 'Nom' }).fill(roomName); + await appPage.selectRoomType('Salon public'); + + await page.getByRole('button', { name: 'Créer un nouveau salon' }).click(); + await expect(page.locator('button').filter({ hasText: roomName })).toBeVisible(); + + // Write in the public room + await page.locator('.mx_BasicMessageComposer').getByRole('textbox').fill('message non chiffré'); + await page.getByRole('button', { name: 'Envoyer le message' }).click(); + await expect(page.getByRole('status', { name: 'Votre message a été envoyé' })).toBeVisible(); + + return roomName; +} + +// Helper function to create an encrypted private room +async function createEncryptedPrivateRoom(page: Page, roomName: string): Promise { + const appPage = new TchapAppPage(page); + await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); + await page.getByText('Nouveau salon').click(); + await page.getByRole('textbox', { name: 'Nom' }).fill(roomName); + await appPage.selectRoomType('Salon privé sécurisé'); + await page.getByRole('button', { name: 'Créer un nouveau salon' }).click(); + + // Write in the encrypted private room + await page.locator('.mx_BasicMessageComposer').getByRole('textbox').fill('message chiffré'); + await page.getByRole('button', { name: 'Envoyer le message' }).click(); + await expect(page.getByRole('status', { name: 'Votre message a été envoyé' })).toBeVisible(); + + // Verify parameters + await page.locator('button').filter({ hasText: roomName }).click(); + await page.getByRole('menuitem', { name: 'Paramètres' }).click(); + await page.getByText('Vie privée').click(); + await expect(page.getByRole('radio', { name: 'salon privé' })).toBeChecked(); + await page.getByRole('button', { name: 'Fermer la boîte de dialogue' }).click(); + + return roomName; +} + +// Helper function to create an unencrypted private room +async function createUnencryptedPrivateRoom(page: Page, roomName: string): Promise { + const appPage = new TchapAppPage(page); + await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); + await page.getByText('Nouveau salon').click(); + await page.getByRole('textbox', { name: 'Nom' }).fill(roomName); + await appPage.selectRoomType('Salon privé'); + await page.getByRole('button', { name: 'Créer un nouveau salon' }).click(); + + // Write in the unencrypted private room + await expect(page.locator('button').filter({ hasText: 'Non chiffré' })).toBeVisible(); + await expect(page.getByText('Le chiffrement de bout en bout n')).toBeVisible(); + await page.locator('.mx_BasicMessageComposer').getByRole('textbox').fill('message non chiffré'); + await page.getByRole('button', { name: 'Envoyer le message' }).click(); + await expect(page.getByRole('status', { name: 'Votre message a été envoyé' })).toBeVisible(); + + // Verify parameters + await page.locator('button').filter({ hasText: roomName }).click(); + await page.getByRole('menuitem', { name: 'Paramètres' }).click(); + await page.getByText('Vie privée').click(); + await expect(page.getByRole('radio', { name: 'salon privé' })).toBeChecked(); + await page.getByRole('button', { name: 'Fermer la boîte de dialogue' }).click(); + + return roomName; +} + +// Helper function to create an external private room +async function createExternalPrivateRoom( + page: Page, + roomName: string = 'Salon ouvert aux externes' +): Promise { + const appPage = new TchapAppPage(page); + await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); + await page.getByText('Nouveau salon').click(); + await page.getByLabel('Créer un salon').click(); + await appPage.selectRoomType('Salon privé sécurisé avec externes'); + await page.getByRole('textbox', { name: 'Nom' }).fill(roomName); + await page.getByRole('button', { name: 'Créer un nouveau salon' }).click(); + + return roomName; +} + test.describe .serial('Minimal scenario', () => { test.setTimeout(120_000); const external_user = generateTestUserData(INVITED_EMAIL_DOMAIN); const agent_user = generateTestUserData(STANDARD_EMAIL_DOMAIN); + + const public_room_name = generateRoomName('public_room_name'); + const private_crypted_room_name = generateRoomName('private_crypted_room_name'); + const private_uncrypted_room_name = generateRoomName('private_uncrypted_room_name'); /* * tested: * creer compte agent * creer salon privé - * creer forum public + * creer salon privé non chiffré + * creer salon public * creer salon privé ouverts aux externes * inviter un externe * envoyer fichier, fichier vérolé @@ -37,15 +124,13 @@ test.describe * TODO : A. expirer le compte, vérifier que les clients affichent un truc cohérent, */ - test('test all', async ({ page, context, screenChecker, browser }) => { + test('internal user', async ({ page, context, screenChecker }) => { const invitee1_search_name = 'olivier test1'; // TODO : ensure that invitee exists in the environment const invitee1_display_name = 'Olivier Test1'; // TODO : ensure that invitee exists in the environment const invitee2_email = 'testeur@agent2.tchap.incubateur.net'; // TODO : ensure that invitee exists in the environment const invitee2_display_name = 'Testeur [Incubateur]'; // TODO : ensure that invitee exists in the environment - const public_room_name = generateRoomName('Forum'); - const room_name = generateRoomName('Salon Privé_'); // Grant clipboard permissions to browser context await context.grantPermissions(['clipboard-read', 'clipboard-write']); @@ -108,53 +193,34 @@ test.describe await screenChecker(page, '#/home'); - //await page.getByRole('button', { name: 'OK' }).click(); - //await page.getByRole('button', { name: 'OK' }).click(); + //click on + if (await page.getByRole('button', { name: 'OK' }).isVisible()) { + await page.getByRole('button', { name: 'OK' }).click(); + } + // Faire la même chose une deuxième fois + if (await page.getByRole('button', { name: 'OK' }).isVisible()) { + await page.getByRole('button', { name: 'OK' }).click(); + } - //creer salon public - await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); - await page.getByRole('menuitem', { name: 'Nouveau salon', exact: true }).click(); - const dialog = page.locator('.tc_TchapCreateRoomDialog'); - await page.getByRole('textbox', { name: 'Nom' }).fill(public_room_name); - await dialog - .locator('.tc_TchapRoomTypeSelector_RadioButton_title') - .getByText('Forum') - .click(); - await dialog.getByRole('button', { name: 'Créer un nouveau salon' }).click(); - await expect(page.locator('button').filter({ hasText: public_room_name })).toBeVisible(); + if (await page.getByRole('button', { name: 'Ignorer' }).isVisible()) { + await page.getByRole('button', { name: 'Ignorer' }).click(); + } - //ecrire dans le salon public - await page - .locator('.mx_BasicMessageComposer') - .getByRole('textbox') - .fill('message non chiffré'); - await page.getByRole('button', { name: 'Envoyer le message' }).click(); - await expect(page.getByRole('status', { name: 'Votre message a été envoyé' })).toBeVisible(); + //creer salon public + await createPublicRoom(page, public_room_name); //chercher salon public await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); - await page.getByRole('menuitem', { name: 'Rejoindre un forum', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Rejoindre un salon public', exact: true }).click(); await page.getByRole('textbox', { name: 'Rechercher' }).fill(public_room_name); await expect(page.getByLabel('Suggestions').getByText(public_room_name)).toBeVisible(); await page.getByRole('textbox', { name: 'Rechercher' }).press('Escape'); //creer salon privé - await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); - await page.getByText('Nouveau salon').click(); - await page.getByRole('textbox', { name: 'Nom' }).fill(room_name); - await page.getByRole('button', { name: 'Créer un nouveau salon' }).click(); - - //ecrire dans le salon privé - await page.locator('.mx_BasicMessageComposer').getByRole('textbox').fill('message chiffré'); - await page.getByRole('button', { name: 'Envoyer le message' }).click(); - await expect(page.getByRole('status', { name: 'Votre message a été envoyé' })).toBeVisible(); - - //vérfier les parametres du salon privé - await page.locator('button').filter({ hasText: room_name }).click(); - await page.getByRole('menuitem', { name: 'Paramètres' }).click(); - await page.getByText('Vie privée').click(); - await expect(page.getByRole('radio', { name: 'salon privé' })).toBeChecked(); - await page.getByRole('button', { name: 'Fermer la boîte de dialogue' }).click(); + await createEncryptedPrivateRoom(page, private_crypted_room_name); + + //creer salon privé non chiffré + await createUnencryptedPrivateRoom(page, private_uncrypted_room_name); //inviter agents by name await page.getByRole('button', { name: 'Personnes' }).click(); @@ -191,12 +257,7 @@ test.describe await page.getByRole('listitem').filter({ hasText: /^Contenu bloqué$/ }); //creer salon privé ouvert aux externes - await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); - await page.getByText('Nouveau salon').click(); - await page.getByLabel('Créer un salon').click(); - await page.getByRole('radio', { name: 'Salon ouvert aux externes' }); - await page.getByRole('textbox', { name: 'Nom' }).fill('Salon ouvert aux externes'); - await page.getByRole('button', { name: 'Créer un nouveau salon' }).click(); + await createExternalPrivateRoom(page); //inviter agent externe await page.getByRole('button', { name: 'Personnes' }).click(); @@ -226,7 +287,7 @@ test.describe //await openResetPasswordEmailLegacy(context, screenChecker, agent_user.email); const resetPwdPage = await openResetPasswordEmail(context, screenChecker, agent_user.email); - const newPassword = agent_user.password + '4'; + const newPassword = `${agent_user.password}4`; await resetPwdPage.locator('input[name="new_password"]').fill(newPassword); await resetPwdPage.locator('input[name="new_password_again"]').fill(newPassword); await resetPwdPage.locator('body').click({ position: { x: 0, y: 0 } }); //unfocus field @@ -242,7 +303,9 @@ test.describe await expect( resetPwdPage.getByRole('link').filter({ hasText: 'Continuer dans Tchap' }) ).toBeVisible(); + }); + test('external user', async ({ screenChecker, browser }) => { const context_ext = await browser.newContext(); const page_ext = await context_ext.newPage(); diff --git a/tests/web/create-room.spec.ts b/tests/web/create-room.spec.ts index 768a7c7..ca351df 100644 --- a/tests/web/create-room.spec.ts +++ b/tests/web/create-room.spec.ts @@ -24,7 +24,7 @@ test.describe('Create Room', () => { // In local test An error dialog should appear first complaining about wss socket and SSL certificate error // So not really working locally - if (env == 'local') { + if (env === 'local') { await page.getByRole('button').getByText('OK').click(); } else { // Takes some time to appear @@ -66,7 +66,7 @@ test.describe('Create Room', () => { // In local test An error dialog should appear first complaining about wss socket and SSL certificate error // So not really working locally - if (env == 'local') { + if (env === 'local') { await page.getByRole('button').getByText('OK').click(); } else { // Takes some time @@ -108,7 +108,7 @@ test.describe('Create Room', () => { // In local test An error dialog should appear first complaining about wss socket and SSL certificate error // So not really working locally - if (env == 'local') { + if (env === 'local') { await page.getByRole('button').getByText('OK').click(); } else { // Takes some time diff --git a/utils/TchapAppPage.ts b/utils/TchapAppPage.ts index 60dcafc..ffafb56 100644 --- a/utils/TchapAppPage.ts +++ b/utils/TchapAppPage.ts @@ -188,4 +188,31 @@ export class TchapAppPage { await this.page.mouse.wheel(0, 1000); } while (await needsScroll()); } + + /** + * Select a room type in the Tchap create room dialog. + * This function uses a robust selector to avoid flaky tests when selecting + * between similar room type options (e.g., "Salon privé" vs "Salon privé sécurisé") + * + * @param roomType The exact room type to select + */ + public async selectRoomType( + roomType: + 'Salon privé' + | 'Salon privé sécurisé' + | 'Salon privé sécurisé avec externes' + | 'Salon public' + ): Promise { + const dialog = this.page.locator('.tc_TchapCreateRoomDialog'); + const radioButtonTitles = dialog.locator('.tc_TchapRoomTypeSelector_RadioButton_title'); + + // Use filter with regex to match the exact room type text + // This is more robust than getByText() as it properly handles similar strings + const selectedButton = radioButtonTitles.filter({ + hasText: new RegExp(`^${roomType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`), + }); + + // Click the button + await selectedButton.click(); + } } diff --git a/utils/auth-helpers.ts b/utils/auth-helpers.ts index db1b73b..76b022f 100644 --- a/utils/auth-helpers.ts +++ b/utils/auth-helpers.ts @@ -1,4 +1,4 @@ -import { type BrowserContext, expect, Frame, type Page } from '@playwright/test'; +import { type BrowserContext, expect, type Page } from '@playwright/test'; import { createKeycloakUser, deleteKeycloakUser } from './keycloak-admin'; import { waitForMasUser, createMasUserWithPassword, deactivateMasUser } from './mas-admin'; import { @@ -115,8 +115,7 @@ export async function performOidcLogin( export async function performOidcLoginFromTchap( page: Page, user: TestUser, - screenshot_path: string, - tchap_legacy: boolean = false + screenshot_path: string ): Promise { //we go to the welcome and then to the login page because sometimes the email field disapears await page.goto(`${ELEMENT_URL}/#/welcome`, { waitUntil: 'networkidle' }); @@ -312,11 +311,7 @@ export async function loginWithPassword( /** * Same as performPasswordLogin but without the screenshots */ -export async function performSimplePasswordLogin( - page: Page, - user: TestUser, - screenshot_path: string -): Promise { +export async function performSimplePasswordLogin(page: Page, user: TestUser): Promise { console.log(`[Auth] Performing password login for user: ${user.username}`); // Navigate to the login page @@ -336,14 +331,14 @@ export async function performSimplePasswordLogin( } export function generateRoomName(prefix: string) { - const timestamp = new Date().getTime(); + const timestamp = Date.now(); const randomSuffix = Math.floor(Math.random() * 10000); - return `prefix_${timestamp}_${randomSuffix}`; + return `${prefix}_${timestamp}_${randomSuffix}`; } // Generate a unique username and email for testing export function generateTestUserData(domain: string): TestUser { - const timestamp = new Date().getTime(); + const timestamp = Date.now(); const randomSuffix = Math.floor(Math.random() * 10000); const username = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}`; const localpart = `${TEST_USER_PREFIX}_${timestamp}_${randomSuffix}-${domain}`; diff --git a/utils/config.ts b/utils/config.ts index 186463a..039acf0 100644 --- a/utils/config.ts +++ b/utils/config.ts @@ -1,5 +1,5 @@ import dotenv from 'dotenv'; -import path from 'path'; +import path from 'node:path'; // Determine which environment to use export const env = process.env.ENV || 'local'; @@ -21,8 +21,6 @@ export const MAIL_URL = process.env.MAIL_URL || ''; export const MAILPIT_USER = process.env.MAILPIT_USER || ''; export const MAILPIT_PWD = process.env.MAILPIT_PWD || ''; -export const TCHAP_LEGACY: boolean = Boolean(process.env.TCHAP_LEGACY); - // Keycloak Admin Credentials export const KEYCLOAK_ADMIN_USERNAME = process.env.KEYCLOAK_ADMIN_USERNAME || 'admin'; export const KEYCLOAK_ADMIN_PASSWORD = process.env.KEYCLOAK_ADMIN_PASSWORD || 'admin'; diff --git a/utils/keycloak-admin.ts b/utils/keycloak-admin.ts index b331515..067172e 100644 --- a/utils/keycloak-admin.ts +++ b/utils/keycloak-admin.ts @@ -24,7 +24,9 @@ async function getApiContext(): Promise { * Get an admin access token for Keycloak */ export async function getKeycloakAdminToken(): Promise { - console.log(`[Keycloak API] Requesting admin token with username: ${KEYCLOAK_ADMIN_USERNAME}`); + console.log( + `[Keycloak API] Requesting admin token with username: ${KEYCLOAK_ADMIN_USERNAME} at url ${KEYCLOAK_URL}` + ); const apiRequestContext = await getApiContext(); const response = await apiRequestContext.post('/realms/master/protocol/openid-connect/token', { diff --git a/utils/mailpit.ts b/utils/mailpit.ts index c8c8935..9e7e3c5 100644 --- a/utils/mailpit.ts +++ b/utils/mailpit.ts @@ -18,7 +18,7 @@ function extractCodeFromContent(content: string): string { export async function getMailpitClient() { const mailpit = await new MailpitClient( MAIL_URL, - MAILPIT_USER != '' ? { username: MAILPIT_USER, password: MAILPIT_PWD } : undefined + MAILPIT_USER !== '' ? { username: MAILPIT_USER, password: MAILPIT_PWD } : undefined ); await mailpit.getInfo(); return mailpit; diff --git a/utils/mas-admin.ts b/utils/mas-admin.ts index 728dce9..3d41ec9 100644 --- a/utils/mas-admin.ts +++ b/utils/mas-admin.ts @@ -291,7 +291,7 @@ export async function oauthLinkExistsByUserId(userId: string): Promise //console.log(data.data) const links = data.data; console.log(`[MAS API] Oauth links for user ${userId} : ${JSON.stringify(links)}`); - return links.length == 1; + return links.length === 1; } /** @@ -319,7 +319,7 @@ export async function oauthLinkExistsBySubject(subject: string): Promise Date: Mon, 27 Apr 2026 15:00:17 +0200 Subject: [PATCH 66/71] update verification code in tests --- utils/mailpit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/mailpit.ts b/utils/mailpit.ts index 9e7e3c5..1d2b9a1 100644 --- a/utils/mailpit.ts +++ b/utils/mailpit.ts @@ -34,7 +34,7 @@ export async function getLatestVerificationCode(toEmail: string): Promise Date: Mon, 27 Apr 2026 17:49:25 +0200 Subject: [PATCH 67/71] fix logout test --- tests/auth/logout/tchap-logout.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/auth/logout/tchap-logout.spec.ts b/tests/auth/logout/tchap-logout.spec.ts index ce07eee..207d859 100644 --- a/tests/auth/logout/tchap-logout.spec.ts +++ b/tests/auth/logout/tchap-logout.spec.ts @@ -41,7 +41,7 @@ test.describe('Tchap : logout', () => { await page.getByRole('button').filter({ hasText: 'Continuer' }).click(); // Success - Confirm identity - await expect(page.locator('text=Confirmez votre identité')).toBeVisible({ timeout: 20000 }); + await expect(page.getByRole('button').filter({hasText: 'Vérification impossible ?'})).toBeVisible({ timeout: 20000 }); await screenChecker(page, `/`); }); }); From 0712ff8182a415364d378efeed792ded19216b8d Mon Sep 17 00:00:00 2001 From: olivierdelcroix Date: Tue, 28 Apr 2026 10:53:39 +0200 Subject: [PATCH 68/71] add int02 --- .env.int02 | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .env.int02 diff --git a/.env.int02 b/.env.int02 new file mode 100644 index 0000000..edae58d --- /dev/null +++ b/.env.int02 @@ -0,0 +1,46 @@ +#int01 (preprod) + +# URLs +MAS_URL=https://auth.a.tchap.gouv.fr +#KEYCLOAK_URL=https://identite-sandbox.proconnect.gouv.fr/users/start-sign-in +ELEMENT_URL=https://www.beta.tchap.gouv.fr +BASE_URL=https://matrix.a.tchap.gouv.fr +MAIL_URL=https://yop.tchap.incubateur.net + +# mailpit user credentials +MAILPIT_USER=tchap +MAILPIT_PWD= + +# Keycloak Admin Credentials +#KEYCLOAK_ADMIN_USERNAME=admin +#KEYCLOAK_ADMIN_PASSWORD=admin +#KEYCLOAK_REALM=proconnect-mock + +# MAS Admin API Credentials +MAS_ADMIN_CLIENT_ID= +MAS_ADMIN_CLIENT_SECRET= + +# Test User Credentials +TEST_USER_PREFIX=user.int02 +TEST_USER_PASSWORD=Test@123456sdksdfkljfs222 + +# Email domains for test users +STANDARD_EMAIL_DOMAIN=yop2.tchap.incubateur.net +INVITED_EMAIL_DOMAIN=yopext.tchap.incubateur.net +NOT_INVITED_EMAIL_DOMAIN=gmail.com +WRONG_SERVER_EMAIL_DOMAIN=wrong.server.com +NUMERIQUE_EMAIL_DOMAIN=numerique.gouv.fr + +# Browser Locale +BROWSER_LOCALE=fr-FR + +# Screenshots Directory +SCREENSHOTS_DIR=playwright-results/int01 + +#Run tests in parallel +TEST_IN_PARALLEL=false + +#disable tls verification in nodeJS (required for axios to work with self signed certificate) +NODE_TLS_REJECT_UNAUTHORIZED = '0' + +SYNAPSE_ADMIN_TOKEN= \ No newline at end of file From 86e86b1660a997c7e5be03030ea8f457285e6b18 Mon Sep 17 00:00:00 2001 From: olivierdelcroix Date: Wed, 29 Apr 2026 10:33:11 +0200 Subject: [PATCH 69/71] move module into synapse folder --- .../tchap-deactivated-account.spec.ts | 10 +++++----- .../email-account-validity/tchap-expiration.spec.ts | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) rename tests/{ => synapse}/email-account-validity/tchap-deactivated-account.spec.ts (97%) rename tests/{ => synapse}/email-account-validity/tchap-expiration.spec.ts (93%) diff --git a/tests/email-account-validity/tchap-deactivated-account.spec.ts b/tests/synapse/email-account-validity/tchap-deactivated-account.spec.ts similarity index 97% rename from tests/email-account-validity/tchap-deactivated-account.spec.ts rename to tests/synapse/email-account-validity/tchap-deactivated-account.spec.ts index 7f7a04e..ecbf475 100644 --- a/tests/email-account-validity/tchap-deactivated-account.spec.ts +++ b/tests/synapse/email-account-validity/tchap-deactivated-account.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../../fixtures/auth-fixture'; +import { test, expect } from '../../../fixtures/auth-fixture'; import { addUserEmail, createMasUserWithPassword, @@ -8,10 +8,10 @@ import { getOauthLinkBySubject, oauthLinkExistsBySubject, reactivateMasUser, -} from '../../utils/mas-admin'; -import { SCREENSHOTS_DIR, ELEMENT_URL } from '../../utils/config'; -import { performOidcLogin } from '../../utils/auth-helpers'; -import { getLatestVerificationCode } from '../../utils/mailpit'; +} from '../../../utils/mas-admin'; +import { SCREENSHOTS_DIR, ELEMENT_URL } from '../../../utils/config'; +import { performOidcLogin } from '../../../utils/auth-helpers'; +import { getLatestVerificationCode } from '../../../utils/mailpit'; test.describe('Tchap : Login password', () => { test('password login when account is deactivated displays "Identifiants Invalides"', async ({ diff --git a/tests/email-account-validity/tchap-expiration.spec.ts b/tests/synapse/email-account-validity/tchap-expiration.spec.ts similarity index 93% rename from tests/email-account-validity/tchap-expiration.spec.ts rename to tests/synapse/email-account-validity/tchap-expiration.spec.ts index cbb219a..83b7c41 100644 --- a/tests/email-account-validity/tchap-expiration.spec.ts +++ b/tests/synapse/email-account-validity/tchap-expiration.spec.ts @@ -1,12 +1,12 @@ -import { test, expect } from '../../fixtures/auth-fixture'; +import { test, expect } from '../../../fixtures/auth-fixture'; import { createMasTestUser, loginWithPassword, openRenewAccountEmail, -} from '../../utils/auth-helpers'; -import { getMasUserByEmail } from '../../utils/mas-admin'; -import { STANDARD_EMAIL_DOMAIN } from '../../utils/config'; -import { setAccountExpiration } from '../../utils/synapse-admin'; +} from '../../../utils/auth-helpers'; +import { getMasUserByEmail } from '../../../utils/mas-admin'; +import { STANDARD_EMAIL_DOMAIN } from '../../../utils/config'; +import { setAccountExpiration } from '../../../utils/synapse-admin'; test.describe('Tchap : account expiration', () => { test('should show expiration message when account is expired', async ({ From 14668809c382da94e3bbfbf823156c27992ff0c5 Mon Sep 17 00:00:00 2001 From: olivierdelcroix Date: Thu, 7 May 2026 15:03:27 +0200 Subject: [PATCH 70/71] more tries in CI --- playwright.config.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index fc4d0ec..81a0c88 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -19,10 +19,9 @@ export default defineConfig({ // Limit the number of workers on CI, use default locally workers: process.env.CI ? 2 : 1, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, + /* use retries to handle flaky tests */ + retries: process.env.CI ? 5 : 2, - retries: process.env.CI ? 2 : 2, /* Reporter to use */ reporter: 'html', /* Shared settings for all the projects below */ From 4ec1129192f08d1bfc465aa91689008e37155d9c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 11:30:21 +0000 Subject: [PATCH 71/71] Bump axios in the npm_and_yarn group across 1 directory Bumps the npm_and_yarn group with 1 update in the / directory: [axios](https://github.com/axios/axios). Updates `axios` from 1.15.1 to 1.16.0 - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.15.1...v1.16.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.16.0 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2d45061..13ee8c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -213,13 +213,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz", - "integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" }