Initial server source import
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user