279 lines
11 KiB
Python
279 lines
11 KiB
Python
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import socket
|
|
import sys
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from urllib.parse import urljoin, urlparse
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
APP_DIR = ROOT / 'app'
|
|
TESTS_DIR = ROOT / 'tests'
|
|
sys.path.insert(0, str(APP_DIR))
|
|
sys.path.insert(0, str(TESTS_DIR))
|
|
|
|
try:
|
|
from playwright.sync_api import sync_playwright
|
|
except ImportError:
|
|
sync_playwright = None
|
|
|
|
import uvicorn
|
|
|
|
from admin_api import EDGE_MARKER_HEADER, OPERATOR_HEADER, SECURITY_HEADERS
|
|
import test_admin_api as admin_test_fixture
|
|
from worker_api import create_worker_app
|
|
|
|
|
|
PUBLIC_PREFIX = '/browser-fixture-prefix'
|
|
|
|
|
|
def _browser_executable():
|
|
configured = os.environ.get('TRUF_BROWSER_EXECUTABLE')
|
|
candidates = [
|
|
configured,
|
|
shutil.which('google-chrome'),
|
|
shutil.which('chromium'),
|
|
shutil.which('chromium-browser'),
|
|
shutil.which('chrome'),
|
|
r'C:\Program Files\Google\Chrome\Application\chrome.exe',
|
|
r'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe',
|
|
]
|
|
return next((value for value in candidates if value and os.path.isfile(value)), None)
|
|
|
|
|
|
BROWSER_EXECUTABLE = _browser_executable()
|
|
|
|
|
|
class _PrefixAdapter:
|
|
def __init__(self, app):
|
|
self.app = app
|
|
|
|
async def __call__(self, scope, receive, send):
|
|
if scope['type'] != 'http' or not (
|
|
scope['path'] == PUBLIC_PREFIX
|
|
or scope['path'].startswith(PUBLIC_PREFIX + '/')
|
|
):
|
|
await self.app(scope, receive, send)
|
|
return
|
|
suffix = scope['path'][len(PUBLIC_PREFIX):]
|
|
mapped = dict(scope)
|
|
mapped['path'] = '/admin-internal' + suffix
|
|
mapped['raw_path'] = mapped['path'].encode('ascii')
|
|
await self.app(mapped, receive, send)
|
|
|
|
|
|
class AdminBrowserTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
if sync_playwright is None:
|
|
raise RuntimeError(
|
|
'Playwright is required; install tests/requirements-browser.txt'
|
|
)
|
|
cls.fixture = admin_test_fixture.AdminAPITests(methodName='runTest')
|
|
cls.fixture.setUp()
|
|
app = create_worker_app(
|
|
cls.fixture.worker, reaper_interval_seconds=3600,
|
|
admin_service=cls.fixture.admin,
|
|
)
|
|
cls.app = _PrefixAdapter(app)
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
probe.bind(('127.0.0.1', 0))
|
|
cls.port = probe.getsockname()[1]
|
|
config = uvicorn.Config(
|
|
cls.app, host='127.0.0.1', port=cls.port,
|
|
log_level='critical', lifespan='off', access_log=False,
|
|
)
|
|
cls.server = uvicorn.Server(config)
|
|
cls.thread = threading.Thread(target=cls.server.run, daemon=True)
|
|
cls.thread.start()
|
|
deadline = time.monotonic() + 10
|
|
while not cls.server.started and cls.thread.is_alive():
|
|
if time.monotonic() >= deadline:
|
|
raise RuntimeError('browser fixture server did not start')
|
|
time.sleep(0.02)
|
|
if not cls.server.started:
|
|
raise RuntimeError('browser fixture server failed')
|
|
cls.base_url = f'http://127.0.0.1:{cls.port}{PUBLIC_PREFIX}'
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
cls.server.should_exit = True
|
|
cls.thread.join(timeout=10)
|
|
cls.fixture.tearDown()
|
|
|
|
def _assert_storage_empty(self, page):
|
|
state = page.evaluate('''async () => ({
|
|
local: Object.keys(localStorage),
|
|
session: Object.keys(sessionStorage),
|
|
caches: 'caches' in self ? await caches.keys() : [],
|
|
serviceWorkers: 'serviceWorker' in navigator
|
|
? (await navigator.serviceWorker.getRegistrations()).length : 0,
|
|
indexedDb: indexedDB.databases
|
|
? (await indexedDB.databases()).map(item => item.name || '') : [],
|
|
})''')
|
|
self.assertEqual(state, {
|
|
'local': [], 'session': [], 'caches': [],
|
|
'serviceWorkers': 0, 'indexedDb': [],
|
|
})
|
|
|
|
def _assert_page_contract(self, page, response, *, script_count=0):
|
|
self.assertIsNotNone(response)
|
|
self.assertEqual(response.status, 200)
|
|
headers = response.all_headers()
|
|
for name, expected in SECURITY_HEADERS.items():
|
|
self.assertEqual(headers.get(name.lower()), expected)
|
|
self.assertEqual(page.locator('script').count(), script_count)
|
|
if script_count:
|
|
self.assertEqual(
|
|
page.locator('script:not([src])').count(), 0,
|
|
)
|
|
event_attributes = page.locator('*').evaluate_all('''elements =>
|
|
elements.flatMap(element => Array.from(element.attributes))
|
|
.filter(attribute => attribute.name.toLowerCase().startsWith('on'))
|
|
.map(attribute => attribute.name)
|
|
''')
|
|
self.assertEqual(event_attributes, [])
|
|
scroll_width = page.evaluate('document.documentElement.scrollWidth')
|
|
client_width = page.evaluate('document.documentElement.clientWidth')
|
|
overflowing = page.locator('*').evaluate_all('''elements => elements
|
|
.filter(element => element.getBoundingClientRect().right >
|
|
document.documentElement.clientWidth + 1)
|
|
.slice(0, 10)
|
|
.map(element => ({
|
|
tag: element.tagName,
|
|
className: element.className,
|
|
right: element.getBoundingClientRect().right,
|
|
scrollWidth: element.scrollWidth,
|
|
display: getComputedStyle(element).display,
|
|
width: getComputedStyle(element).width,
|
|
parent: element.parentElement && {
|
|
tag: element.parentElement.tagName,
|
|
className: element.parentElement.className,
|
|
width: getComputedStyle(element.parentElement).width,
|
|
overflow: getComputedStyle(element.parentElement).overflowX,
|
|
},
|
|
}))''')
|
|
self.assertLessEqual(scroll_width, client_width, overflowing)
|
|
for target in page.locator('a[href], link[href], form[action]').evaluate_all(
|
|
'''elements => elements.map(element =>
|
|
new URL(element.getAttribute('href') || element.getAttribute('action'),
|
|
document.baseURI).href)'''
|
|
):
|
|
parsed = urlparse(target)
|
|
self.assertEqual(parsed.netloc, f'127.0.0.1:{self.port}')
|
|
self.assertTrue(parsed.path.startswith(PUBLIC_PREFIX))
|
|
|
|
def test_navigation_csp_mobile_and_secret_non_retention(self):
|
|
console_errors = []
|
|
with sync_playwright() as playwright:
|
|
launch_options = {'headless': True, 'args': ['--disable-gpu']}
|
|
if BROWSER_EXECUTABLE is not None:
|
|
launch_options['executable_path'] = BROWSER_EXECUTABLE
|
|
browser = playwright.chromium.launch(**launch_options)
|
|
context = browser.new_context(
|
|
viewport={'width': 1440, 'height': 900},
|
|
extra_http_headers={
|
|
EDGE_MARKER_HEADER: admin_test_fixture.MARKER,
|
|
OPERATOR_HEADER: admin_test_fixture.OPERATOR,
|
|
},
|
|
)
|
|
context.grant_permissions(
|
|
['clipboard-read', 'clipboard-write'],
|
|
origin=f'http://127.0.0.1:{self.port}',
|
|
)
|
|
page = context.new_page()
|
|
page.on(
|
|
'console',
|
|
lambda message: console_errors.append(message.text)
|
|
if message.type == 'error' else None,
|
|
)
|
|
|
|
response = page.goto(self.base_url + '/secrets', wait_until='load')
|
|
self._assert_page_contract(page, response)
|
|
textarea = page.locator('textarea[name="document_text"]')
|
|
initial_secret = textarea.input_value()
|
|
self.assertIn('editor-secret', initial_secret)
|
|
self.assertEqual(page.content().count('editor-secret'), 1)
|
|
self.assertNotIn(
|
|
'editor-secret',
|
|
page.locator('body').evaluate('''body => {
|
|
const clone = body.cloneNode(true);
|
|
clone.querySelectorAll('textarea').forEach(item => item.remove());
|
|
return clone.textContent;
|
|
}'''),
|
|
)
|
|
self.assertEqual(textarea.get_attribute('autocomplete'), 'off')
|
|
self.assertEqual(textarea.locator('xpath=ancestor::form').get_attribute('autocomplete'), 'off')
|
|
self._assert_storage_empty(page)
|
|
|
|
unsaved = 'browser-unsaved-secret-must-not-persist'
|
|
textarea.fill(initial_secret + '\n' + unsaved)
|
|
page.goto(self.base_url + '/overview', wait_until='load')
|
|
page.go_back(wait_until='load')
|
|
page.reload(wait_until='load')
|
|
self.assertNotIn(unsaved, textarea.input_value())
|
|
self.assertNotIn(unsaved, page.content())
|
|
self._assert_storage_empty(page)
|
|
|
|
response = page.goto(self.base_url + '/', wait_until='load')
|
|
self._assert_page_contract(page, response)
|
|
response = page.goto(
|
|
self.base_url + '/assignments/5', wait_until='load',
|
|
)
|
|
self._assert_page_contract(page, response, script_count=1)
|
|
copy_button = page.locator('[data-copy-target]').first
|
|
expected_json = page.locator('.canonical-json').first.input_value()
|
|
copy_button.click()
|
|
deadline = time.monotonic() + 5
|
|
while (
|
|
copy_button.text_content() != 'Copied canonical JSON'
|
|
and time.monotonic() < deadline
|
|
):
|
|
page.wait_for_timeout(20)
|
|
self.assertEqual(copy_button.text_content(), 'Copied canonical JSON')
|
|
self.assertEqual(
|
|
page.evaluate('navigator.clipboard.readText()'), expected_json,
|
|
)
|
|
self._assert_storage_empty(page)
|
|
|
|
response = page.goto(self.base_url + '/', wait_until='load')
|
|
self._assert_page_contract(page, response)
|
|
navigation = page.locator('nav a').evaluate_all(
|
|
'links => links.map(link => new URL(link.href).href)'
|
|
)
|
|
self.assertGreaterEqual(len(navigation), 8)
|
|
for target in navigation:
|
|
self.assertTrue(urlparse(target).path.startswith(PUBLIC_PREFIX))
|
|
response = page.goto(target, wait_until='load')
|
|
self._assert_page_contract(page, response)
|
|
|
|
self.assertEqual(console_errors, [])
|
|
page.goto(self.base_url + '/overview', wait_until='load')
|
|
page.evaluate('''() => {
|
|
const probe = document.createElement('script');
|
|
probe.textContent = 'window.__trufInlineCspProbe = true';
|
|
document.body.appendChild(probe);
|
|
}''')
|
|
page.wait_for_timeout(100)
|
|
self.assertFalse(page.evaluate('Boolean(window.__trufInlineCspProbe)'))
|
|
|
|
page.set_viewport_size({'width': 390, 'height': 844})
|
|
for path in ('/supervisor', '/files', '/audit', '/secrets'):
|
|
response = page.goto(self.base_url + path, wait_until='load')
|
|
self._assert_page_contract(page, response)
|
|
response = page.goto(
|
|
self.base_url + '/assignments/5', wait_until='load',
|
|
)
|
|
self._assert_page_contract(page, response, script_count=1)
|
|
|
|
self._assert_storage_empty(page)
|
|
context.close()
|
|
browser.close()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|