"""Offline users/roles/password/audit browser scenario; synthetic SQLite data only.""" import os from pathlib import Path import sys import socket import threading import time import uvicorn sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from playwright.sync_api import expect, sync_playwright from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from app.config import Settings, get_settings from app.db import Base, get_db from app.main import app from app.models import Employee, EmployeePublication, utcnow from app.security import ensure_bootstrap_admin from app.services import council def main(): engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool) Base.metadata.create_all(engine) factory = sessionmaker(bind=engine) settings = Settings(_env_file=None, admin_username='admin', admin_password='Browser initial admin', session_secret='browser-users-secret') with factory() as db: admin = ensure_bootstrap_admin(db, settings) admin.first_login_at = utcnow() employee = Employee(profile_key='browser-users', full_name='Сотрудник для проверки', canonical_url='https://www.hse.ru/staff/test', current_data={'sections': [{'title': 'Учёные степени', 'items': ['Доктор технических наук']}]}) db.add(employee) db.flush() db.add(EmployeePublication(employee_id=employee.id, title='Статья для проверки', year=2024, publication_type='ARTICLE', source_hash='browser-users', raw_data={'journal': 'Журнал для проверки'})) db.commit() def database(): with factory() as db: yield db original = council._articles_by_author council._articles_by_author = lambda: {} app.dependency_overrides[get_db] = database app.dependency_overrides[get_settings] = lambda: settings output = Path('.impeccable/review/users') output.mkdir(parents=True, exist_ok=True) listener = socket.socket() listener.bind(('127.0.0.1', 0)) server = uvicorn.Server(uvicorn.Config(app, lifespan='off', access_log=False, log_level='critical')) thread = threading.Thread(target=server.run, kwargs={'sockets': [listener]}, daemon=True) thread.start() for _ in range(1000): if server.started: break time.sleep(.01) assert server.started base_url = f'http://127.0.0.1:{listener.getsockname()[1]}' try: with sync_playwright() as playwright: browser = playwright.chromium.launch(executable_path=os.getenv('BROWSER_EXECUTABLE')) context = browser.new_context(viewport={'width':1440,'height':1000}) page = context.new_page() errors = [] page.on('pageerror', lambda error: errors.append(str(error))) page.goto(base_url + '/admin/login') page.get_by_label('Логин', exact=True).fill('admin') page.get_by_label('Пароль', exact=True).fill('Browser initial admin') page.get_by_role('button', name='Войти', exact=True).click() expect(page.get_by_role('link', name='Пользователи', exact=True)).to_be_visible() page.get_by_role('link', name='Пользователи', exact=True).click() creation = page.locator('form[action="/admin/users"]') creation.get_by_label('Логин', exact=True).fill('reader') creation.get_by_label('Имя', exact=True).fill('Тестовый читатель') creation.get_by_role('combobox', name='Роль', exact=True).select_option('council') creation.get_by_role('button', name='Создать пользователя', exact=True).click() temporary = page.locator('[data-temporary-password]') expect(temporary).to_be_visible() password = temporary.input_value() page.screenshot(path=str(output/'users-desktop.png'), mask=[temporary], full_page=True) page.set_viewport_size({'width':320,'height':844}) assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') page.screenshot(path=str(output/'users-mobile.png'), mask=[temporary], full_page=True) creation.get_by_label('Логин', exact=True).fill('nojs.reader') creation.get_by_role('combobox', name='Роль', exact=True).select_option('user') creation.get_by_role('button', name='Создать пользователя', exact=True).click() nojs_password = page.locator('[data-temporary-password]').input_value() page.get_by_role('button', name='Выйти', exact=True).click() page.goto(base_url + '/admin/login') page.get_by_label('Логин', exact=True).fill('reader') page.get_by_label('Пароль', exact=True).fill(password) page.get_by_role('button', name='Войти', exact=True).click() modal = page.get_by_role('dialog', name='Смените временный пароль') expect(modal).to_be_visible() page.keyboard.press('Escape') expect(modal).to_be_visible() expect(modal.get_by_label('Текущий пароль', exact=True)).to_be_focused() assert modal.evaluate('node => node.scrollWidth <= node.clientWidth') page.screenshot(path=str(output/'required-mobile.png')) page.set_viewport_size({'width':1440,'height':1000}) page.screenshot(path=str(output/'required-desktop.png')) modal.get_by_label('Текущий пароль', exact=True).fill(password) modal.get_by_label('Новый пароль', exact=True).fill('Reader personal passphrase 123') modal.get_by_label('Повторите новый пароль', exact=True).fill('Reader personal passphrase 123') modal.get_by_role('button', name='Сохранить новый пароль', exact=True).click() guide = page.locator('[data-guide]') expect(guide).to_be_visible() expect(guide.get_by_role('heading', name='Познакомьтесь с Workers')).to_be_visible() restricted = context.new_page() restricted.add_init_script("""Object.defineProperty(window, 'sessionStorage', { get() { throw new DOMException('Storage is disabled', 'SecurityError'); }, });""") restricted.goto(base_url + '/admin/account') expect(restricted.get_by_role('link', name='Открыть подсказки', exact=True)).to_be_visible() restricted.get_by_role('link', name='Как пользоваться', exact=True).click() expect(restricted.get_by_role('heading', name='Как пользоваться сайтом')).to_be_visible() restricted.close() for width in (1440, 320): page.set_viewport_size({'width': width, 'height': 900}) assert guide.evaluate('node => node.scrollWidth <= node.clientWidth') assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') page.screenshot(path=str(output / f'guide-first-{width}.png')) page.route('**/admin/guide/dismiss', lambda route: route.fulfill(status=503, body='Temporary failure')) guide.get_by_role('button', name='Закрыть подсказки', exact=True).click() expect(guide.locator('[data-guide-error]')).to_be_visible() guide.get_by_role('button', name='Продолжить без сохранения', exact=True).click() expect(guide).not_to_be_visible() expect(page.get_by_role('link', name='Как пользоваться', exact=True)).to_be_focused() page.unroute('**/admin/guide/dismiss') page.reload() expect(guide).to_be_visible() guide.get_by_role('heading').focus() page.keyboard.press('Escape') expect(guide).not_to_be_visible() expect(page.get_by_role('link', name='Как пользоваться', exact=True)).to_be_focused() page.reload() expect(guide).not_to_be_visible() page.get_by_role('link', name='Как пользоваться', exact=True).click() expect(guide).to_be_visible() go_next = guide.get_by_role('button', name='Далее', exact=True) go_back = guide.get_by_role('button', name='Назад', exact=True) tips = page.locator('.guide__tip:visible') go_next.click() expect(guide.get_by_role('heading', name='Найдите сотрудника', exact=True)).to_be_focused() assert page.url.endswith('/admin/directory') expect(tips).to_have_count(3) search = page.get_by_role('textbox', name='ФИО или ссылка', exact=True) expect(search).to_have_accessible_description('Введите фамилию, часть ФИО или ссылку на профиль ВШЭ.') search.fill('Сотрудник') page.get_by_role('button', name='Применить фильтры', exact=True).click() expect(tips).to_have_count(3) expect(search).to_have_value('Сотрудник') page.get_by_role('link', name='Сбросить', exact=True).click() expect(search).to_have_value('') expect(tips).to_have_count(3) go_back.click() expect(guide.get_by_role('heading', name='Познакомьтесь с Workers')).to_be_visible() for _ in range(3): go_next.click() expect(guide.get_by_role('heading', name='Проверьте повышение квалификации')).to_be_visible() expect(tips).to_have_count(2) expect(page.get_by_role('combobox', name='Курсы в профиле', exact=True)).to_have_accessible_description( 'Выберите «Есть курсы» или «Нет курсов» по записям в профиле ВШЭ.' ) expect(page.get_by_role('textbox', name='С какого года включительно', exact=True)).to_have_accessible_description( 'Если указан только год, будут показаны сотрудники с курсами. ' 'Например, 2024 включает 2024 год и все последующие.' ) for width in (1440, 320): page.set_viewport_size({'width': width, 'height': 900}) assert tips.evaluate_all('tips => tips.every(tip => tip.scrollWidth <= tip.clientWidth)') assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') page.set_viewport_size({'width': 1440, 'height': 900}) for _ in range(3): go_next.click() assert page.url.endswith('/admin/council') expect(tips).to_have_count(3) for width in (1440, 320): page.set_viewport_size({'width': width, 'height': 900}) assert page.evaluate('''() => { const tips = [...document.querySelectorAll('.guide__tip')]; const boxes = tips.map(tip => tip.getBoundingClientRect()); const fields = [...document.querySelectorAll('.council__search input:not([type=hidden]), .council__search select')]; const overlaps = (a, b) => a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; return tips.every(tip => tip.scrollWidth <= tip.clientWidth && tip.scrollHeight <= tip.clientHeight) && boxes.every((box, i) => boxes.slice(i + 1).every(other => !overlaps(box, other)) && fields.every(field => !overlaps(box, field.getBoundingClientRect()))) && document.documentElement.scrollWidth <= innerWidth; }''') guide.evaluate("node => node.scrollIntoView({block: 'start'})") page.screenshot(path=str(output / f'guide-council-{width}.png')) go_back.click() expect(guide.get_by_role('heading', name='Откройте карточку сотрудника')).to_be_visible() profile = page.get_by_role('link', name='Сотрудник для проверки', exact=True) expect(profile).to_have_accessible_description('Нажмите на ФИО, чтобы открыть контакты, степени и публикации сотрудника.') profile.click() expect(guide.get_by_role('heading', name='Продолжите обучение')).to_be_visible() guide.get_by_role('button', name='Продолжить', exact=True).click() expect(guide.get_by_role('heading', name='Откройте карточку сотрудника')).to_be_visible() go_next.click() go_next.click() go_next.click() expect(tips).to_have_count(3) expect(page.locator('[data-guide-count="total"] .guide__tip')).to_contain_text('Уникальные работы') page.screenshot(path=str(output / 'guide-counts-mobile.png')) go_next.click() expect(tips).to_have_count(2) expect(page.locator('[data-guide-count="hse"] .guide__tip')).to_contain_text('личного кабинета') go_next.click() expect(tips).to_have_count(2) go_next.click() assert page.url.endswith('/admin/council/publications') go_next.click() expect(tips).to_have_count(3) warning = page.locator('[data-warning] button').first warning.focus() expect(warning).to_have_attribute('aria-expanded', 'true') page.keyboard.press('Escape') expect(warning).to_have_attribute('aria-expanded', 'false') expect(guide).to_be_visible() page.locator('[data-council-article]').click() page.get_by_role('button', name='Редактировать статью: Статья для проверки', exact=True).click() guide_editor = page.get_by_role('dialog', name='Редактирование статьи') expect(guide_editor.locator('.guide__tip')).to_have_count(3) expect(guide_editor.get_by_role('combobox', name='Квартиль', exact=True)).to_have_accessible_description( 'Правка затронет статьи этого журнала за тот же год. Если год неизвестен, поле недоступно.' ) page.keyboard.press('Escape') expect(guide_editor).not_to_be_visible() expect(guide).to_be_visible() guide.get_by_role('button', name='Завершить подсказки', exact=True).click() expect(guide).not_to_be_visible() expect(tips).to_have_count(0) page.go_back() expect(guide).not_to_be_visible() expect(tips).to_have_count(0) no_js = browser.new_context(java_script_enabled=False) nojs_page = no_js.new_page() nojs_page.goto(base_url + '/admin/login') nojs_page.get_by_label('Логин', exact=True).fill('nojs.reader') nojs_page.get_by_label('Пароль', exact=True).fill(nojs_password) nojs_page.get_by_role('button', name='Войти', exact=True).click() nojs_page.get_by_label('Текущий пароль', exact=True).fill(nojs_password) nojs_page.get_by_label('Новый пароль', exact=True).fill('No JS personal passphrase 123') nojs_page.get_by_label('Повторите новый пароль', exact=True).fill('No JS personal passphrase 123') nojs_page.get_by_role('button', name='Сохранить новый пароль', exact=True).click() nojs_page.get_by_role('link', name='Открыть подсказки', exact=True).click() expect(nojs_page.locator('[data-guide-step]')).to_have_count(2) expect(nojs_page.get_by_text('Повышение квалификации', exact=True)).to_be_visible() expect(nojs_page.get_by_role('link', name='Диссертационный совет', exact=True)).to_have_count(0) expect(nojs_page.get_by_role('link', name='Запуски', exact=True)).to_have_count(0) nojs_page.get_by_role('button', name='Завершить подсказки', exact=True).click() expect(nojs_page.get_by_role('link', name='Открыть подсказки', exact=True)).to_have_count(0) no_js.close() expect(page.get_by_role('link', name='Пользователи', exact=True)).to_have_count(0) expect(page.get_by_role('link', name='Запуски', exact=True)).to_have_count(0) page.get_by_role('link', name='Обзор', exact=True).click() expect(page.get_by_role('button', name='Запустить парсинг', exact=True)).to_have_count(0) page.get_by_role('link', name='Диссертационный совет', exact=True).click() page.locator('.council__candidate-summary').click() article = page.locator('[data-council-article]') article.focus() page.keyboard.press('Enter') page.keyboard.press('Enter') editor = page.get_by_role('dialog', name='Редактирование статьи') editor.get_by_label('Название статьи', exact=True).fill('Статья изменена читателем') editor.get_by_role('button', name='Сохранить', exact=True).click() expect(article.locator('strong')).to_have_text('Статья изменена читателем') page.get_by_role('link', name='Личный кабинет', exact=True).click() expect(page.get_by_text('Редактирование статьи', exact=True)).to_be_visible() expect(page.get_by_text('Статья изменена читателем', exact=False)).to_be_visible() page.screenshot(path=str(output/'account-desktop.png'), full_page=True) page.set_viewport_size({'width':320,'height':844}) assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') page.screenshot(path=str(output/'account-mobile.png'), full_page=True) page.get_by_role('button', name='Выйти', exact=True).click() page.goto(base_url + '/admin/login') page.get_by_label('Логин', exact=True).fill('admin') page.get_by_label('Пароль', exact=True).fill('Browser initial admin') page.get_by_role('button', name='Войти', exact=True).click() page.get_by_role('link', name='Пользователи', exact=True).click() page.once('dialog', lambda dialog: dialog.accept()) page.get_by_role('button', name='Сбросить пароль reader', exact=True).click() expect(page.locator('[data-temporary-password]')).to_be_visible() page.locator('[data-temporary-password]').evaluate("input => input.value = '[скрыто]'") expect(page.locator('.table__cell').filter(has_text='Требуется смена пароля')).to_be_visible() assert not errors, errors browser.close() finally: server.should_exit = True thread.join(timeout=5) listener.close() council._articles_by_author = original app.dependency_overrides.clear() engine.dispose() print(f'Users browser scenario passed; screenshots: {output}') if __name__ == '__main__': main()