Files
glances/tests/test_browser_restful.py
Nicolas Hennion 137dc03a11 Lint the code
2026-03-24 08:18:04 +00:00

290 lines
10 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
#
# Glances - An eye on your system
#
# SPDX-FileCopyrightText: 2024 Nicolas Hennion <nicolas@nicolargo.com>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
"""Glances unit tests for the WebUI/RESTful API Central Browser mode.
Tests cover:
- /api/<version>/serverslist endpoint returns a valid servers list
- Credential fields (password, uri) are stripped from API responses
- Dynamic (Zeroconf) server entries do not leak saved credentials via the API
- Server list structure validation
"""
import re
import shlex
import subprocess
import sys
import time
from pathlib import Path
import pytest
import requests
from glances.outputs.glances_restful_api import GlancesRestfulApi
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
SERVER_PORT = 61237 # Distinct port to avoid conflicts with other tests
API_VERSION = GlancesRestfulApi.API_VERSION
URL = f"http://localhost:{SERVER_PORT}/api/{API_VERSION}"
# Servers injected into the generated config
STATIC_SERVERS = [
{'name': 'localhost', 'alias': 'Local Test Server', 'port': '61209', 'protocol': 'rpc'},
{'name': 'localhost', 'alias': 'Local REST Server', 'port': '61208', 'protocol': 'rest'},
]
PASSWORDS = {
'localhost': 'testpassword',
'default': 'defaultpassword',
}
DEFAULT_CONF = Path(__file__).resolve().parent.parent / 'conf' / 'glances.conf'
# ---------------------------------------------------------------------------
# Helpers generate a browser-enabled config
# ---------------------------------------------------------------------------
def _generate_browser_conf(tmp_path):
"""Read the default glances.conf, activate [serverlist] and [passwords],
write to tmp_path and return the file path."""
source = DEFAULT_CONF.read_text(encoding='utf-8')
# --- Build [serverlist] replacement ---
serverlist_lines = [
'[serverlist]',
'columns=system:hr_name,load:min5,cpu:total,mem:percent',
]
for idx, srv in enumerate(STATIC_SERVERS, start=1):
serverlist_lines.append(f'server_{idx}_name={srv["name"]}')
serverlist_lines.append(f'server_{idx}_alias={srv["alias"]}')
serverlist_lines.append(f'server_{idx}_port={srv["port"]}')
serverlist_lines.append(f'server_{idx}_protocol={srv["protocol"]}')
serverlist_block = '\n'.join(serverlist_lines) + '\n'
# --- Build [passwords] replacement ---
password_lines = ['[passwords]']
for host, pwd in PASSWORDS.items():
password_lines.append(f'{host}={pwd}')
password_block = '\n'.join(password_lines) + '\n'
# Replace existing sections in-place
source = re.sub(
r'\[serverlist\].*?(?=\n\[|\Z)',
serverlist_block,
source,
count=1,
flags=re.DOTALL,
)
source = re.sub(
r'\[passwords\].*?(?=\n\[|\Z)',
password_block,
source,
count=1,
flags=re.DOTALL,
)
conf_file = tmp_path / 'glances.conf'
conf_file.write_text(source, encoding='utf-8')
return str(conf_file)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope='module')
def browser_conf_path(tmp_path_factory):
return _generate_browser_conf(tmp_path_factory.mktemp('browser_api_conf'))
@pytest.fixture(scope='module')
def glances_browser_server(browser_conf_path):
"""Start a Glances web server in browser mode with the generated config."""
cmdline = (
f'{sys.executable}'
f' -m glances -B 0.0.0.0 -w --browser'
f' -p {SERVER_PORT} --disable-webui --disable-autodiscover'
f' -C {browser_conf_path}'
)
args = shlex.split(cmdline)
pid = subprocess.Popen(args)
# Wait for the server to start
time.sleep(5)
yield pid
pid.terminate()
pid.wait(timeout=5)
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def http_get(url):
return requests.get(url, headers={'Accept-encoding': 'identity'}, timeout=10)
# ---------------------------------------------------------------------------
# Tests /api/<version>/serverslist endpoint
# ---------------------------------------------------------------------------
class TestServersListEndpoint:
"""Validate the /serverslist API endpoint."""
def test_serverslist_returns_200(self, glances_browser_server):
req = http_get(f'{URL}/serverslist')
assert req.ok, f'Expected 200, got {req.status_code}'
def test_serverslist_returns_list(self, glances_browser_server):
req = http_get(f'{URL}/serverslist')
data = req.json()
assert isinstance(data, list)
def test_serverslist_has_servers(self, glances_browser_server):
"""At least the configured static servers should be returned."""
req = http_get(f'{URL}/serverslist')
data = req.json()
assert len(data) >= len(STATIC_SERVERS), f'Expected at least {len(STATIC_SERVERS)} servers, got {len(data)}'
def test_serverslist_server_has_required_fields(self, glances_browser_server):
"""Each server entry should have essential fields."""
req = http_get(f'{URL}/serverslist')
required_keys = {'key', 'name', 'ip', 'port', 'protocol', 'status', 'type'}
for server in req.json():
missing = required_keys - set(server.keys())
assert not missing, f'Server {server.get("name")} missing keys: {missing}'
def test_serverslist_server_types(self, glances_browser_server):
req = http_get(f'{URL}/serverslist')
for server in req.json():
assert server['type'] in ('STATIC', 'DYNAMIC')
def test_serverslist_server_protocols(self, glances_browser_server):
req = http_get(f'{URL}/serverslist')
for server in req.json():
assert server['protocol'] in ('rpc', 'rest')
# ---------------------------------------------------------------------------
# Tests Credential sanitization in API responses (CVE fix validation)
# ---------------------------------------------------------------------------
class TestServersListCredentialSanitization:
"""Verify that password and uri fields are stripped from API responses.
This validates the fix for CVE-2026-32633.
"""
def test_no_password_field_in_response(self, glances_browser_server):
"""The 'password' field must be stripped from all server entries."""
req = http_get(f'{URL}/serverslist')
for server in req.json():
assert 'password' not in server, f'Server {server.get("name")} still exposes "password" field'
def test_no_uri_field_in_response(self, glances_browser_server):
"""The 'uri' field must be stripped from all server entries."""
req = http_get(f'{URL}/serverslist')
for server in req.json():
assert 'uri' not in server, f'Server {server.get("name")} still exposes "uri" field'
def test_no_credential_in_any_field(self, glances_browser_server):
"""No field value should contain embedded credentials (user:pass@ pattern)."""
req = http_get(f'{URL}/serverslist')
cred_pattern = re.compile(r'://[^/]*:[^/]*@')
for server in req.json():
for key, value in server.items():
if isinstance(value, str):
assert not cred_pattern.search(value), (
f'Server {server.get("name")}, field "{key}" contains embedded credentials: {value}'
)
# ---------------------------------------------------------------------------
# Tests _sanitize_server static method
# ---------------------------------------------------------------------------
class TestSanitizeServer:
"""Unit tests for GlancesRestfulApi._sanitize_server (no server needed)."""
def test_strips_password(self):
server = {'name': 'host', 'password': 'secret', 'status': 'ONLINE'}
safe = GlancesRestfulApi._sanitize_server(server)
assert 'password' not in safe
def test_strips_uri(self):
server = {'name': 'host', 'uri': 'http://u:p@host:61209', 'status': 'ONLINE'}
safe = GlancesRestfulApi._sanitize_server(server)
assert 'uri' not in safe
def test_preserves_other_fields(self):
server = {
'name': 'host',
'ip': '10.0.0.1',
'port': 61209,
'protocol': 'rpc',
'status': 'ONLINE',
'type': 'STATIC',
'password': 'secret',
'uri': 'http://u:p@host:61209',
}
safe = GlancesRestfulApi._sanitize_server(server)
assert safe['name'] == 'host'
assert safe['ip'] == '10.0.0.1'
assert safe['port'] == 61209
assert safe['protocol'] == 'rpc'
assert safe['status'] == 'ONLINE'
assert safe['type'] == 'STATIC'
def test_does_not_mutate_original(self):
server = {'name': 'host', 'password': 'secret', 'uri': 'http://x'}
GlancesRestfulApi._sanitize_server(server)
assert 'password' in server
assert 'uri' in server
def test_handles_missing_fields(self):
"""Server dict without password/uri should not raise."""
server = {'name': 'host', 'status': 'ONLINE'}
safe = GlancesRestfulApi._sanitize_server(server)
assert safe == server
# ---------------------------------------------------------------------------
# Tests Multiple sequential requests (stability)
# ---------------------------------------------------------------------------
class TestServersListStability:
"""Ensure repeated calls return consistent, sanitized results."""
def test_repeated_calls_consistent(self, glances_browser_server):
"""Multiple sequential requests should return the same server count."""
counts = []
for _ in range(3):
req = http_get(f'{URL}/serverslist')
assert req.ok
counts.append(len(req.json()))
assert len(set(counts)) == 1, f'Inconsistent server counts across calls: {counts}'
def test_repeated_calls_never_leak_credentials(self, glances_browser_server):
"""Credentials must remain stripped across multiple polling cycles."""
for _ in range(3):
req = http_get(f'{URL}/serverslist')
for server in req.json():
assert 'password' not in server
assert 'uri' not in server