diff --git a/.github/workflows/accessibility.yml b/.github/workflows/accessibility.yml new file mode 100644 index 0000000..bc3aa3f --- /dev/null +++ b/.github/workflows/accessibility.yml @@ -0,0 +1,56 @@ +name: Accessibility Tests + +on: + pull_request: + push: + # branches: [main, dev] + workflow_dispatch: + +jobs: + a11y: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install backend deps + # Install python dependencies from project.toml + run: cd server && pip install --no-cache-dir ./ + + - name: Install frontend deps + run: npm ci + + - name: Build frontend + run: npm run build + + - name: Start Flask backend + env: + # Set testing environment variables + CI: 'true' + FLASK_ENV: testing + run: | + python -m server.run & + sleep 5 + + - name: Start frontend + run: | + npm run preview & + sleep 5 + env: + FRONTEND_PORT: 4173 + + - name: Install Playwright browsers + run: npx playwright install --with-deps + + - name: Run Playwright accessibility tests + run: npx playwright test diff --git a/.gitignore b/.gitignore index 26828c9..4c3c646 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,7 @@ db-changes public/config.json +auth.json +# Playwright output +test-results/ +playwright-report/ diff --git a/package-lock.json b/package-lock.json index 5e322d8..3c56a16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,9 @@ "vue-tsc": "^2.2.10" }, "devDependencies": { + "@axe-core/playwright": "^4.11.0", "@eslint/js": "^9.14.0", + "@playwright/test": "^1.57.0", "@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue-jsx": "^4.1.1", "eslint": "^9.14.0", @@ -69,6 +71,19 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@axe-core/playwright": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.0.tgz", + "integrity": "sha512-70vBT/Ylqpm65RQz2iCG2o0JJCEG/WCNyefTr2xcOcr1CoSee60gNQYUMZZ7YukoKkFLv26I/jjlsvwwp532oQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@azure/msal-browser": { "version": "4.15.0", "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.15.0.tgz", @@ -1412,6 +1427,22 @@ "node": "^14.16.0 || ^16.10.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -2705,6 +2736,16 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/axe-core": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/axios": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", @@ -5509,6 +5550,53 @@ "pathe": "^1.1.2" } }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.4.49", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", diff --git a/package.json b/package.json index 1897d1e..4b2602d 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,9 @@ "vue-tsc": "^2.2.10" }, "devDependencies": { + "@axe-core/playwright": "^4.11.0", "@eslint/js": "^9.14.0", + "@playwright/test": "^1.57.0", "@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue-jsx": "^4.1.1", "eslint": "^9.14.0", diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..1ceb1ad --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + + projects: [ + { + name: 'setup', + testMatch: /auth\.setup\.js/, + }, + { + name: 'chromium', + use: { + storageState: 'auth.json', + }, + dependencies: ['setup'], + }, + ], +}); diff --git a/server/__init__.py b/server/__init__.py index 39d7e5d..6872d05 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -1,12 +1,11 @@ import os +import sys from flask import Flask from flask_jwt_extended import JWTManager -from server.config import Config +from server.config import get_config from server.extensions import cors -from server.register_routes import register_routes -from server.routes.auth import auth_bp def create_app(): @@ -14,6 +13,10 @@ def create_app(): os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' app = Flask(__name__) + + config_class = get_config() + app.config.from_object(get_config()) + # Allows cross-origin requests from your Vue frontend cors.init_app( app, @@ -21,9 +24,10 @@ def create_app(): expose_headers=['X-CSRF-TOKEN'], allow_headers=['Content-Type', 'X-CSRF-TOKEN'], ) - app.config.from_object(Config) # Register blueprints (modular routes) + from server.register_routes import register_routes + register_routes(app) jwt = JWTManager(app) diff --git a/server/config.py b/server/config.py index 8a62305..b6f4343 100644 --- a/server/config.py +++ b/server/config.py @@ -46,3 +46,19 @@ class Config: # Power BI Expected API Key EXPECTED_API_KEY = os.environ.get('EXPECTED_API_KEY') + + # Testing Authentication Mode + TEST_AUTH_ENABLED = False + + +class TestingConfig(Config): + TEST_AUTH_ENABLED = True + + +def get_config(): + """ + Decide config based on environment. + """ + if os.getenv('CI') == 'true': + return TestingConfig + return Config diff --git a/server/register_routes.py b/server/register_routes.py index 9f6a6a3..4301d50 100644 --- a/server/register_routes.py +++ b/server/register_routes.py @@ -1,6 +1,6 @@ from flask import Flask -from server.routes.auth import auth_bp +from server.config import get_config from server.routes.data import data_bp from server.routes.powerbi import powerbi_bp from server.routes.report import report_bp @@ -14,9 +14,18 @@ def register_routes(app: Flask): This helps in keeping the main app configuration clean and modular. """ - app.register_blueprint(auth_bp, url_prefix='/api/auth') + app.register_blueprint(data_bp, url_prefix='/api/data') app.register_blueprint(user_bp, url_prefix='/api/user') app.register_blueprint(report_bp, url_prefix='/api/report') app.register_blueprint(stats_bp, url_prefix='/api/stats') app.register_blueprint(powerbi_bp, url_prefix='/api/powerbi') + # Register test auth routes only if testing mode is enabled + if get_config().TEST_AUTH_ENABLED: + from server.routes.auth_test import auth_test_bp + + app.register_blueprint(auth_test_bp, url_prefix='/api/auth_test') + else: + from server.routes.auth import auth_bp + + app.register_blueprint(auth_bp, url_prefix='/api/auth') diff --git a/server/routes/auth.py b/server/routes/auth.py index 92527e9..66731db 100644 --- a/server/routes/auth.py +++ b/server/routes/auth.py @@ -19,14 +19,24 @@ auth_bp = Blueprint('auth', __name__) -AUTHORITY = f'https://login.microsoftonline.com/{Config.TENANT_ID}' - SCOPES = [] -# Initialize MSAL -msal_app = msal.ConfidentialClientApplication( - Config.CLIENT_ID, authority=AUTHORITY, client_credential={Config.CLIENT_SECRET} -) + +def get_msal_app(): + """ + Lazily create the MSAL app only when Azure auth is actually used. + """ + if app.config.get('TEST_AUTH_ENABLED'): + # Don't use Azure in CI mode + raise RuntimeError('MSAL should not be used in CI mode') + + authority = f'https://login.microsoftonline.com/{Config.TENANT_ID}' + + return msal.ConfidentialClientApplication( + Config.CLIENT_ID, + authority=authority, + client_credential=Config.CLIENT_SECRET, + ) @auth_bp.route('/login') @@ -34,6 +44,7 @@ def login(): """ Redirects user to Microsoft Login page. """ + msal_app = get_msal_app() auth_url = msal_app.get_authorization_request_url( SCOPES, redirect_uri=app.config.get('REDIRECT_URI') ) @@ -45,6 +56,7 @@ def auth_redirect(): """ Handles Azure AD login redirect. """ + msal_app = get_msal_app() code = request.args.get('code') if not code: return jsonify({'error': 'No auth code provided'}), 400 @@ -261,9 +273,7 @@ def get_user_by_email(): """ Look up a user in Azure AD by email address. """ - # email = request.args.get('email') - # if not email: - # return jsonify({'error': 'Email parameter is required'}), 400 + msal_app = get_msal_app() data = request.get_json() email = data.get('email') if not email: diff --git a/server/routes/auth_test.py b/server/routes/auth_test.py new file mode 100644 index 0000000..2ec2d73 --- /dev/null +++ b/server/routes/auth_test.py @@ -0,0 +1,53 @@ +from flask import Blueprint, current_app, jsonify, make_response, session +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + set_access_cookies, + set_refresh_cookies, +) + +auth_test_bp = Blueprint('auth_test', __name__) + + +@auth_test_bp.route('/__test/login', methods=['POST']) +def test_login(): + if not current_app.config.get('TEST_AUTH_ENABLED'): + return jsonify({'error': 'Not available'}), 404 + + # Minimal user payload matching your real claims + user = { + 'user_id': 1, + 'display_name': 'Test User', + 'email': 'jointhedots@nhm.ac.uk', + 'role_id': 4, + 'role': 'admin', + 'division_id': None, + 'level': 4, + } + + jwt_token = create_access_token( + identity=str(user['user_id']), + additional_claims={ + 'display_name': user['display_name'], + 'email': user['email'], + 'role_id': user['role_id'], + 'role': user['role'], + 'division_id': user['division_id'], + 'level': user['level'], + }, + ) + + # response = jsonify({"msg": "ok"}) + # set_access_cookies(response, jwt_token) + + # Create refresh token + refresh_token = create_refresh_token(identity=str(user['user_id'])) + # Store in session for later retrieval + session['jwt_token'] = jwt_token + + response = make_response(jsonify({'message': 'Login successful'})) + # Set jwt token as access token in cookies + set_access_cookies(response, jwt_token) + set_refresh_cookies(response, refresh_token) + + return response diff --git a/src/components/HomeStats.vue b/src/components/HomeStats.vue index 2b1fbed..a77a2bf 100644 --- a/src/components/HomeStats.vue +++ b/src/components/HomeStats.vue @@ -1,6 +1,6 @@