Implemented role-based access control, Updated e2e authentication tests and Playwright setup.

This commit is contained in:
2025-10-15 21:56:11 +02:00
parent 94214125fd
commit ef88d2925e
23 changed files with 308 additions and 41 deletions

5
.gitignore vendored
View File

@@ -8,12 +8,17 @@ htmlcov
.venv
tests/settings_from_unit_testing.json
tests/TestDBEngineRoot
tests/*.png
src/*.png
tests/*.html
test-results
.sesskey
tools.db
.mytools_db
.idea/MyManagingTools.iml
.idea/misc.xml
.idea/dataSources.xml
.idea/sqldialects.xml
.idea_bak
**/*.prof

View File

@@ -22,6 +22,7 @@ class AuthManager:
session["username"] = user_data["username"]
session["user_email"] = user_data["email"]
session["is_admin"] = bool(user_data["is_admin"])
session["roles"] = UserDAO.get_user_roles_by_id(user_data["id"])
@staticmethod
def logout_user(session) -> None:
@@ -130,4 +131,3 @@ class AuthManager:
"user_email": "admin@mmt.com",
"is_admin": True
}

View File

@@ -6,9 +6,10 @@ class BaseComponent:
Base class for all components that need to have a session and an id
"""
def __init__(self, session, _id=None, **kwargs):
def __init__(self, session, _id=None, role_name=None, **kwargs):
self._session = session
self._id = _id or self.create_component_id(session)
self._role_name = role_name
def get_id(self):
return self._id
@@ -35,6 +36,17 @@ class BaseComponent:
def create_component_id(session):
pass
def render(self):
pass
def __ft__(self):
if (not self._session["is_admin"] and
self._role_name is not None and
self._role_name not in self._session["roles"]):
return None
return self.render()
class BaseComponentSingleton(BaseComponent):
"""
@@ -43,8 +55,8 @@ class BaseComponentSingleton(BaseComponent):
COMPONENT_INSTANCE_ID = None
def __init__(self, session, _id=None, settings_manager=None, tabs_manager=None, **kwargs):
super().__init__(session, _id, **kwargs)
def __init__(self, session, _id=None, role_name=None, settings_manager=None, tabs_manager=None, **kwargs):
super().__init__(session, _id, role_name,**kwargs)
self._settings_manager = settings_manager
self.tabs_manager = tabs_manager

View File

@@ -1,18 +1,18 @@
from fasthtml.components import *
from components.BaseComponent import BaseComponent
from components.addstuff.constants import ADD_STUFF_INSTANCE_ID
from components.addstuff.constants import ADD_STUFF_INSTANCE_ID, ADD_STUFF_ROLE
from components.repositories.components.Repositories import Repositories
from core.instance_manager import InstanceManager
class AddStuffMenu(BaseComponent):
def __init__(self, session: dict, _id: str, settings_manager=None, tabs_manager=None):
super().__init__(session, _id)
super().__init__(session, _id, ADD_STUFF_ROLE)
self.tabs_manager = tabs_manager # MyTabs component id
self.repositories = InstanceManager.get(session, Repositories.create_component_id(session), Repositories)
def __ft__(self):
def render(self):
return Div(
Div("Add stuff...", tabindex="0"),
Ul(

View File

@@ -1 +1,2 @@
ADD_STUFF_INSTANCE_ID = "__AddStuff__"
ADD_STUFF_ROLE = "add_stuff"

View File

@@ -8,7 +8,7 @@ from components.admin.assets.icons import icon_jira
from components.admin.commands import AdminCommandManager
from components.admin.components.AdminForm import AdminFormItem, AdminFormType, AdminForm, AdminButton, AdminMessageType
from components.admin.components.ImportHolidays import ImportHolidays
from components.admin.constants import ADMIN_INSTANCE_ID, ADMIN_AI_BUDDY_INSTANCE_ID, ADMIN_JIRA_INSTANCE_ID
from components.admin.constants import ADMIN_INSTANCE_ID, ADMIN_AI_BUDDY_INSTANCE_ID, ADMIN_JIRA_INSTANCE_ID, ADMIN_ROLE
from components.aibuddy.assets.icons import icon_brain_ok
from components.hoildays.assets.icons import icon_holidays
from components.tabs.components.MyTabs import MyTabs
@@ -19,7 +19,7 @@ from core.jira import Jira
class Admin(BaseComponent):
def __init__(self, session, _id: str = None, settings_manager=None, tabs_manager: MyTabs = None):
super().__init__(session, _id)
super().__init__(session, _id, ADMIN_ROLE)
self.settings_manager = settings_manager
self.tabs_manager = tabs_manager
self.commands = AdminCommandManager(self)
@@ -117,7 +117,7 @@ class Admin(BaseComponent):
form.set_message(f"Error {res.status_code} - {res.text}", AdminMessageType.ERROR)
return self.tabs_manager.render()
def __ft__(self):
def render(self):
return Div(
Div(cls="divider"),
Div(mk_ellipsis("Admin", cls="text-sm font-medium mb-1 mr-3")),

View File

@@ -1,4 +1,5 @@
ADMIN_INSTANCE_ID = "__Admin__"
ADMIN_ROLE = "admin"
ADMIN_AI_BUDDY_INSTANCE_ID = "__AdminAIBuddy__"
ADMIN_IMPORT_HOLIDAYS_INSTANCE_ID = "__AdminImportHolidays__"
ADMIN_JIRA_INSTANCE_ID = "__AdminJira__"

View File

@@ -21,7 +21,7 @@ from core.settings_management import GenericDbManager
class AIBuddy(BaseComponent):
def __init__(self, session, _id: str = None, settings_manager=None, tabs_manager=None):
super().__init__(session, _id)
super().__init__(session, _id, AI_BUDDY_ROLE)
self.settings_manager = settings_manager
self.db = GenericDbManager(session, settings_manager, AI_BUDDY_SETTINGS_ENTRY, AIBuddySettings)
self.tabs_manager = tabs_manager
@@ -153,7 +153,7 @@ class AIBuddy(BaseComponent):
for name, tool in available_tools.items()
]
def __ft__(self):
def render(self):
return Div(
Div(cls="divider"),
Div(

View File

@@ -1,4 +1,5 @@
AI_BUDDY_INSTANCE_ID = "__AIBuddy__"
AI_BUDDY_ROLE = "ai_buddy"
ROUTE_ROOT = "/ai"

View File

@@ -2,7 +2,7 @@ from fasthtml.components import *
from components.BaseComponent import BaseComponent
from components.applications.commands import Commands
from components.applications.constants import APPLICATION_INSTANCE_ID
from components.applications.constants import APPLICATION_INSTANCE_ID, APPLICATION_ROLE
from components.hoildays.assets.icons import icon_holidays
from components.hoildays.components.HolidaysViewer import HolidaysViewer
from components.hoildays.constants import HOLIDAYS_VIEWER_INSTANCE_ID
@@ -12,7 +12,7 @@ from core.instance_manager import InstanceManager
class Applications(BaseComponent):
def __init__(self, session: dict, _id: str, settings_manager=None, tabs_manager=None):
super().__init__(session, _id)
super().__init__(session, _id, APPLICATION_ROLE)
self.tabs_manager = tabs_manager
self.settings_manager = settings_manager
self.commands = Commands(self)
@@ -30,7 +30,7 @@ class Applications(BaseComponent):
raise NotImplementedError(app_name)
def __ft__(self):
def render(self):
return Div(
Div(cls="divider"),

View File

@@ -1,4 +1,5 @@
APPLICATION_INSTANCE_ID = "__Applications__"
APPLICATION_ROLE = "applications"
ROUTE_ROOT = "/apps"

View File

@@ -10,7 +10,7 @@ from components.aibuddy.components.AIBuddy import AIBuddy
from components.debugger.assets.icons import icon_dbengine
from components.debugger.commands import Commands
from components.debugger.components.JsonViewer import JsonViewer
from components.debugger.constants import DBENGINE_DEBUGGER_INSTANCE_ID
from components.debugger.constants import DBENGINE_DEBUGGER_INSTANCE_ID, DEBUGGER_ROLE
from components_helpers import mk_ellipsis, mk_accordion_section
from core.instance_manager import InstanceManager
from core.utils import get_unique_id
@@ -20,7 +20,7 @@ logger = logging.getLogger("Debugger")
class Debugger(BaseComponent):
def __init__(self, session, _id, settings_manager, tabs_manager):
super().__init__(session, _id)
super().__init__(session, _id, DEBUGGER_ROLE)
self.settings_manager = settings_manager
self.db_engine = settings_manager.get_db_engine()
self.tabs_manager = tabs_manager
@@ -104,7 +104,7 @@ class Debugger(BaseComponent):
self.tabs_manager.add_tab(title, content, key=tab_key)
return self.tabs_manager.render()
def __ft__(self):
def render(self):
return Div(
Div(cls="divider"),
mk_ellipsis("Debugger", cls="text-sm font-medium mb-1"),

View File

@@ -1,4 +1,5 @@
DBENGINE_DEBUGGER_INSTANCE_ID = "debugger"
DEBUGGER_ROLE = "debugger"
ROUTE_ROOT = "/debugger"
INDENT_SIZE = 20

View File

@@ -8,7 +8,7 @@ from components.datagrid_new.components.DataGrid import DataGrid
from components.form.components.MyForm import MyForm, FormField
from components.repositories.assets.icons import icon_database, icon_table
from components.repositories.commands import Commands
from components.repositories.constants import REPOSITORIES_INSTANCE_ID, ROUTE_ROOT, Routes
from components.repositories.constants import REPOSITORIES_INSTANCE_ID, ROUTE_ROOT, Routes, REPOSITORIES_ROLE
from components.repositories.db_management import RepositoriesDbManager, Repository
from components_helpers import mk_icon, mk_ellipsis
from core.instance_manager import InstanceManager
@@ -19,7 +19,7 @@ logger = logging.getLogger("Repositories")
class Repositories(BaseComponent):
def __init__(self, session: dict, _id: str, settings_manager=None, tabs_manager=None):
super().__init__(session, _id)
super().__init__(session, _id, REPOSITORIES_ROLE)
self.commands = Commands(self)
self.tabs_manager = tabs_manager
self.db = RepositoriesDbManager(session, settings_manager)
@@ -121,7 +121,7 @@ class Repositories(BaseComponent):
def refresh(self):
return self._mk_repositories(oob=True)
def __ft__(self):
def render(self):
return Div(
Div(cls="divider"),
mk_ellipsis("Repositories", cls="text-sm font-medium mb-1"),

View File

@@ -1,4 +1,5 @@
REPOSITORIES_INSTANCE_ID = "__Repositories__"
REPOSITORIES_ROLE = "repositories"
ROUTE_ROOT = "/repositories"
USERS_REPOSITORY_NAME = "__USERS___"
HOLIDAYS_TABLE_NAME = "__HOLIDAYS__"

View File

@@ -28,7 +28,7 @@ class UndoRedo(BaseComponentSingleton):
COMPONENT_INSTANCE_ID = UNDO_REDO_INSTANCE_ID
def __init__(self, session, _id, settings_manager=None, tabs_manager=None):
super().__init__(session, _id, settings_manager, tabs_manager)
super().__init__(session, _id, None, settings_manager, tabs_manager)
self.index = -1
self.history = []
self._commands = UndoRedoCommandManager(self)

View File

@@ -7,7 +7,7 @@ from components.BaseComponent import BaseComponentSingleton
from components.form.components.MyForm import MyForm, FormField
from components.workflows.commands import WorkflowsCommandManager
from components.workflows.components.WorkflowDesigner import WorkflowDesigner
from components.workflows.constants import WORKFLOWS_INSTANCE_ID
from components.workflows.constants import WORKFLOWS_INSTANCE_ID, WORKFLOWS_ROLE
from components.workflows.db_management import WorkflowsDbManager, WorkflowsDesignerSettings
from components_helpers import mk_ellipsis, mk_icon
from core.instance_manager import InstanceManager
@@ -19,7 +19,7 @@ class Workflows(BaseComponentSingleton):
COMPONENT_INSTANCE_ID = WORKFLOWS_INSTANCE_ID
def __init__(self, session, _id, settings_manager=None, tabs_manager=None):
super().__init__(session, _id, settings_manager, tabs_manager)
super().__init__(session, _id, WORKFLOWS_ROLE, settings_manager, tabs_manager)
self.commands = WorkflowsCommandManager(self)
self.db = WorkflowsDbManager(session, settings_manager)
@@ -80,7 +80,7 @@ class Workflows(BaseComponentSingleton):
def refresh(self):
return self._mk_workflows(True)
def __ft__(self):
def render(self):
return Div(
Div(cls="divider"),
Div(

View File

@@ -1,4 +1,5 @@
WORKFLOWS_INSTANCE_ID = "__Workflows__"
WORKFLOWS_ROLE = "workflows"
WORKFLOW_DESIGNER_INSTANCE_ID = "__WorkflowDesigner__"
WORKFLOW_PLAYER_INSTANCE_ID = "__WorkflowPlayer__"
WORKFLOWS_DB_ENTRY = "Workflows"

View File

@@ -56,12 +56,25 @@ class UserDAO:
VALUES (?, ?, ?, ?, ?, 0)
''', (username, email, password_hash, salt, github_id))
# Get the ID of the newly created user
user_id = cursor.lastrowid
# Add default roles to the new user
default_roles = ['repositories', 'datagrid', 'ai_buddy']
for role in default_roles:
cursor.execute('''
INSERT INTO roles (user_id, role_name)
VALUES (?, ?)
''', (user_id, role))
conn.commit()
return cursor.lastrowid
return user_id
except Exception as e:
logger.error(f"Error creating user: {e}")
return 0
@staticmethod
def authenticate_email(email: str, password: str, db_instance=None) -> dict[str, Any] | None:
"""
@@ -106,6 +119,7 @@ class UserDAO:
# Return user info
return dict(user)
@staticmethod
def find_or_create_github_user(github_id: str, username: str, email: str | None, db_instance=None) -> dict[
str, Any] | None:
@@ -165,6 +179,7 @@ class UserDAO:
logger.error(f"Error creating GitHub user: {e}")
return None
@staticmethod
def get_user_by_id(user_id: int, db_instance=None) -> dict[str, Any] | None:
"""
@@ -189,6 +204,34 @@ class UserDAO:
user = cursor.fetchone()
return dict(user) if user else None
@staticmethod
def get_user_roles_by_id(user_id: int, db_instance=None) -> list[str]:
"""
Retrieve the roles associated with a user.
Args:
user_id (int): User ID.
db_instance: Database instance (optional, uses default if None)
Returns:
list[str]: List of role names associated with the user.
"""
db_instance = db_instance or get_user_db()
with db_instance.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT role_name
FROM roles
WHERE user_id = ?
''', (user_id,))
# get the roles
roles = [row["role_name"] for row in cursor.fetchall()]
return roles
@staticmethod
def get_all_users(limit: int = 100, offset: int = 0, db_instance=None) -> list[dict[str, Any]]:
"""
@@ -215,12 +258,13 @@ class UserDAO:
last_login,
(github_id IS NOT NULL) as is_github_user
FROM users
ORDER BY created_at DESC LIMIT ?
OFFSET ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
''', (limit, offset))
return [dict(user) for user in cursor.fetchall()]
@staticmethod
def set_admin_status(user_id: int, is_admin: bool, db_instance=None) -> bool:
"""
@@ -250,6 +294,7 @@ class UserDAO:
logger.error(f"Error setting admin status: {e}")
return False
@staticmethod
def delete_user(user_id: int, db_instance=None) -> bool:
"""

View File

@@ -46,6 +46,17 @@ class Database:
''')
logger.info("Created users table")
# Create the roles table
cursor.execute('''
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
role_name TEXT NOT NULL,
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)
''')
logger.info("Created roles table")
# Check if we need to create an admin user
cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = 1")

187
tests/e2e/Readme.md Normal file
View File

@@ -0,0 +1,187 @@
# README - End-to-End Authentication Testing Guide
This README provides details on how to set up, configure, and run the end-to-end (e2e) authentication tests for the **My Managing Tools** application. The tests are implemented using `pytest` with `playwright` for browser automation.
## Purpose
The e2e tests verify that the authentication system works as expected, including login functionality, session validation, and database isolation for testing purposes. These tests ensure the login page behaves properly under different scenarios such as unauthenticated access, form validation, and successful user login.
---
## Table of Contents
1. [Setup Instructions](#setup-instructions)
- [Application Setup](#application-setup)
- [Database Setup](#database-setup)
2. [Installing Dependencies](#installing-dependencies)
3. [Running Tests](#running-tests)
4. [Debugging and Logs](#debugging-and-logs)
5. [Test Scenarios](#test-scenarios)
6. [Troubleshooting](#troubleshooting)
---
## Setup Instructions
Before running the tests, make sure the environment is set up correctly for both the application and the test database.
### Application Setup
1. **Install Required Dependencies** (Python version 3.9 or higher is recommended):
```bash
pip install -r requirements.txt
```
2. **Run the Application**:
Navigate to the `src` directory and start the application:
```bash
cd src
python main.py
```
Alternatively, use Docker:
```bash
docker-compose up -d
```
Once the server starts, ensure the app is accessible at the base URL defined in `playwright_config.py`. By default, it should be `http://localhost:5002`.
### Database Setup
The e2e tests are designed to interact with an **isolated test database**. During setup, a temporary SQLite database will be created for each test session.
1. **Test Database Initialization**:
The database is initialized dynamically during the test execution using a custom temporary path. The `test_database` fixture creates a database with the following characteristics:
- **Temporary Directory**: Ensures no conflicts with the production database.
- **Admin User Auto-Creation**: Adds an admin account if the environment variables `ADMIN_EMAIL` and `ADMIN_PASSWORD` are set.
2. **Environment Variables**:
Ensure the following environment variables are defined for the test database:
```env
DB_PATH="test_mmt_tools.db"
ADMIN_EMAIL="test.admin@test.com"
ADMIN_PASSWORD="TestAdmin123"
```
3. **Configuration**:
The `test_database.py` script handles the database isolation and cleanup after each test session. It ensures that no state is leaked between tests.
---
## Installing Dependencies
Install the required packages for the tests:
1. **Playwright and Pytest**:
Install `pytest-playwright` and playwright dependencies:
```bash
pip install pytest-playwright
playwright install
playwright install-deps # For Linux systems
```
2. **Project Dependencies**:
Use the provided `requirements.txt` to install other necessary packages:
```bash
pip install -r requirements.txt
```
3. **Verify Installation**:
Confirm Playwright is installed and functional:
```bash
playwright --version
```
---
## Running Tests
To execute the end-to-end authentication tests, run the following commands from the project root.
1. **Run All Tests**:
Execute all e2e tests:
```bash
pytest -m "e2e"
```
2. **Run Specific Test**:
Use the test path and `-k` argument to target specific tests:
```bash
pytest tests/test_e2e_authentication.py -k "test_unauthenticated_user_redirected_to_login"
```
3. **Record Test Results**:
Save test results (HTML report):
```bash
pytest --html=test-results/report.html --self-contained-html
```
4. **Parallel Execution**:
If desired, run tests in parallel:
```bash
pytest -n auto
```
---
## Debugging and Logs
1. **Console Logs**:
Console errors during page navigation are captured using the `test_page_loads_without_errors` test. Check the pytest output for details.
2. **Debugging Tools**:
- Videos and HAR traces are recorded in the `test-results` directory.
- Use the `page.pause()` method inside a test for interactive debugging with Playwright Inspector.
3. **Screenshots and Debug HTML**:
If a test fails, screenshots and full HTML are saved for post-mortem debugging:
- `debug_after_login.png`: Screenshot post-login.
- `debug_page.html`: Captured HTML structure of the page.
4. **Server Logs**:
When using the `app_server` fixture, server `stdout` and `stderr` are recorded during the test execution.
---
## Test Scenarios
The main authentication tests cover the following scenarios:
1. **Unauthenticated User Redirect**:
Verify that unauthenticated users accessing protected resources are redirected to the login page.
2. **Login Page Elements**:
Validate that key login page elements are visible, such as email/password fields and the submit button.
3. **Successful Login**:
Ensure users can log in successfully with valid credentials and are redirected to the home page.
4. **Test Database Isolation**:
Confirm that the test environment uses an isolated temporary database and does not affect production data.
5. **Page Load Without Errors**:
Ensure that the login page loads without console or HTTP errors.
For a full list of test cases, see `tests/test_e2e_authentication.py`.
---
## Troubleshooting
- **Server Not Starting**:
If the server fails to start during testing, confirm the `DB_PATH`, `BASE_URL`, and all dependencies are correctly configured in the `.env` file.
- **Playwright Issues**:
Ensure Playwright dependencies (`playwright install`) are installed and functional. Missing browsers or outdated Playwright versions can cause tests to fail.
- **Database Error**:
Check that the `tools.db` file or the temporary test database exists and is accessible. Ensure the test database environment variables are set correctly during tests.
---
## Additional Notes
- The app server is started with test-specific configurations (`app_server` fixture) that ensure end-to-end isolation.
- Tests requiring authentication use an admin user defined in the `test_users` fixture:
- Email: `test.admin@test.com`
- Password: `TestAdmin123`
- Ensure no sensitive data or configurations are hardcoded in the test scripts.

0
tests/e2e/__init__.py Normal file
View File