300 lines
20 KiB
Python
300 lines
20 KiB
Python
"""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()
|