fix: align admin navigation with backend capability
This commit is contained in:
1 parent
8bd2b53469
commit
a1bb3eab98
8 files changed
+79
-32
No files matched your search
@@ -10,7 +10,8 @@ Telegram-бот — только часть рабочего окружения
|
||||
## Текущее состояние
|
||||
|
||||
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2`, backend версии
|
||||
`0.13.1` и frontend `0.3.2` с поисковым экраном и внутренней лабораторией.
|
||||
`0.13.2` и frontend `0.3.3` с поисковым экраном, внутренней лабораторией и
|
||||
согласованным отображением доступных действий учетной записи.
|
||||
Авторизация хранится в
|
||||
SQLite; первый пользователь `admin` создаётся интерактивной CLI-командой.
|
||||
Поиск расширяет аббревиатуры организационно-правовых форм и поднимает
|
||||
@@ -19,8 +20,8 @@ SQLite; первый пользователь `admin` создаётся инт
|
||||
| Компонент | Версия | Состояние |
|
||||
|---|---:|---|
|
||||
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
||||
| Backend | `0.13.1` | авторизация, поиск с фильтрами, ограниченный staging и статические ресурсы |
|
||||
| Frontend | `0.3.2` | поиск по правовой базе, просмотр редакций, настраиваемая ширина панелей и внутренняя лаборатория |
|
||||
| Backend | `0.13.2` | авторизация, серверная capability управления пользователями, поиск и статические ресурсы |
|
||||
| Frontend | `0.3.3` | поиск по правовой базе, просмотр редакций, внутренняя лаборатория и контекстная навигация кабинета |
|
||||
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
|
||||
| RAG и база знаний | — | ещё не созданы |
|
||||
|
||||
@@ -63,4 +64,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.1 · Frontend v0.3.2
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.2 · Frontend v0.3.3
|
||||
@@ -13,7 +13,7 @@ import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from search.auth import AuthError, AuthStore
|
||||
from search.auth import AuthError, AuthStore, can_manage_users
|
||||
from search.minjust_opensearch import APP_VERSION, request_json
|
||||
from search.catalog import CATALOGS, labels
|
||||
from search.query import build_search_query
|
||||
@@ -345,21 +345,21 @@ class Api:
|
||||
return 200, {"account": account, "session_token": token}
|
||||
except AuthError as error:
|
||||
raise ApiError(400, str(error)) from error
|
||||
if user and user["username"].casefold() == "admin" and method == "GET" and parts == ["admin", "users"]:
|
||||
if user and can_manage_users(user["username"]) and method == "GET" and parts == ["admin", "users"]:
|
||||
return 200, {"users": self.auth_store.list_users()}
|
||||
if user and user["username"].casefold() == "admin" and method == "POST" and parts == ["admin", "users"]:
|
||||
if user and can_manage_users(user["username"]) and method == "POST" and parts == ["admin", "users"]:
|
||||
try:
|
||||
self.auth_store.create_user(body.get("username"), body.get("password"))
|
||||
return 201, {"ok": True}
|
||||
except AuthError as error:
|
||||
raise ApiError(400, str(error)) from error
|
||||
if user and user["username"].casefold() == "admin" and method == "DELETE" and len(parts) == 3 and parts[:2] == ["admin", "users"]:
|
||||
if user and can_manage_users(user["username"]) and method == "DELETE" and len(parts) == 3 and parts[:2] == ["admin", "users"]:
|
||||
try:
|
||||
self.auth_store.delete_user(parts[2])
|
||||
return 200, {"ok": True}
|
||||
except AuthError as error:
|
||||
raise ApiError(400, str(error)) from error
|
||||
if user and user["username"].casefold() == "admin" and method == "POST" and len(parts) == 4 and parts[:2] == ["admin", "users"] and parts[3] == "reset-password":
|
||||
if user and can_manage_users(user["username"]) and method == "POST" and len(parts) == 4 and parts[:2] == ["admin", "users"] and parts[3] == "reset-password":
|
||||
try:
|
||||
self.auth_store.reset_password(parts[2], body.get("password"))
|
||||
return 200, {"ok": True}
|
||||
@@ -424,7 +424,7 @@ def handler(api: Api):
|
||||
raise ApiError(401, "authentication required")
|
||||
if user["must_change_password"] and not (method == "POST" and route == "/auth/change-password") and not (method == "GET" and route == "/auth/me"):
|
||||
raise ApiError(403, "change password before continuing")
|
||||
if route.startswith("/admin/users") and user["username"].casefold() != "admin":
|
||||
if route.startswith("/admin/users") and not can_manage_users(user["username"]):
|
||||
raise ApiError(403, "admin account required")
|
||||
body = None
|
||||
if method in {"POST", "DELETE"}:
|
||||
@@ -503,7 +503,7 @@ def main() -> int:
|
||||
api = Api(arguments.url, arguments.index, arguments.data, arguments.reviews_db, secret, arguments.users_db, arguments.cookie_secure)
|
||||
if arguments.create_admin or arguments.reset_admin_password:
|
||||
users = api.auth_store.list_users()
|
||||
admin_exists = any(user["username"].casefold() == "admin" for user in users)
|
||||
admin_exists = any(can_manage_users(user["username"]) for user in users)
|
||||
if arguments.create_admin and users:
|
||||
parser.error("accounts already exist")
|
||||
if arguments.reset_admin_password and not admin_exists:
|
||||
|
||||
@@ -22,6 +22,10 @@ class AuthError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def can_manage_users(username: str) -> bool:
|
||||
return isinstance(username, str) and username.casefold() == "admin"
|
||||
|
||||
|
||||
class AuthStore:
|
||||
def __init__(self, path: Path | str):
|
||||
if path != ":memory:":
|
||||
@@ -106,7 +110,8 @@ class AuthStore:
|
||||
|
||||
@staticmethod
|
||||
def _public(user: sqlite3.Row) -> dict:
|
||||
return {"username": user["username"], "role": "Администратор", "must_change_password": bool(user["must_change_password"]), "created_at": user["created_at"]}
|
||||
can_manage = can_manage_users(user["username"])
|
||||
return {"username": user["username"], "role": "Администратор" if can_manage else "Пользователь", "can_manage_users": can_manage, "must_change_password": bool(user["must_change_password"]), "created_at": user["created_at"]}
|
||||
|
||||
def user_for_token(self, token: str | None) -> dict | None:
|
||||
if not token:
|
||||
@@ -151,7 +156,7 @@ class AuthStore:
|
||||
raise AuthError("login already exists") from error
|
||||
|
||||
def delete_user(self, username: str) -> None:
|
||||
if username.casefold() == "admin":
|
||||
if can_manage_users(username):
|
||||
raise AuthError("admin account cannot be deleted")
|
||||
with self._lock:
|
||||
self.connection.execute("DELETE FROM users WHERE username=?", (username,))
|
||||
|
||||
@@ -17,7 +17,7 @@ from typing import Iterator
|
||||
|
||||
from search.catalog import authority_codes, legal_force_code, source_code
|
||||
|
||||
APP_VERSION = "0.13.1"
|
||||
APP_VERSION = "0.13.2"
|
||||
LANGUAGES = {"ru", "ky"}
|
||||
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
|
||||
|
||||
@@ -465,7 +465,7 @@ def main() -> int:
|
||||
else:
|
||||
documents, fragments = export_bulk(arguments.input, arguments.output, arguments.index, arguments.limit)
|
||||
destination = arguments.output
|
||||
print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash · Backend v{APP_VERSION} · Frontend v0.3.2")
|
||||
print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash · Backend v{APP_VERSION} · Frontend v0.3.3")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -657,7 +657,7 @@
|
||||
<section class="search__document" id="public-document-panel" aria-labelledby="public-document-heading"><div class="search__document-head"><div class="search__breadcrumbs" hidden><span>Документ</span><span aria-hidden="true">/</span><span id="document-breadcrumb">Выберите результат</span></div><div><button class="search__mobile-back" id="document-back" type="button">К результатам</button><button class="search__expand" id="document-expand" type="button" aria-label="Развернуть документ">Развернуть</button></div><h2 id="public-document-heading">Документ</h2></div><p id="public-document-meta" class="search__document-meta"><span id="document-facts">Выберите документ в выдаче, чтобы открыть текст.</span><label class="search__document-language" hidden>Язык<select id="document-language" disabled aria-label="Язык документа"></select></label></p><div class="search__document-toolbar"><details class="search__toc" id="document-toc"><summary>Содержание</summary><nav aria-label="Содержание документа"><ol id="document-toc-list"><li>Оглавление появится после загрузки документа</li></ol></nav></details><label class="search__edition-label"><select id="document-edition" disabled aria-label="Редакция документа"></select></label><button id="document-print" type="button">Печать</button><a id="document-source" class="search__source-link" href="#" target="_blank" rel="noreferrer" hidden>Официальный источник</a></div><div class="search__find"><label for="document-find">Найти в документе</label><input id="document-find" type="search" placeholder="Фраза в документе" disabled><span class="search__find-count" id="document-find-count" aria-live="polite"></span><button id="document-find-prev" type="button" disabled aria-label="Предыдущее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m12 4-6 6 6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button><button id="document-find-next" type="button" disabled aria-label="Следующее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m8 4 6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button></div><p class="search__document-note" id="document-note" hidden></p><article id="public-document-body" class="search__document-body"></article></section>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="search__footer"><span><strong>Акылдаш</strong> <span aria-hidden="true">·</span> Правовая база Кыргызстана</span><span>Frontend v0.3.2 <span aria-hidden="true">·</span> Backend v0.13.1</span></footer>
|
||||
<footer class="search__footer"><span><strong>Акылдаш</strong> <span aria-hidden="true">·</span> Правовая база Кыргызстана</span><span>Frontend v0.3.3 <span aria-hidden="true">·</span> Backend v0.13.2</span></footer>
|
||||
<div class="review__actions"><div class="review__actions-inner"><label>Проверяющий<input id="reviewer" readonly aria-label="Проверяющий"></label><label>Комментарий к выдаче <input id="overall-comment" maxlength="4000" placeholder="Что важно учесть? (необязательно)"></label><button class="review__button--primary" id="save" type="button" disabled>Сначала выполните поиск</button><p id="draft-status" class="review__status" role="status" aria-live="polite">Черновик появится после поиска.</p></div></div>
|
||||
<dialog id="login-dialog" aria-labelledby="login-heading"><form class="review__page" id="login-form"><h2 id="login-heading">Вход в Акылдаш</h2><p>Введите учетные данные, выданные администратором.</p><label>Логин<input id="login-username" name="username" autocomplete="username" required minlength="3" maxlength="64"></label><label>Пароль<input id="login-password" name="password" type="password" autocomplete="current-password" required></label><p id="login-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Войти</button></form></dialog>
|
||||
<dialog id="password-dialog" aria-labelledby="password-heading"><form class="review__page" id="password-form"><h2 id="password-heading">Создайте новый пароль</h2><p class="review__notice">Для продолжения замените временный пароль. Используйте не менее 12 символов.</p><label>Текущий пароль<input id="current-password" type="password" autocomplete="current-password" required></label><label>Новый пароль<input id="new-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><label>Повторите новый пароль<input id="confirm-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p id="password-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Сохранить пароль</button></form></dialog>
|
||||
@@ -675,7 +675,7 @@
|
||||
<dialog id="document-dialog"><button class="review__dialog-close" id="dialog-close" type="button">Закрыть</button><h2 id="dialog-heading">Документ</h2><div class="review__document-body" id="dialog-body"></div></dialog>
|
||||
<dialog class="search__about" id="sources-dialog" aria-labelledby="sources-heading"><button class="review__dialog-close" id="sources-close" type="button">Закрыть</button><h2 id="sources-heading">Источники документов</h2><p>Поиск выполняется по документам, подготовленным из правовых источников Кыргызской Республики. Для каждого результата доступная редакция и ссылка на официальный источник показываются в панели документа.</p><p>Официальный источник: <a href="https://cbd.minjust.gov.kg/" target="_blank" rel="noreferrer">Централизованный банк данных правовой информации Министерства юстиции Кыргызской Республики</a>.</p></dialog>
|
||||
<div class="review__tour-spotlight" id="tutorial-spotlight" hidden aria-hidden="true"></div><dialog class="review__tour" id="tutorial-dialog" aria-labelledby="tutorial-heading" aria-describedby="tutorial-text"><div class="review__tour-content"><p class="review__tour-kicker">Короткое знакомство</p><h2 id="tutorial-heading"></h2><p id="tutorial-text"></p><div class="review__tour-footer"><span class="review__tour-progress" id="tutorial-progress"></span><div class="review__tour-actions"><button id="tutorial-skip" type="button">Пропустить</button><button class="review__button--primary" id="tutorial-next" type="button">Далее</button></div></div></div></dialog>
|
||||
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.3.2 · Backend v0.13.1 · Внутренняя лаборатория релевантности</div></footer>
|
||||
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.3.3 · Backend v0.13.2 · Внутренняя лаборатория релевантности</div></footer>
|
||||
<script>
|
||||
document.body.classList.add('review__locked');
|
||||
const DRAFT_KEY = 'akyldash-search-review-draft';
|
||||
@@ -708,11 +708,11 @@
|
||||
$('review-review-nav').classList.toggle('is-active', reviewRoute); $('review-search-nav').classList.toggle('is-active', searchRoute); $('review-users-open').classList.toggle('is-active', usersRoute); $('review-nav').classList.toggle('is-active', reviewRoute); $('search-nav').classList.toggle('is-active', searchRoute);
|
||||
document.body.classList.toggle('is-review-route', reviewRoute);
|
||||
document.body.classList.toggle('is-search-route', searchRoute); document.body.classList.toggle('is-users-route', usersRoute);
|
||||
if (usersRoute) { document.title = 'Пользователи · Акылдаш'; $('route-heading').textContent = 'Пользователи'; $('route-description').textContent = 'Управляйте учётными записями. Доступ к действиям проверяется сервером.'; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#users-page'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.2 · Backend v0.13.1'; }
|
||||
else if (!reviewRoute) { document.title = 'Поиск по правовой базе · Акылдаш'; document.querySelector('.review__header-content').hidden = true; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#public-results'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.2 · Backend v0.13.1'; }
|
||||
if (usersRoute) { document.title = 'Пользователи · Акылдаш'; $('route-heading').textContent = 'Пользователи'; $('route-description').textContent = 'Управляйте учётными записями. Доступ к действиям проверяется сервером.'; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#users-page'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.3 · Backend v0.13.2'; }
|
||||
else if (!reviewRoute) { document.title = 'Поиск по правовой базе · Акылдаш'; document.querySelector('.review__header-content').hidden = true; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#public-results'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.3 · Backend v0.13.2'; }
|
||||
async function apiFetch(path, options = {}) { const response = await fetch(path, {credentials: 'same-origin', ...options}); if (response.status === 401 && path !== '/auth/login') { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); } return response; }
|
||||
const showLogin = () => { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); };
|
||||
const enterWorkspace = (user) => { account = user; $('signed-in-user').textContent = user.username; $('review-signed-in-user').textContent = user.username; $('reviewer').value = user.username; $('profile-username').textContent = user.username; $('profile-role').textContent = user.role; $('profile-created').textContent = new Date(user.created_at * 1000).toLocaleDateString('ru-RU'); const canManageUsers = user.role === 'admin'; $('search-users-open').hidden = $('review-users-open').hidden = !canManageUsers; document.body.classList.remove('review__locked'); if (usersRoute) { if (!canManageUsers) { location.replace('/'); return; } document.body.classList.add('is-admin'); const savedLanguage = (() => { try { return localStorage.getItem('akyldash-ui-language') || 'ru'; } catch (_) { return 'ru'; } })(); updateUsersLanguage(savedLanguage); } else if (searchRoute) initSearchPage(); };
|
||||
const enterWorkspace = (user) => { account = user; $('signed-in-user').textContent = user.username; $('review-signed-in-user').textContent = user.username; $('reviewer').value = user.username; $('profile-username').textContent = user.username; $('profile-role').textContent = user.role; $('profile-created').textContent = new Date(user.created_at * 1000).toLocaleDateString('ru-RU'); const canManageUsers = user.can_manage_users === true; $('review-nav').hidden = true; $('review-users-open').hidden = !canManageUsers || searchRoute; $('search-users-open').hidden = !canManageUsers || !searchRoute; document.body.classList.remove('review__locked'); if (usersRoute) { if (!canManageUsers) { location.replace('/'); return; } document.body.classList.add('is-admin'); const savedLanguage = (() => { try { return localStorage.getItem('akyldash-ui-language') || 'ru'; } catch (_) { return 'ru'; } })(); updateUsersLanguage(savedLanguage); } else if (searchRoute) initSearchPage(); };
|
||||
const filterFields = [['statuses', {ru: 'Статус', ky: 'Статус'}, 'status'], ['document_types', {ru: 'Вид документа', ky: 'Документтин түрү'}, 'document_type'], ['authorities', {ru: 'Орган принятия', ky: 'Кабыл алган орган'}, 'authority']];
|
||||
let filterCatalogs = {};
|
||||
let filterCatalogsLoaded = false;
|
||||
@@ -1210,7 +1210,7 @@
|
||||
$('document-source').textContent = copy('source');
|
||||
const modal = modalCopy[uiLanguage];
|
||||
$('account-heading').textContent = modal.account; $('review-nav').textContent = modal.lab; $('search-users-open').textContent = modal.usersHeading; $('search-logout').textContent = modal.logout;
|
||||
$('profile-role').textContent = account?.role === 'admin' ? modal.admin : account?.role || '';
|
||||
$('profile-role').textContent = account?.role || '';
|
||||
document.querySelectorAll('.review__password-toggle').forEach((button) => { const visible = button.getAttribute('aria-pressed') === 'true'; const label = visible ? modal.hidePassword : modal.showPassword; button.textContent = label; button.setAttribute('aria-label', label); button.title = label; });
|
||||
$('profile-tab').textContent = ky ? 'Профиль' : 'Профиль'; $('security-tab').textContent = ky ? 'Коопсуздук' : 'Безопасность';
|
||||
document.querySelectorAll('[data-close]').forEach((button) => { button.setAttribute('aria-label', modal.close); button.title = modal.close; });
|
||||
@@ -1342,7 +1342,7 @@
|
||||
document.querySelectorAll('.review__user-table-head [role="columnheader"]').forEach((cell, index) => { cell.textContent = [words.login, words.role, words.state, words.lastLogin, words.actions][index]; });
|
||||
$('create-user-heading').textContent = words.create; document.querySelectorAll('#create-user-form label').forEach((label, index) => { const input = label.querySelector('input'); label.firstChild.textContent = `${[words.username, words.tempPassword][index]} `; if (input) label.append(input); }); document.querySelector('#create-user-form p').textContent = words.tempHelp; document.querySelector('#create-user-form button[type="submit"]').textContent = words.create;
|
||||
$('delete-user-heading').textContent = words.deleteHeading; $('delete-user-confirm').textContent = words.delete; document.querySelectorAll('#create-user-dialog [data-close], #delete-user-dialog [data-close]').forEach((button) => button.textContent = words.cancel);
|
||||
document.querySelector('footer .review__header-content').textContent = `Акылдаш · Frontend v0.3.2 · Backend v0.13.1`;
|
||||
document.querySelector('footer .review__header-content').textContent = `Акылдаш · Frontend v0.3.3 · Backend v0.13.2`;
|
||||
if (usersRoute && account) loadUsers();
|
||||
};
|
||||
$('users-ui-ru').addEventListener('click', () => updateUsersLanguage('ru'));
|
||||
|
||||
@@ -59,6 +59,34 @@ class SearchApiTest(unittest.TestCase):
|
||||
admin_api = get("/admin/users")
|
||||
admin_api.send_response.assert_called_once_with(401)
|
||||
|
||||
def test_account_capability_matches_the_admin_users_api_guard(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = self.make_api(Path(temporary))
|
||||
api.auth_store.create_admin("Fixture-only-admin-password-2026")
|
||||
admin_token, _ = api.auth_store.login("admin", "Fixture-only-admin-password-2026")
|
||||
api.auth_store.create_user("fixture-user", "Fixture-only-temporary-password-2026")
|
||||
api.auth_store.change_password("fixture-user", "Fixture-only-temporary-password-2026", "Fixture-only-user-password-2026")
|
||||
user_token, _ = api.auth_store.login("fixture-user", "Fixture-only-user-password-2026")
|
||||
request_handler = handler(api)
|
||||
|
||||
def get(path, token):
|
||||
request = request_handler.__new__(request_handler)
|
||||
request.path = path
|
||||
request.headers = {"Cookie": f"akyldash_session={token}"}
|
||||
request.wfile = io.BytesIO()
|
||||
request.send_response = Mock()
|
||||
request.send_header = Mock()
|
||||
request.end_headers = Mock()
|
||||
request.do_GET()
|
||||
return request
|
||||
|
||||
admin_account = json.loads(get("/auth/me", admin_token).wfile.getvalue())["account"]
|
||||
user_account = json.loads(get("/auth/me", user_token).wfile.getvalue())["account"]
|
||||
self.assertEqual((admin_account["role"], admin_account["can_manage_users"]), ("Администратор", True))
|
||||
self.assertEqual((user_account["role"], user_account["can_manage_users"]), ("Пользователь", False))
|
||||
get("/admin/users", admin_token).send_response.assert_called_once_with(200)
|
||||
get("/admin/users", user_token).send_response.assert_called_once_with(403)
|
||||
|
||||
def test_search_pagination_filters_and_highlight(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = self.make_api(Path(temporary))
|
||||
|
||||
@@ -5,8 +5,9 @@ const repo = path.resolve(__dirname, '../..');
|
||||
const artifacts = process.env.VISUAL_REVIEW_ARTIFACTS || path.join(repo, 'docs/product/visual-review');
|
||||
const html = fs.readFileSync(path.join(repo, 'backend/search/review.html'), 'utf8');
|
||||
const root = path.join(repo, 'backend/search/static/fonts');
|
||||
const {docs, reviewDocs, catalogs, passwordChangeAccount, article, articleKy} = require('./fixtures.cjs');
|
||||
const {docs, reviewDocs, catalogs, passwordChangeAccount, ordinaryAccount, article, articleKy} = require('./fixtures.cjs');
|
||||
fs.mkdirSync(artifacts, {recursive: true});
|
||||
let fixtureAccount = passwordChangeAccount;
|
||||
const fixtureState = {users:[{username:'fixture-user',role:'user',must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}],passwordChanges:0,userCreates:0,passwordResets:0,userDeletes:0,reviewSaves:0,lastReviewRatings:[]};
|
||||
let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMetadata = false; let failReviewDocumentB = false; let delayReviewDocumentA = false; let delayNextReviewSearch = false;
|
||||
(async()=>{
|
||||
@@ -18,10 +19,10 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
|
||||
const req=route.request(), url=new URL(req.url());
|
||||
if (req.isNavigationRequest()) return route.fulfill({status:200,contentType:'text/html; charset=utf-8',body:html});
|
||||
const p=url.pathname;
|
||||
if(p==='/auth/me') return route.fulfill({json:{account:passwordChangeAccount}});
|
||||
if(p==='/auth/me') return route.fulfill({json:{account:fixtureAccount}});
|
||||
if(p==='/search/filters') return route.fulfill({json:catalogs});
|
||||
if(p==='/search-reviews' && req.method()==='POST') { fixtureState.reviewSaves++; fixtureState.lastReviewRatings=req.postDataJSON().results.map((result)=>({code:result.code,rating:result.rating})); return route.fulfill({json:{id:42}}); }
|
||||
if(p==='/auth/change-password' && req.method()==='POST') { fixtureState.passwordChanges++; return route.fulfill({json:{account:passwordChangeAccount}}); }
|
||||
if(p==='/auth/change-password' && req.method()==='POST') { fixtureState.passwordChanges++; return route.fulfill({json:{account:fixtureAccount}}); }
|
||||
if(p==='/admin/users' && req.method()==='GET') return route.fulfill({json:{users:fixtureState.users}});
|
||||
if(p==='/admin/users' && req.method()==='POST') { const body=req.postDataJSON(); fixtureState.userCreates++; fixtureState.users.push({username:body.username,role:'user',must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}); return route.fulfill({status:201,json:{username:body.username}}); }
|
||||
const resetMatch=p.match(/^\/admin\/users\/([^/]+)\/reset-password$/);
|
||||
@@ -248,7 +249,7 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
|
||||
["expandedFocusRestored!=='document-expand'", expandedFocusRestored!=='document-expand'],
|
||||
["!accountActions.open", !accountActions.open],
|
||||
["!accountActions.heading", !accountActions.heading],
|
||||
["!accountActions.reviewVisible", !accountActions.reviewVisible],
|
||||
["accountActions.reviewVisible", accountActions.reviewVisible],
|
||||
["accountActions.reviewHref!=='/review'", accountActions.reviewHref!=='/review'],
|
||||
["!accountActions.usersVisible", !accountActions.usersVisible],
|
||||
["accountActions.usersHref!=='/users'", accountActions.usersHref!=='/users'],
|
||||
@@ -352,15 +353,15 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
|
||||
["!reviewFlow.reviewScroll.footerAfterSave", !reviewFlow.reviewScroll.footerAfterSave],
|
||||
["!reviewFlow.reviewSaved.includes('Оценка сохранена')", !reviewFlow.reviewSaved.includes('Оценка сохранена')]
|
||||
].filter(([, failed]) => failed).map(([name]) => name); console.log('labLayout',JSON.stringify(labLayout)); console.log('reviewFlow',JSON.stringify(reviewFlow)); console.log('failedCoreChecks', JSON.stringify(failedCoreChecks)); if (failedCoreChecks.length) process.exit(1);
|
||||
const usersPage=page; await usersPage.setViewportSize({width:320,height:760}); await usersPage.goto('https://fixture.invalid/users'); await usersPage.locator('#user-list .review__user-row').waitFor(); const usersRoute=await usersPage.evaluate(()=>({visibleFooters:[...document.querySelectorAll('body > footer')].filter(el=>getComputedStyle(el).display!=='none'&&el.getBoundingClientRect().height>0).length,visible:!document.querySelector('#users-page').hidden,admin:document.body.classList.contains('is-admin'),accountOpen:document.querySelector('#account-dialog').open,headerVisible:getComputedStyle(document.querySelector('body > header.review__header')).display!=='none',footerAfterMain:Boolean(document.querySelector('#users-page').compareDocumentPosition(document.querySelector('body > footer.review__header')) & Node.DOCUMENT_POSITION_FOLLOWING),reviewActionsHidden:getComputedStyle(document.querySelector('body > .review__actions')).display==='none',footerAfterUsers:document.querySelector('body > footer.review__header').getBoundingClientRect().top>=document.querySelector('#user-list').getBoundingClientRect().bottom,overflow:document.documentElement.scrollWidth>innerWidth})); await usersPage.locator('#users-ui-ky').click(); const usersKy=await usersPage.evaluate(()=>({lang:document.documentElement.lang,heading:document.querySelector('#users-heading').textContent})); console.log('usersRouteCheck',JSON.stringify({usersRoute,usersKy,errors})); if(usersRoute.visibleFooters!==1||!usersRoute.visible||!usersRoute.admin||usersRoute.accountOpen||!usersRoute.headerVisible||!usersRoute.footerAfterMain||!usersRoute.reviewActionsHidden||!usersRoute.footerAfterUsers||usersRoute.overflow||errors.length||usersKy.lang!=='ky'||usersKy.heading!=='Колдонуучулар') process.exit(1);
|
||||
const usersPage=page; await usersPage.setViewportSize({width:320,height:760}); await usersPage.goto('https://fixture.invalid/users'); await usersPage.locator('#user-list .review__user-row').waitFor(); const usersRoute=await usersPage.evaluate(()=>({visibleFooters:[...document.querySelectorAll('body > footer')].filter(el=>getComputedStyle(el).display!=='none'&&el.getBoundingClientRect().height>0).length,visible:!document.querySelector('#users-page').hidden,admin:document.body.classList.contains('is-admin'),accountOpen:document.querySelector('#account-dialog').open,headerVisible:getComputedStyle(document.querySelector('body > header.review__header')).display!=='none',footerAfterMain:Boolean(document.querySelector('#users-page').compareDocumentPosition(document.querySelector('body > footer.review__header')) & Node.DOCUMENT_POSITION_FOLLOWING),reviewActionsHidden:getComputedStyle(document.querySelector('body > .review__actions')).display==='none',footerAfterUsers:document.querySelector('body > footer.review__header').getBoundingClientRect().top>=document.querySelector('#user-list').getBoundingClientRect().bottom,overflow:document.documentElement.scrollWidth>innerWidth})); await usersPage.locator('#users-ui-ky').click(); const usersKy=await usersPage.evaluate(()=>({lang:document.documentElement.lang,heading:document.querySelector('#users-heading').textContent})); const usersNavContract=await usersPage.evaluate(()=>({headerUsers:!document.querySelector('#review-users-open').hidden,dialogUsers:!document.querySelector('#search-users-open').hidden,dialogLab:!document.querySelector('#review-nav').hidden,headerLab:!document.querySelector('#review-review-nav').hidden})); console.log('usersRouteCheck',JSON.stringify({usersRoute,usersKy,usersNavContract,errors})); if(usersRoute.visibleFooters!==1||!usersRoute.visible||!usersRoute.admin||usersRoute.accountOpen||!usersRoute.headerVisible||!usersRoute.footerAfterMain||!usersRoute.reviewActionsHidden||!usersRoute.footerAfterUsers||usersRoute.overflow||errors.length||usersKy.lang!=='ky'||usersKy.heading!=='Колдонуучулар'||!usersNavContract.headerUsers||usersNavContract.dialogUsers||usersNavContract.dialogLab||!usersNavContract.headerLab) process.exit(1);
|
||||
const reflowWidth=640; await usersPage.setViewportSize({width:reflowWidth,height:450});
|
||||
await usersPage.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3'); await usersPage.locator('.search__result-title').first().waitFor();
|
||||
const search640=await usersPage.evaluate(()=>{const button=document.querySelector('#search-account-open'),r=button.getBoundingClientRect();return {documentWidth:document.documentElement.scrollWidth,accountVisible:r.width>0&&getComputedStyle(button).visibility!=='hidden',accountWithinViewport:r.left>=0&&r.right<=innerWidth};});
|
||||
await usersPage.locator('#search-account-open').click();
|
||||
const accountDialog640=await usersPage.evaluate(()=>{const dialog=document.querySelector('#account-dialog'),r=dialog.getBoundingClientRect(),actions=[...dialog.querySelectorAll('#review-nav,#search-users-open,#search-logout')].filter(el=>!el.hidden);return {open:dialog.open,withinViewport:r.left>=0&&r.right<=innerWidth,actionsVisible:actions.length>0&&actions.every(el=>{const a=el.getBoundingClientRect();return a.width>0&&a.left>=0&&a.right<=innerWidth;})};});
|
||||
await usersPage.keyboard.press('Escape');
|
||||
await usersPage.goto('https://fixture.invalid/review'); await usersPage.locator('#review').waitFor();
|
||||
const review640=await usersPage.evaluate(()=>{const account=document.querySelector('.review__nav-account'),logout=document.querySelector('#review-logout'),r=account.getBoundingClientRect(),a=logout.getBoundingClientRect();return {documentWidth:document.documentElement.scrollWidth,accountWithinViewport:r.left>=0&&r.right<=innerWidth,logoutVisible:a.width>0&&getComputedStyle(logout).visibility!=='hidden',logoutWithinViewport:a.left>=0&&a.right<=innerWidth};});
|
||||
await usersPage.goto('https://fixture.invalid/review'); await usersPage.waitForFunction(()=>document.querySelector('#profile-username').textContent==='admin'&&!document.body.classList.contains('review__locked'));
|
||||
const review640=await usersPage.evaluate(()=>{const account=document.querySelector('.review__nav-account'),logout=document.querySelector('#review-logout'),r=account.getBoundingClientRect(),a=logout.getBoundingClientRect();return {documentWidth:document.documentElement.scrollWidth,accountWithinViewport:r.left>=0&&r.right<=innerWidth,logoutVisible:a.width>0&&getComputedStyle(logout).visibility!=='hidden',logoutWithinViewport:a.left>=0&&a.right<=innerWidth};}); const reviewNavContract=await usersPage.evaluate(()=>({headerUsers:!document.querySelector('#review-users-open').hidden,dialogUsers:!document.querySelector('#search-users-open').hidden,dialogLab:!document.querySelector('#review-nav').hidden,headerLab:!document.querySelector('#review-review-nav').hidden})); if(!reviewNavContract.headerUsers||reviewNavContract.dialogUsers||reviewNavContract.dialogLab||!reviewNavContract.headerLab) throw new Error(`Review account actions duplicate header navigation: ${JSON.stringify(reviewNavContract)}`);
|
||||
await usersPage.goto('https://fixture.invalid/users'); await usersPage.locator('#user-list .review__user-row').waitFor();
|
||||
const users640=await usersPage.evaluate(()=>{const account=document.querySelector('.review__nav-account'),logout=document.querySelector('#review-logout'),create=document.querySelector('#create-user-open'),r=account.getBoundingClientRect(),a=logout.getBoundingClientRect(),c=create.getBoundingClientRect();return {documentWidth:document.documentElement.scrollWidth,accountWithinViewport:r.left>=0&&r.right<=innerWidth,logoutVisible:a.width>0&&getComputedStyle(logout).visibility!=='hidden',logoutWithinViewport:a.left>=0&&a.right<=innerWidth,createVisible:c.width>0&&getComputedStyle(create).visibility!=='hidden',createWithinViewport:c.left>=0&&c.right<=innerWidth};});
|
||||
const reflow640={search640,accountDialog640,review640,users640}; console.log('reflow640',JSON.stringify(reflow640));
|
||||
@@ -390,7 +391,7 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
|
||||
}
|
||||
}
|
||||
await usersPage.locator('#ui-ru').click();
|
||||
await usersPage.goto('https://fixture.invalid/review'); await usersPage.locator('#review').waitFor();
|
||||
await usersPage.goto('https://fixture.invalid/review'); await usersPage.waitForFunction(()=>document.querySelector('#profile-username').textContent==='fixture-user'&&!document.body.classList.contains('review__locked'));
|
||||
layoutRegressions.reviewNav=await usersPage.evaluate(()=>{
|
||||
const read=id=>{const el=document.getElementById(id),s=getComputedStyle(el),r=el.getBoundingClientRect();return {tag:el.tagName,href:el.getAttribute('href'),active:el.classList.contains('is-active'),height:r.height,display:s.display,alignItems:s.alignItems,color:s.color,background:s.backgroundColor,borderColor:s.borderTopColor};};
|
||||
return {search:read('review-search-nav'),lab:read('review-review-nav')};
|
||||
@@ -402,6 +403,10 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
|
||||
const read=id=>{const el=document.getElementById(id),s=getComputedStyle(el),r=el.getBoundingClientRect();return {tag:el.tagName,href:el.getAttribute('href'),active:el.classList.contains('is-active'),height:r.height,display:s.display,alignItems:s.alignItems,color:s.color,background:s.backgroundColor,borderColor:s.borderTopColor};};
|
||||
return {search:read('search-nav'),lab:read('search-review-nav')};
|
||||
});
|
||||
await usersPage.locator('#search-account-open').click();
|
||||
const publicAccountNav=await usersPage.evaluate(()=>({headerUsers:!document.querySelector('#review-users-open').hidden,dialogUsers:!document.querySelector('#search-users-open').hidden,dialogLab:!document.querySelector('#review-nav').hidden,headerLab:!document.querySelector('#search-review-nav').hidden}));
|
||||
await usersPage.keyboard.press('Escape');
|
||||
if(publicAccountNav.headerUsers||!publicAccountNav.dialogUsers||publicAccountNav.dialogLab||!publicAccountNav.headerLab) throw new Error(`Public account actions do not match visible header links: ${JSON.stringify(publicAccountNav)}`);
|
||||
await usersPage.setViewportSize({width:390,height:844});
|
||||
await usersPage.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3+%D0%BD%D0%B0+%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C');
|
||||
await usersPage.locator('.search__result-title').first().waitFor(); await usersPage.locator('.search__result-title').first().click(); await usersPage.locator('#public-document-heading').waitFor();
|
||||
@@ -424,6 +429,13 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
|
||||
const navFailures=navQuiet(layoutRegressions.reviewNav.search)||navPrimary(layoutRegressions.reviewNav.lab)||navQuiet(layoutRegressions.publicNav.lab)||navPrimary(layoutRegressions.publicNav.search)||layoutRegressions.reviewNav.focused.id!=='review-search-nav'||layoutRegressions.reviewNav.focused.outline!=='3px';
|
||||
const mobileFailures=Object.entries(layoutRegressions.mobile).filter(([,value])=>value.overlaps||value.back.tag!=='BUTTON'||value.back.name!=='К результатам'||value.back.height<44||value.textRects.some(rect=>rect.left<value.breadcrumb.left-1||rect.right>value.breadcrumb.right+1)).map(([viewport])=>viewport);
|
||||
if(brandFailures.length||navFailures||mobileFailures.length) throw new Error(JSON.stringify({brandFailures,navFailures,mobileFailures,layoutRegressions}));
|
||||
fixtureAccount=ordinaryAccount;
|
||||
await usersPage.goto('https://fixture.invalid/review'); await usersPage.locator('#review').waitFor();
|
||||
const ordinaryNav=await usersPage.evaluate(()=>({role:document.querySelector('#profile-role').textContent,headerUsers:!document.querySelector('#review-users-open').hidden,accountUsers:!document.querySelector('#search-users-open').hidden,adminPage:document.body.classList.contains('is-admin')}));
|
||||
await usersPage.goto('https://fixture.invalid/users'); await usersPage.waitForURL('https://fixture.invalid/'); await usersPage.waitForFunction(()=>document.querySelector('#profile-username').textContent==='fixture-user'&&!document.body.classList.contains('review__locked')); await usersPage.locator('#search-results-heading').waitFor();
|
||||
const ordinaryRoute=await usersPage.evaluate(()=>({path:location.pathname,usersHidden:document.querySelector('#users-page').hidden,adminPage:document.body.classList.contains('is-admin')}));
|
||||
console.log('ordinaryAccountCapability',JSON.stringify({ordinaryNav,ordinaryRoute}));
|
||||
if(ordinaryNav.role!=='Пользователь'||ordinaryNav.headerUsers||ordinaryNav.accountUsers||ordinaryNav.adminPage||ordinaryRoute.path!=='/'||!ordinaryRoute.usersHidden||ordinaryRoute.adminPage) throw new Error('Ordinary account must not receive user-management navigation or the users screen.');
|
||||
await browser.close();
|
||||
|
||||
})();
|
||||
@@ -22,8 +22,9 @@ const visualReviewArticles = {
|
||||
'review-b':'<h2>Расчёт условной налоговой базы</h2><p>Вымышленный текст для проверки панели чтения и элементов оценки.</p>'
|
||||
};
|
||||
const catalogs = {document_types:[{code:'code',labels:{ru:'Кодекс',ky:'Кодекс'},count:12},{code:'law',labels:{ru:'Закон',ky:'Мыйзам'},count:10},{code:'decree',labels:{ru:'Постановление',ky:'Токтом'},count:8}],statuses:[{code:'active',labels:{ru:'Действует',ky:'Күчүндө'},count:24},{code:'repealed',labels:{ru:'Утратил силу',ky:'Күчүн жоготту'},count:5}],authorities:[{code:'ministries_and_committees',labels:{ru:'Министерства и комитеты',ky:'Министрликтер'},count:5}],legal_forces:[]};
|
||||
const passwordChangeAccount = {username:'fixture-admin',role:'admin',created_at:1780000000,must_change_password:false};
|
||||
const passwordChangeAccount = {username:'admin',role:'Администратор',can_manage_users:true,created_at:1780000000,must_change_password:false};
|
||||
const ordinaryAccount = {username:'fixture-user',role:'Пользователь',can_manage_users:false,created_at:1780000000,must_change_password:false};
|
||||
const article = '<h2>Налог на прибыль</h2><p><a href="../../tmp/old.html">Old local reference</a> <a href="http://cbd.minjust.gov.kg/old">Legacy HTTP reference</a> <a href="https://cbd.minjust.gov.kg/doc-1" ping="https://evil.invalid/ping">Official reference</a> <a href="https://127.0.0.1/private">Private address reference</a><img src="../../tmp/old.png" srcset="https://evil.invalid/pixel 1x, https://192.168.1.10/pixel 2x"><img alt="Private address image" src="https://192.168.1.10/pixel"></p><p><b><span>Раздел I. Общие положения</span></b></p><p><b><span>Глава 1. Основные положения</span></b></p><p><b><span>Статья 1. Основные понятия</span></b></p><p>Обычное упоминание статьи 9 не является заголовком.</p><p id="duplicate-heading"><b><span>Статья 1. Повторный номер</span></b></p><p id="duplicate-heading"><b><span>Раздел II. Налоговая отчётность</span></b></p><p>Текст документа приведён для примера оформления. Здесь будут отображаться положения выбранной редакции. По запросу налог на прибыль читатель найдёт нужный фрагмент.</p><h2>Налоговая отчётность</h2><p>В этом разделе отображается текст выбранной редакции. Поиск внутри документа помогает перейти к нужному фрагменту.</p><p>Далее приводятся положения о налоге на прибыль.</p><p>Упоминание запроса налог на прибыль в следующем разделе.</p>';
|
||||
const articleKy = '<h2>Киреше салыгы</h2><p><b><span>БӨЛҮМ 1. Жалпы жоболор</span></b></p><p><b><span>Глава 1. Негизги жоболор</span></b></p><p><b><span>Берене 1. Негизги түшүнүктөр</span></b></p><p>Текстте берене 9 жөнүндө кадимки эскерүү бар.</p>';
|
||||
|
||||
module.exports = {docs, reviewDocs, visualDocs, visualReviewDocs, visualArticle, visualReviewArticles, catalogs, passwordChangeAccount, article, articleKy};
|
||||
module.exports = {docs, reviewDocs, visualDocs, visualReviewDocs, visualArticle, visualReviewArticles, catalogs, passwordChangeAccount, ordinaryAccount, article, articleKy};
|
||||
Reference in new issue
Block a user