diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index 82f01e1d..17f310dd 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -1936,144 +1936,7 @@ def check_auth(payload=None): return jsonify({"success": True, "message": "Authentication check successful"}), 200 -# -------------------------- -# Remember Me Validation endpoint -# -------------------------- -@app.route("/auth/validate-remember", methods=["POST"]) -@validate_request( - operation_id="validate_remember", - summary="Validate Remember Me Token", - description="Validate a persistent Remember Me token against stored hash. Called from login page (no auth required).", - request_model=ValidateRememberRequest, - response_model=ValidateRememberResponse, - tags=["auth"], - auth_callable=None # No auth required - used on login page -) -def validate_remember(payload=None): - """ - Validate a Remember Me token from persistent cookie. - - Security: Uses timing-safe hash comparison to prevent timing attacks. - Token format: hex-encoded 32 random bytes (64 chars) from bin2hex(random_bytes(32)) - """ - try: - # Extract token from request - data = request.get_json() or {} - token = data.get("token") - - if not token: - mylog("verbose", ["[auth/validate-remember] Missing token in request"]) - return jsonify({ - "success": True, - "valid": False, - "message": "Token validation failed: missing token" - }), 200 - - # Validate token against stored hash - params_instance = ParametersInstance() - result = params_instance.validate_token(token) - - if result['valid']: - mylog("verbose", ["[auth/validate-remember] Token validation successful"]) - return jsonify({ - "success": True, - "valid": True, - "message": "Token validation successful" - }), 200 - else: - mylog("verbose", ["[auth/validate-remember] Token validation failed"]) - return jsonify({ - "success": True, - "valid": False, - "message": "Token validation failed" - }), 200 - - except Exception as e: - mylog("verbose", [f"[auth/validate-remember] Unexpected error: {e}"]) - return jsonify({ - "success": False, - "valid": False, - "error": "Internal server error", - "message": "An unexpected error occurred during token validation" - }), 500 - - -# -------------------------- -# Remember Me Save endpoint -# -------------------------- -@app.route("/auth/remember-me/save", methods=["POST"]) -@validate_request( - operation_id="save_remember", - summary="Save Remember Me Token", - description="Save a Remember Me token to the database. Called after successful login to enable persistent authentication.", - request_model=SaveRememberRequest, - response_model=SaveRememberResponse, - tags=["auth"], - auth_callable=None # No auth required - used on login page -) -def save_remember(payload=None): - """ - Save a Remember Me token. - - Flow: - 1. User logs in with "Remember Me" checkbox - 2. Password validated successfully - 3. Token generated: bin2hex(random_bytes(32)) - 4. This endpoint called: saves hash(token) to Parameters table - 5. Token (unhashed) set in persistent cookie - 6. Session created and user redirected - - Security: Only the HASH is stored in the database, not the token itself. - If database is compromised, attacker cannot use stolen hashes without the original token. - """ - try: - import uuid - import hashlib - - # Extract token from request - data = request.get_json() or {} - token = data.get("token") - - if not token or len(token) < 64: - mylog("verbose", ["[auth/remember-me/save] Invalid or missing token"]) - return jsonify({ - "success": False, - "error": "Invalid token", - "message": "Token must be 64+ hex characters" - }), 400 - - # Hash the token - token_hash = hashlib.sha256(token.encode('utf-8')).hexdigest() - - # Generate UUID-based parameter ID - token_id = f"remember_me_token_{uuid.uuid4()}" - - # Store hash in Parameters table - params_instance = ParametersInstance() - success = params_instance.set_parameter(token_id, token_hash) - - if success: - mylog("verbose", [f"[auth/remember-me/save] Token saved successfully: {token_id}"]) - return jsonify({ - "success": True, - "message": "Remember Me token saved successfully", - "token_id": token_id - }), 200 - else: - mylog("verbose", ["[auth/remember-me/save] Failed to save token to database"]) - return jsonify({ - "success": False, - "error": "Database error", - "message": "Failed to save Remember Me token" - }), 500 - - except Exception as e: - mylog("verbose", [f"[auth/remember-me/save] Unexpected error: {e}"]) - return jsonify({ - "success": False, - "error": "Internal server error", - "message": "An unexpected error occurred while saving Remember Me token" - }), 500 +# Remember Me is now implemented via cookies only (no API endpoints required) # -------------------------- diff --git a/server/api_server/openapi/schemas.py b/server/api_server/openapi/schemas.py index df01ec0c..0830a3fc 100644 --- a/server/api_server/openapi/schemas.py +++ b/server/api_server/openapi/schemas.py @@ -1036,82 +1036,7 @@ class GetSettingResponse(BaseResponse): # ============================================================================= -class ValidateRememberRequest(BaseModel): - """Request to validate a Remember Me token.""" - model_config = ConfigDict( - json_schema_extra={ - "examples": [{ - "token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2" - }] - } - ) - - token: str = Field( - ..., - min_length=64, - max_length=128, - description="The unhashed Remember Me token from persistent cookie (hex-encoded binary)" - ) - - -class ValidateRememberResponse(BaseResponse): - """Response from Remember Me token validation.""" - model_config = ConfigDict( - extra="allow", - json_schema_extra={ - "examples": [{ - "success": True, - "valid": True, - "message": "Token validation successful" - }, { - "success": True, - "valid": False, - "message": "Token validation failed" - }] - } - ) - - valid: bool = Field( - ..., - description="Whether the token is valid and matches stored hash" - ) - - -class SaveRememberRequest(BaseModel): - """Request to save a Remember Me token.""" - model_config = ConfigDict( - json_schema_extra={ - "examples": [{ - "token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2" - }] - } - ) - - token: str = Field( - ..., - min_length=64, - max_length=128, - description="The unhashed Remember Me token to save (hex-encoded binary from bin2hex(random_bytes(32)))" - ) - - -class SaveRememberResponse(BaseResponse): - """Response from Remember Me token save operation.""" - model_config = ConfigDict( - extra="allow", - json_schema_extra={ - "examples": [{ - "success": True, - "message": "Token saved successfully", - "token_id": "remember_me_token_550e8400-e29b-41d4-a716-446655440000" - }] - } - ) - - token_id: Optional[str] = Field( - None, - description="The parameter ID where token hash was stored (UUID-based)" - ) +# Remember Me schemas removed - Remember Me now uses cookies only (no API endpoints) # ============================================================================= diff --git a/test/api_endpoints/test_remember_me_endpoints.py b/test/api_endpoints/test_remember_me_endpoints.py deleted file mode 100644 index b18894ee..00000000 --- a/test/api_endpoints/test_remember_me_endpoints.py +++ /dev/null @@ -1,400 +0,0 @@ -""" -Remember Me Token Tests - Security & Functionality - -Tests the secure Remember Me feature: -- Token generation and storage -- Token validation (including timing-safe comparison) -- Security: Tampered token rejection -- API endpoint validation -""" - -import sys -import os -import hashlib -import json - -# Register NetAlertX directories -INSTALL_PATH = os.getenv("NETALERTX_APP", "/app") -sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) - -import pytest -from api_server.api_server_start import app # noqa: E402 -from models.parameters_instance import ParametersInstance # noqa: E402 - - -@pytest.fixture -def client(): - """Flask test client.""" - with app.test_client() as client: - yield client - - -@pytest.fixture -def params_instance(): - """ParametersInstance for direct database access.""" - return ParametersInstance() - - -@pytest.fixture -def test_token(): - """Generate a valid test token (64-hex characters).""" - import os - return os.urandom(32).hex() # 32 bytes = 64 hex chars - - -# ============================================================================ -# REMEMBER ME SAVE ENDPOINT TESTS -# ============================================================================ - -def test_save_remember_success(client, test_token): - """POST /auth/remember-me/save - Valid token should save successfully.""" - resp = client.post("/auth/remember-me/save", json={"token": test_token}) - - assert resp.status_code == 200 - data = resp.get_json() - assert data is not None - assert data["success"] is True - assert "saved successfully" in data.get("message", "").lower() - assert data.get("token_id") is not None - assert data["token_id"].startswith("remember_me_token_") - - -def test_save_remember_missing_token(client): - """POST /auth/remember-me/save - Missing token should fail with 422 (validation error).""" - resp = client.post("/auth/remember-me/save", json={}) - - assert resp.status_code == 422 # Pydantic validation error, not 400 - data = resp.get_json() - assert data is not None - assert data["success"] is False - - -def test_save_remember_empty_token(client): - """POST /auth/remember-me/save - Empty token should fail with 422.""" - resp = client.post("/auth/remember-me/save", json={"token": ""}) - - assert resp.status_code == 422 # Pydantic validation error - data = resp.get_json() - assert data is not None - assert data["success"] is False - - -def test_save_remember_short_token(client): - """POST /auth/remember-me/save - Token too short (< 64 chars) should fail with 422.""" - resp = client.post("/auth/remember-me/save", json={"token": "a" * 32}) - - assert resp.status_code == 422 # Pydantic validation error - data = resp.get_json() - assert data is not None - assert data["success"] is False - - -def test_save_remember_null_token(client): - """POST /auth/remember-me/save - Null token should fail with 422.""" - resp = client.post("/auth/remember-me/save", json={"token": None}) - - assert resp.status_code == 422 # Pydantic validation error - data = resp.get_json() - assert data is not None - assert data["success"] is False - - -# ============================================================================ -# REMEMBER ME VALIDATION ENDPOINT TESTS -# ============================================================================ - -def test_validate_remember_valid_token(client, test_token, params_instance): - """POST /auth/validate-remember - Valid token should validate successfully.""" - # First save the token - save_resp = client.post("/auth/remember-me/save", json={"token": test_token}) - assert save_resp.status_code == 200 - - # Now validate it - validate_resp = client.post("/auth/validate-remember", json={"token": test_token}) - - assert validate_resp.status_code == 200 - data = validate_resp.get_json() - assert data is not None - assert data["success"] is True - assert data["valid"] is True - assert "successful" in data.get("message", "").lower() - - -def test_validate_remember_tampered_token(client, test_token): - """ - POST /auth/validate-remember - SECURITY TEST: Tampered token should be rejected. - - This test verifies the security of the timing-safe hash comparison. - Even a single-character modification to the token should fail validation. - """ - # Save the original token - save_resp = client.post("/auth/remember-me/save", json={"token": test_token}) - assert save_resp.status_code == 200 - - # Tamper with the token by modifying last character - tampered_token = test_token[:-1] + ("0" if test_token[-1] != "0" else "1") - - # Attempt to validate with tampered token - validate_resp = client.post("/auth/validate-remember", json={"token": tampered_token}) - - assert validate_resp.status_code == 200 - data = validate_resp.get_json() - assert data is not None - assert data["success"] is True # API doesn't error - assert data["valid"] is False # But validation fails - assert "failed" in data.get("message", "").lower() - - -def test_validate_remember_nonexistent_token(client): - """POST /auth/validate-remember - Non-existent token should return invalid.""" - import os - random_token = os.urandom(32).hex() - - resp = client.post("/auth/validate-remember", json={"token": random_token}) - - assert resp.status_code == 200 - data = resp.get_json() - assert data is not None - assert data["success"] is True - assert data["valid"] is False - - -def test_validate_remember_missing_token(client): - """POST /auth/validate-remember - Missing token should fail with 422.""" - resp = client.post("/auth/validate-remember", json={}) - - # Pydantic validation catches this before handler - assert resp.status_code == 422 - data = resp.get_json() - assert data is not None - assert data["success"] is False - - -def test_validate_remember_empty_token(client): - """POST /auth/validate-remember - Empty token should fail with 422.""" - resp = client.post("/auth/validate-remember", json={"token": ""}) - - # Pydantic validation catches this before handler - assert resp.status_code == 422 - data = resp.get_json() - assert data is not None - assert data["success"] is False - - -def test_validate_remember_null_token(client): - """POST /auth/validate-remember - Null token should fail with 422.""" - resp = client.post("/auth/validate-remember", json={"token": None}) - - # Pydantic validation catches this before handler - assert resp.status_code == 422 - data = resp.get_json() - assert data is not None - assert data["success"] is False - - -# ============================================================================ -# COMPLETE WORKFLOW TESTS -# ============================================================================ - -def test_remember_me_complete_workflow(client, test_token): - """ - Save and validate: Complete Remember Me workflow. - - Simulates: - 1. User logs in, "Remember Me" checked - 2. System saves token via API - 3. User closes browser, session expires - 4. User returns, system validates token from cookie - 5. User authenticated without re-entering password - """ - # Step 1: Save token (happens at login with "Remember Me") - save_resp = client.post("/auth/remember-me/save", json={"token": test_token}) - assert save_resp.status_code == 200 - save_data = save_resp.get_json() - assert save_data["success"] is True - token_id = save_data["token_id"] - - # Step 2: Validate token (happens on return visit from cookie) - validate_resp = client.post("/auth/validate-remember", json={"token": test_token}) - assert validate_resp.status_code == 200 - validate_data = validate_resp.get_json() - assert validate_data["success"] is True - assert validate_data["valid"] is True - - # Step 3: Verify token was actually stored in Parameters table - stored_value = ParametersInstance().get_parameter(token_id) - assert stored_value is not None - expected_hash = hashlib.sha256(test_token.encode('utf-8')).hexdigest() - assert stored_value == expected_hash - - -def test_remember_me_multiple_tokens(client): - """Multiple tokens can coexist (for multi-device Remember Me in future).""" - import os - - token1 = os.urandom(32).hex() - token2 = os.urandom(32).hex() - - # Save both tokens - resp1 = client.post("/auth/remember-me/save", json={"token": token1}) - resp2 = client.post("/auth/remember-me/save", json={"token": token2}) - - assert resp1.status_code == 200 - assert resp2.status_code == 200 - - # Both should validate - validate1 = client.post("/auth/validate-remember", json={"token": token1}) - validate2 = client.post("/auth/validate-remember", json={"token": token2}) - - assert validate1.get_json()["valid"] is True - assert validate2.get_json()["valid"] is True - - # Each token should only match itself, not the other - cross_validate = client.post("/auth/validate-remember", json={"token": token1 + token2[:32]}) - assert cross_validate.get_json()["valid"] is False - - -# ============================================================================ -# SECURITY TESTS -# ============================================================================ - -def test_timing_attack_prevention(client, test_token): - """ - Verify timing-safe hash comparison prevents timing attacks. - - The _hash_equals() method should take roughly equal time regardless of - where the mismatch occurs in the string. - """ - # Save token - client.post("/auth/remember-me/save", json={"token": test_token}) - - # Create tampered versions at different positions - tamper_positions = [0, 10, 32, 60, 63] # Various positions - - for pos in tamper_positions: - tampered_token = list(test_token) - tampered_token[pos] = "F" if tampered_token[pos] != "F" else "0" - tampered_token = "".join(tampered_token) - - resp = client.post("/auth/validate-remember", json={"token": tampered_token}) - data = resp.get_json() - - # All tampered attempts should fail validation - assert data["valid"] is False, f"Tamper at position {pos} was not detected!" - - -def test_hash_not_stored_on_disk(client, test_token): - """Verify that the HASH (not the token) is stored on disk.""" - save_resp = client.post("/auth/remember-me/save", json={"token": test_token}) - token_id = save_resp.get_json()["token_id"] - - # Retrieve the stored value - stored_hash = ParametersInstance().get_parameter(token_id) - - # Verify it's a hash, not the original token - assert stored_hash != test_token, "Token hash should not match original token!" - assert len(stored_hash) == 64, "SHA256 hash should be 64 hex characters" - assert stored_hash == hashlib.sha256(test_token.encode('utf-8')).hexdigest() - - -def test_database_compromise_mitigation(client, test_token): - """ - If database is compromised, stolen hash should not authenticate. - - This verifies the security model: attacker gets hash, but can't use it - without the original token (which only exists in the user's cookie). - """ - # Save token - save_resp = client.post("/auth/remember-me/save", json={"token": test_token}) - token_id = save_resp.get_json()["token_id"] - - # Simulate database breach: attacker gets the stored hash - stored_hash = ParametersInstance().get_parameter(token_id) - - # Attacker tries to use the stolen hash as a token - breach_test_resp = client.post("/auth/validate-remember", json={"token": stored_hash}) - breach_test_data = breach_test_resp.get_json() - - # Validation should fail because hash doesn't match hash(hash(token)) - assert breach_test_data["valid"] is False, "Stolen hash should not authenticate!" - - -# ============================================================================ -# MALFORMED REQUEST TESTS -# ============================================================================ - -def test_save_remember_no_json_body(client): - """POST /auth/remember-me/save - Missing JSON body should fail.""" - resp = client.post( - "/auth/remember-me/save", - data="not json", - content_type="application/json" - ) - assert resp.status_code in [400, 500] - - -def test_validate_remember_no_json_body(client): - """POST /auth/validate-remember - Missing JSON body should handle gracefully.""" - resp = client.post( - "/auth/validate-remember", - data="not json", - content_type="application/json" - ) - assert resp.status_code in [200, 400, 500] - - -def test_save_remember_extra_fields(client, test_token): - """POST /auth/remember-me/save - Extra fields should be ignored.""" - resp = client.post("/auth/remember-me/save", json={ - "token": test_token, - "extra_field": "should be ignored", - "another": 123 - }) - - assert resp.status_code == 200 - data = resp.get_json() - assert data["success"] is True - - -# ============================================================================ -# CLEANUP/MAINTENANCE TESTS -# ============================================================================ - -def test_delete_parameter(params_instance, test_token): - """ParametersInstance.delete_parameter() should clean up tokens.""" - # Save a token - test_id = "test_token_cleanup" - test_hash = hashlib.sha256(test_token.encode('utf-8')).hexdigest() - - params_instance.set_parameter(test_id, test_hash) - assert params_instance.get_parameter(test_id) is not None - - # Delete it - success = params_instance.delete_parameter(test_id) - assert success is True - assert params_instance.get_parameter(test_id) is None - - -def test_delete_parameters_by_prefix(params_instance): - """ParametersInstance.delete_parameters_by_prefix() should batch delete tokens.""" - import os - - # Create multiple tokens with same prefix - prefix = "remember_me_token_test_" - for i in range(3): - token_id = f"{prefix}{i}" - params_instance.set_parameter(token_id, f"hash_{i}") - - # Verify they exist - assert params_instance.get_parameter(f"{prefix}0") is not None - assert params_instance.get_parameter(f"{prefix}1") is not None - assert params_instance.get_parameter(f"{prefix}2") is not None - - # Delete by prefix - deleted_count = params_instance.delete_parameters_by_prefix(prefix) - assert deleted_count >= 3 - - # Verify they're gone - assert params_instance.get_parameter(f"{prefix}0") is None - assert params_instance.get_parameter(f"{prefix}1") is None - assert params_instance.get_parameter(f"{prefix}2") is None diff --git a/test/ui/test_ui_login.py b/test/ui/test_ui_login.py new file mode 100644 index 00000000..ee944846 --- /dev/null +++ b/test/ui/test_ui_login.py @@ -0,0 +1,412 @@ +#!/usr/bin/env python3 +""" +Login Page UI Tests +Tests login functionality, Remember Me, and deep link support +""" + +import sys +import os +import time + +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC + +# Add test directory to path +sys.path.insert(0, os.path.dirname(__file__)) + +from .test_helpers import BASE_URL, wait_for_page_load, wait_for_element_by_css # noqa: E402 + + +def get_login_password(): + """Get login password from config file or environment + + Returns the plaintext password that should be used for login. + For test/dev environments, tries common test passwords and defaults. + Returns None if password cannot be determined (will skip test). + """ + # Try environment variable first (for testing) + if os.getenv("LOGIN_PASSWORD"): + return os.getenv("LOGIN_PASSWORD") + + # SHA256 hash of "password" - the default test password (from index.php) + DEFAULT_PASSWORD_HASH = '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92' + + # List of passwords to try in order + passwords_to_try = ["123456", "password", "test", "admin"] + + # Try common config file locations + config_paths = [ + "/data/config/app.conf", + "/app/back/app.conf", + os.path.expanduser("~/.netalertx/app.conf") + ] + + for config_path in config_paths: + try: + if os.path.exists(config_path): + print(f"📋 Reading config from: {config_path}") + with open(config_path, 'r') as f: + for line in f: + # Only look for SETPWD_password lines (not other config like API keys) + if 'SETPWD_password' in line and '=' in line: + # Extract the value between quotes + value = line.split('=', 1)[1].strip() + # Remove quotes + value = value.strip('"').strip("'") + print(f"✓ Found password config: {value[:32]}...") + + # If it's the default, use the default password + if value == DEFAULT_PASSWORD_HASH: + print(f" Using default password: '123456'") + return "123456" + # If it's plaintext and looks reasonable + elif len(value) < 100 and not value.startswith('{') and value.isalnum(): + print(f" Using plaintext password: '{value}'") + return value + # For other hashes, can't determine plaintext + break # Found SETPWD_password, stop looking + except (FileNotFoundError, IOError, PermissionError) as e: + print(f"⚠ Error reading {config_path}: {e}") + continue + + # If we couldn't determine the password from config, try default password + print(f"ℹ Password not determinable from config, trying default passwords...") + + # For now, return first test password to try + # Tests will skip if login fails + return None + + +def perform_login(driver, password=None): + """Helper function to perform login with optional password fallback + + Args: + driver: Selenium WebDriver + password: Password to try. If None, will try default test password + """ + if password is None: + password = "123456" # Default test password + + password_input = driver.find_element(By.NAME, "loginpassword") + password_input.send_keys(password) + + submit_button = driver.find_element(By.CSS_SELECTOR, "button[type='submit']") + submit_button.click() + + # Wait for page to respond to form submission + # This might either redirect or show login error + time.sleep(1) + wait_for_page_load(driver, timeout=5) + + +def test_login_page_loads(driver): + """Test: Login page loads successfully""" + driver.get(f"{BASE_URL}/index.php") + wait_for_page_load(driver) + + # Check that login form is present + password_field = driver.find_element(By.NAME, "loginpassword") + assert password_field, "Password field should be present" + + submit_button = driver.find_element(By.CSS_SELECTOR, "button[type='submit']") + assert submit_button, "Submit button should be present" + + +def test_login_redirects_to_devices(driver): + """Test: Successful login redirects to devices page""" + import pytest + password = get_login_password() + # Use password if found, otherwise helper will use default "password" + + driver.get(f"{BASE_URL}/index.php") + wait_for_page_load(driver) + + perform_login(driver, password) + + # Wait for redirect to complete (server-side redirect is usually instant) + time.sleep(1) + + # Should be redirected to devices page + if '/devices.php' not in driver.current_url: + pytest.skip(f"Login failed or not configured. URL: {driver.current_url}") + + assert '/devices.php' in driver.current_url, \ + f"Expected redirect to devices.php, got {driver.current_url}" + + +def test_login_with_deep_link_preserves_hash(driver): + """Test: Login with deep link (?next=...) preserves the URL fragment hash""" + import base64 + import pytest + + password = get_login_password() + + # Create a deep link to devices.php#device-123 + deep_link_path = "/devices.php#device-123" + encoded_path = base64.b64encode(deep_link_path.encode()).decode() + + # Navigate to login with deep link + driver.get(f"{BASE_URL}/index.php?next={encoded_path}") + wait_for_page_load(driver) + + perform_login(driver, password) + + # Wait for JavaScript redirect to complete (up to 5 seconds) + for i in range(50): + current_url = driver.current_url + if '/devices.php' in current_url or '/index.php' not in current_url: + break + time.sleep(0.1) + + # Check that we're on the right page with the hash preserved + current_url = driver.current_url + if '/devices.php' not in current_url: + pytest.skip(f"Login failed or not configured. URL: {current_url}") + + assert '#device-123' in current_url, f"Expected #device-123 hash in URL, got {current_url}" + + +def test_login_with_deep_link_to_device_tree(driver): + """Test: Login with deep link to network tree page""" + import base64 + import pytest + + password = get_login_password() + + # Create a deep link to network.php#settings-panel + deep_link_path = "/network.php#settings-panel" + encoded_path = base64.b64encode(deep_link_path.encode()).decode() + + # Navigate to login with deep link + driver.get(f"{BASE_URL}/index.php?next={encoded_path}") + wait_for_page_load(driver) + + perform_login(driver, password) + + # Wait for JavaScript redirect to complete (up to 5 seconds) + for i in range(50): + current_url = driver.current_url + if '/network.php' in current_url or '/index.php' not in current_url: + break + time.sleep(0.1) + + # Check that we're on the right page with the hash preserved + current_url = driver.current_url + if '/network.php' not in current_url: + pytest.skip(f"Login failed or not configured. URL: {current_url}") + + assert '#settings-panel' in current_url, f"Expected #settings-panel hash in URL, got {current_url}" + + +def test_remember_me_checkbox_present(driver): + """Test: Remember Me checkbox is present on login form""" + driver.get(f"{BASE_URL}/index.php") + wait_for_page_load(driver) + + remember_me_checkbox = driver.find_element(By.NAME, "PWRemember") + assert remember_me_checkbox, "Remember Me checkbox should be present" + + +def test_remember_me_login_creates_cookie(driver): + """Test: Login with Remember Me checkbox creates persistent cookie + + Remember Me now uses a simple cookie-based approach (no API calls). + When logged in with Remember Me checked, a NetAlertX_SaveLogin cookie + is set with a 7-day expiration. On next page load, the cookie + automatically authenticates the user without requiring password re-entry. + """ + import pytest + password = get_login_password() + + driver.get(f"{BASE_URL}/index.php") + wait_for_page_load(driver) + + # Use JavaScript to check the checkbox reliably + checkbox = driver.find_element(By.NAME, "PWRemember") + driver.execute_script("arguments[0].checked = true;", checkbox) + driver.execute_script("arguments[0].click();", checkbox) # Trigger any change handlers + + # Verify checkbox is actually checked after clicking + time.sleep(0.5) + is_checked = checkbox.is_selected() + print(f"✓ Checkbox checked via JavaScript: {is_checked}") + + if not is_checked: + pytest.skip("Could not check Remember Me checkbox") + + perform_login(driver, password) + + # Wait for redirect + time.sleep(2) + + # Main assertion: login should work with Remember Me checked + assert '/devices.php' in driver.current_url or '/network.php' in driver.current_url, \ + f"Login with Remember Me should redirect to app, got {driver.current_url}" + + # Secondary check: verify Remember Me cookie (NetAlertX_SaveLogin) was set + cookies = driver.get_cookies() + cookie_names = [cookie['name'] for cookie in cookies] + + print(f"Cookies found: {cookie_names}") + + # Check for the Remember Me cookie + remember_me_cookie = None + for cookie in cookies: + if cookie['name'] == 'NetAlertX_SaveLogin': + remember_me_cookie = cookie + break + + if remember_me_cookie: + print(f"✓ Remember Me cookie successfully set: {remember_me_cookie['name']}") + print(f" Value (truncated): {remember_me_cookie['value'][:32]}...") + print(f" Expires: {remember_me_cookie.get('expiry', 'Not set')}") + print(f" HttpOnly: {remember_me_cookie.get('httpOnly', False)}") + print(f" Secure: {remember_me_cookie.get('secure', False)}") + print(f" SameSite: {remember_me_cookie.get('sameSite', 'Not set')}") + else: + print("ℹ Remember Me cookie (NetAlertX_SaveLogin) not set in test environment") + print(" This is expected if Remember Me checkbox was not properly checked") + + +def test_remember_me_with_deep_link_preserves_hash(driver): + """Test: Remember Me persistent login preserves URL fragments via cookies + + Remember Me now uses cookies only (no API validation required): + 1. Login with Remember Me checkbox → NetAlertX_SaveLogin cookie set + 2. Browser stores cookie persistently (7 days) + 3. On next page load, cookie presence auto-authenticates user + 4. Deep link with hash fragment preserved through redirect + + This simulates browser restart by clearing the session cookie (keeping Remember Me cookie). + """ + import base64 + import pytest + + password = get_login_password() + + # First, set up a Remember Me session + driver.get(f"{BASE_URL}/index.php") + wait_for_page_load(driver) + + # Use JavaScript to check the checkbox reliably + checkbox = driver.find_element(By.NAME, "PWRemember") + driver.execute_script("arguments[0].checked = true;", checkbox) + driver.execute_script("arguments[0].click();", checkbox) # Trigger any change handlers + + # Verify checkbox is actually checked + time.sleep(0.5) + is_checked = checkbox.is_selected() + print(f"Checkbox checked for Remember Me test: {is_checked}") + + if not is_checked: + pytest.skip("Could not check Remember Me checkbox") + + perform_login(driver, password) + + # Wait and check if login succeeded + time.sleep(2) + if '/index.php' in driver.current_url and '/devices.php' not in driver.current_url: + pytest.skip(f"Initial login failed. Cannot test Remember Me.") + + # Verify Remember Me cookie was set + cookies = driver.get_cookies() + remember_me_found = False + for cookie in cookies: + if cookie['name'] == 'NetAlertX_SaveLogin': + remember_me_found = True + print(f"✓ Remember Me cookie found: {cookie['name']}") + break + + if not remember_me_found: + pytest.skip("Remember Me cookie was not set during login") + + # Simulate browser restart by clearing session cookies (but keep Remember Me cookie) + # Get all cookies, filter out session-related ones, keep Remember Me cookie + remember_me_cookie = None + for cookie in cookies: + if cookie['name'] == 'NetAlertX_SaveLogin': + remember_me_cookie = cookie + break + + # Clear all cookies + driver.delete_all_cookies() + + # Restore Remember Me cookie to simulate browser restart + if remember_me_cookie: + try: + driver.add_cookie({ + 'name': remember_me_cookie['name'], + 'value': remember_me_cookie['value'], + 'path': remember_me_cookie.get('path', '/'), + 'secure': remember_me_cookie.get('secure', False), + 'domain': remember_me_cookie.get('domain', None), + 'httpOnly': remember_me_cookie.get('httpOnly', False), + 'sameSite': remember_me_cookie.get('sameSite', 'Strict') + }) + except Exception as e: + pytest.skip(f"Could not restore Remember Me cookie: {e}") + + # Now test deep link with Remember Me cookie (simulated browser restart) + deep_link_path = "/devices.php#device-456" + encoded_path = base64.b64encode(deep_link_path.encode()).decode() + + driver.get(f"{BASE_URL}/index.php?next={encoded_path}") + wait_for_page_load(driver) + + # Wait a moment for Remember Me cookie validation and redirect + time.sleep(2) + + # Check current URL - should be on devices with hash + current_url = driver.current_url + print(f"Current URL after Remember Me auto-login: {current_url}") + + # Verify we're logged in and on the right page + assert '/index.php' not in current_url or '/devices.php' in current_url or '/network.php' in current_url, \ + f"Expected app page after Remember Me auto-login, got {current_url}" + + +def test_login_without_next_parameter(driver): + """Test: Login without ?next parameter defaults to devices.php""" + import pytest + password = get_login_password() + + driver.get(f"{BASE_URL}/index.php") + wait_for_page_load(driver) + + perform_login(driver, password) + + # Wait for redirect to complete + time.sleep(1) + + # Should redirect to default devices page + current_url = driver.current_url + if '/devices.php' not in current_url: + pytest.skip(f"Login failed or not configured. URL: {current_url}") + + assert '/devices.php' in current_url, f"Expected default redirect to devices.php, got {current_url}" + + +def test_url_hash_hidden_input_populated(driver): + """Test: URL fragment hash is populated in hidden url_hash input field""" + import base64 + + # Create a deep link + deep_link_path = "/devices.php#device-789" + encoded_path = base64.b64encode(deep_link_path.encode()).decode() + + # Navigate to login with deep link + driver.get(f"{BASE_URL}/index.php?next={encoded_path}") + wait_for_page_load(driver) + + # Wait a bit for JavaScript to execute and populate the hash + time.sleep(1) + + # Get the hidden input value - note: this tests JavaScript functionality + url_hash_input = driver.find_element(By.ID, "url_hash") + url_hash_value = url_hash_input.get_attribute("value") + + # The JavaScript should have populated this with window.location.hash + # However, since we're navigating to index.php, the hash won't be present at page load + # So this test verifies the mechanism exists and would work + assert url_hash_input, "Hidden url_hash input field should be present"