fix: seed выдаёт права новых ролей и прав в заполненную матрицу (ревью R-3, R-9, #9)
При непустой `role_permission` вставляются пары, у которых роль или право вставлены этим же прогоном (`_upsert` возвращает вставленные ключи): их администратор снять не мог. Прочие пары по-прежнему не трогаются — снятые права не возвращаются. Убрана мёртвая ветка `unchanged` (R-9): при пустой таблице совпадений быть не может, при заполненной сверка не нужна. Тесты: новое право и новая роль во временном seed-каталоге → повторный seed выдаёт только их пары, снятая связь не возвращается; администратор снял все связи → матрица заливается заново. README, CLAUDE.md, комментарий `role_permissions.yaml` и докстринг `seed.py` уточнены. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
f47cbeedda
commit
2c83005564
@ -76,8 +76,8 @@
|
|||||||
dev-эмитент (`POST /api/v1/dev/token`) существует только при `AUTH_DEV_ISSUER=true` **и** `DEBUG=true`.
|
dev-эмитент (`POST /api/v1/dev/token`) существует только при `AUTH_DEV_ISSUER=true` **и** `DEBUG=true`.
|
||||||
Аудит входа — `services/session_audit.py` (`app.state.session_audit`): `LOGIN` (dev-токен; первый запрос после старта),
|
Аудит входа — `services/session_audit.py` (`app.state.session_audit`): `LOGIN` (dev-токен; первый запрос после старта),
|
||||||
`LOGIN_FAILED` (`AuthenticationFailedError` 401/403 → обработчик пишет в отдельной транзакции `session_transaction`).
|
`LOGIN_FAILED` (`AuthenticationFailedError` 401/403 → обработчик пишет в отдельной транзакции `session_transaction`).
|
||||||
- **Seed** — `python -m scripts.seed [--dev]` (`make seed|seed-dev`), идемпотентен (матрица `role_permission` — только в
|
- **Seed** — `python -m scripts.seed [--dev]` (`make seed|seed-dev`), идемпотентен (матрица `role_permission` — целиком только в
|
||||||
пустую таблицу, иначе шаг пропускается); `--dev` — только для разработки (CLI отказывает с кодом 2 при `DEBUG=false`).
|
пустую таблицу, в заполненную — лишь пары впервые созданных ролей/прав); `--dev` — только для разработки (CLI отказывает с кодом 2 при `DEBUG=false`).
|
||||||
**Сверка ORM↔DBML** — `scripts/check_dbml_vs_orm.py` (`make dbml-check`).
|
**Сверка ORM↔DBML** — `scripts/check_dbml_vs_orm.py` (`make dbml-check`).
|
||||||
|
|
||||||
## Зависимости от соседей
|
## Зависимости от соседей
|
||||||
|
|||||||
17
README.md
17
README.md
@ -88,8 +88,8 @@ make seed-dev # справочники продукта + dev-пользов
|
|||||||
|
|
||||||
`make seed` загружает только справочники (роли, права, филиалы, план счетов, правила элиминации, чек-лист), без
|
`make seed` загружает только справочники (роли, права, филиалы, план счетов, правила элиминации, чек-лист), без
|
||||||
dev-пользователей. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует; матрицу прав
|
dev-пользователей. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует; матрицу прав
|
||||||
`role_permission` он загружает только в пустую таблицу (правки администратора не перезаписываются, см.
|
`role_permission` он загружает целиком только в пустую таблицу, в заполненную — лишь связи новых ролей и прав
|
||||||
«Администрирование»). `make seed-dev`
|
(правки администратора не перезаписываются, см. «Администрирование»). `make seed-dev`
|
||||||
(`--dev`) работает только при `DEBUG=true` в `api/.env`; при `DEBUG=false` загрузчик отказывает с кодом 2
|
(`--dev`) работает только при `DEBUG=true` в `api/.env`; при `DEBUG=false` загрузчик отказывает с кодом 2
|
||||||
(«`--dev` доступен только при DEBUG=true (dev-пользователи не для прода)»), не трогая БД.
|
(«`--dev` доступен только при DEBUG=true (dev-пользователи не для прода)»), не трогая БД.
|
||||||
|
|
||||||
@ -365,11 +365,14 @@ cd web && npm install && npm run start # фронтенд (rsbuild dev), о
|
|||||||
API и UI **отклоняется** (422); dev-пользователи `*@armka.local` создаёт `make seed-dev` напрямую в БД. Для ручной
|
API и UI **отклоняется** (422); dev-пользователи `*@armka.local` создаёт `make seed-dev` напрямую в БД. Для ручной
|
||||||
проверки заводите пользователей с `example.com`. Email уникален без учёта регистра (409 `EMAIL_EXISTS`), хранится как введён.
|
проверки заводите пользователей с `example.com`. Email уникален без учёта регистра (409 `EMAIL_EXISTS`), хранится как введён.
|
||||||
- **Seed не трогает заполненную матрицу прав.** Связи «роль × право» из `api/seed/core/role_permissions.yaml`
|
- **Seed не трогает заполненную матрицу прав.** Связи «роль × право» из `api/seed/core/role_permissions.yaml`
|
||||||
`make seed` / `make seed-dev` загружают только в **пустую** `role_permission` (первый запуск на новой БД). Если
|
`make seed` / `make seed-dev` загружают целиком только в **пустую** `role_permission` (первый запуск на новой БД).
|
||||||
связи уже есть, шаг пропускается целиком — в отчёте `role_permission: пропущено — матрица прав уже заполнена…`:
|
Если связи уже есть, seed добавляет лишь пары, у которых роль **или** право впервые созданы этим же запуском
|
||||||
право, снятое администратором, не возвращается, добавленное сверх файла не отзывается. Роли и права (`role`,
|
(новая роль или новое право в файлах seed — их администратор снять не мог); остальные пары не трогаются — в отчёте
|
||||||
`permission`) по-прежнему приводятся к файлам seed. Изменения `role_permissions.yaml` на действующем стенде
|
`role_permission: пропущено — матрица прав уже заполнена…` (или число добавленных пар новых ролей и прав). Право,
|
||||||
(например, права новой роли) seed не применит — их выдаёт администратор (`PUT /admin/roles/{code}/permissions`).
|
снятое администратором с существующей роли, не возвращается, добавленное сверх файла не отзывается; новые пары
|
||||||
|
существующих ролей и прав из файла выдаёт администратор (`PUT /admin/roles/{code}/permissions`). Роли и права
|
||||||
|
(`role`, `permission`) по-прежнему приводятся к файлам seed. Пограничный случай: если администратор снял **все**
|
||||||
|
связи, таблица пуста, и следующий seed зальёт матрицу файла заново.
|
||||||
- **Профиль `/me` не перечитывается** после того, как администратор правит собственные роли или права: перезагрузите
|
- **Профиль `/me` не перечитывается** после того, как администратор правит собственные роли или права: перезагрузите
|
||||||
страницу или войдите заново.
|
страницу или войдите заново.
|
||||||
- **Имя и филиал ГК одним PATCH** пишут одно событие аудита `ASSIGNMENT_CHANGE` с обоими полями в `before`/`after`
|
- **Имя и филиал ГК одним PATCH** пишут одно событие аудита `ASSIGNMENT_CHANGE` с обоими полями в `before`/`after`
|
||||||
|
|||||||
@ -7,8 +7,9 @@ Spec reference-data-seed; design D-4 и решение #18 (R-5): `run_seed(sess
|
|||||||
1. `load_seed` (`seed_lib/loaders.py`) читает ВСЕ файлы в память и проверяет их (обязательные поля, enum'ы из
|
1. `load_seed` (`seed_lib/loaders.py`) читает ВСЕ файлы в память и проверяет их (обязательные поля, enum'ы из
|
||||||
`src.models.enums`, ссылки внутри наборов); любая ошибка — `SeedError`, база при этом не затрагивается.
|
`src.models.enums`, ссылки внутри наборов); любая ошибка — `SeedError`, база при этом не затрагивается.
|
||||||
2. `apply_seed` (`seed_lib/apply.py`) применяет набор к БД шагами в порядке зависимостей: upsert по натуральному
|
2. `apply_seed` (`seed_lib/apply.py`) применяет набор к БД шагами в порядке зависимостей: upsert по натуральному
|
||||||
ключу, без удалений. Исключение — матрица `role_permission`: загружается только в пустую таблицу, иначе шаг
|
ключу, без удалений. Исключение — матрица `role_permission`: целиком загружается только в пустую таблицу; в
|
||||||
пропускается (в отчёте — «пропущено»), чтобы не вернуть права, снятые администратором (issue #9 п.7).
|
заполненную — лишь пары ролей и прав, вставленных этим же запуском, остальное пропускается (в отчёте —
|
||||||
|
«пропущено»), чтобы не вернуть права, снятые администратором (issue #9 п.7, ревью R-3).
|
||||||
|
|
||||||
План счетов (`core/chart_of_accounts.csv`, design D-9) — ДОПУЩЕНИЕ OQ-47: полного плана 1С клиента нет, в файле
|
План счетов (`core/chart_of_accounts.csv`, design D-9) — ДОПУЩЕНИЕ OQ-47: полного плана 1С клиента нет, в файле
|
||||||
Приказ Минфина № 94н: 62 активных синтетических счёта (01–05, 07–11, 14–16, 19–21, 23, 25, 26, 28, 29, 40–46,
|
Приказ Минфина № 94н: 62 активных синтетических счёта (01–05, 07–11, 14–16, 19–21, 23, 25, 26, 28, 29, 40–46,
|
||||||
|
|||||||
@ -20,10 +20,13 @@ async def _upsert(
|
|||||||
key: str,
|
key: str,
|
||||||
rows: list[dict[str, Any]],
|
rows: list[dict[str, Any]],
|
||||||
stat: TableStat,
|
stat: TableStat,
|
||||||
) -> None:
|
) -> set[Any]:
|
||||||
"""Вставить новые и обновить изменившиеся строки по натуральному ключу `key`; удалений нет."""
|
"""Вставить новые и обновить изменившиеся строки по натуральному ключу `key`; удалений нет.
|
||||||
|
|
||||||
|
Возвращает ключи строк, ВСТАВЛЕННЫХ этим прогоном (для матрицы прав: связи новых ролей и прав).
|
||||||
|
"""
|
||||||
if not rows:
|
if not rows:
|
||||||
return
|
return set()
|
||||||
key_column = getattr(model, key)
|
key_column = getattr(model, key)
|
||||||
value_names = [name for name in rows[0] if name != key]
|
value_names = [name for name in rows[0] if name != key]
|
||||||
existing: dict[Any, tuple[Any, ...]] = {
|
existing: dict[Any, tuple[Any, ...]] = {
|
||||||
@ -31,10 +34,12 @@ async def _upsert(
|
|||||||
for row in (await session.execute(select(key_column, *(getattr(model, name) for name in value_names)))).all()
|
for row in (await session.execute(select(key_column, *(getattr(model, name) for name in value_names)))).all()
|
||||||
}
|
}
|
||||||
changed = []
|
changed = []
|
||||||
|
inserted: set[Any] = set()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
current = existing.get(row[key])
|
current = existing.get(row[key])
|
||||||
if current is None:
|
if current is None:
|
||||||
stat.inserted += 1
|
stat.inserted += 1
|
||||||
|
inserted.add(row[key])
|
||||||
changed.append(row)
|
changed.append(row)
|
||||||
elif current != tuple(row[name] for name in value_names):
|
elif current != tuple(row[name] for name in value_names):
|
||||||
stat.updated += 1
|
stat.updated += 1
|
||||||
@ -47,38 +52,48 @@ async def _upsert(
|
|||||||
index_elements=[key_column], set_={name: statement.excluded[name] for name in value_names}
|
index_elements=[key_column], set_={name: statement.excluded[name] for name in value_names}
|
||||||
)
|
)
|
||||||
await session.execute(statement, changed)
|
await session.execute(statement, changed)
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
MATRIX_SKIPPED = (
|
MATRIX_SKIPPED = (
|
||||||
"матрица прав уже заполнена; seed её не меняет, чтобы не вернуть права, снятые администратором "
|
"матрица прав уже заполнена; seed не возвращает права, снятые администратором, и добавляет только связи ролей "
|
||||||
"(правка — PUT /admin/roles/{code}/permissions)"
|
"и прав, впервые созданных этим запуском (правка — PUT /admin/roles/{code}/permissions)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _apply_role_permissions(session: AsyncSession, data: SeedData, stat: TableStat) -> None:
|
async def _apply_role_permissions( # noqa: PLR0913 - набор, статистика и коды впервые вставленных ролей и прав
|
||||||
"""Матрица «роль × право» из файла — только в пустую `role_permission` (первичная загрузка, issue #9 п.7).
|
session: AsyncSession,
|
||||||
|
data: SeedData,
|
||||||
|
stat: TableStat,
|
||||||
|
*,
|
||||||
|
new_roles: set[str],
|
||||||
|
new_permissions: set[str],
|
||||||
|
) -> None:
|
||||||
|
"""Матрица «роль × право» из файла (issue #9 п.7, ревью R-3).
|
||||||
|
|
||||||
Если связи уже есть, матрицей владеет администратор: шаг пропускается целиком (`stat.skipped_reason`), снятые
|
Пустая `role_permission` (первичная загрузка, в том числе после того, как администратор снял все связи) —
|
||||||
права не возвращаются, добавленные не отзываются. Роли и права (`role`, `permission`) по-прежнему upsert'ятся.
|
вставляется вся матрица файла. Непустая — матрицей владеет администратор: вставляются только пары, у которых
|
||||||
|
роль ИЛИ право вставлены этим же прогоном (`new_roles`/`new_permissions` — их администратор снять не мог);
|
||||||
|
остальные пары не трогаются (`stat.skipped_reason`): снятые права не возвращаются, добавленные не отзываются.
|
||||||
"""
|
"""
|
||||||
if await session.scalar(select(RolePermission.role_id).limit(1)) is not None:
|
matrix_filled = await session.scalar(select(RolePermission.role_id).limit(1)) is not None
|
||||||
|
if matrix_filled:
|
||||||
stat.skipped_reason = MATRIX_SKIPPED
|
stat.skipped_reason = MATRIX_SKIPPED
|
||||||
return
|
|
||||||
role_ids = {code: id_ for id_, code in (await session.execute(select(Role.id, Role.code))).all()}
|
role_ids = {code: id_ for id_, code in (await session.execute(select(Role.id, Role.code))).all()}
|
||||||
permission_ids = {code: id_ for id_, code in (await session.execute(select(Permission.id, Permission.code))).all()}
|
permission_ids = {code: id_ for id_, code in (await session.execute(select(Permission.id, Permission.code))).all()}
|
||||||
existing = set((await session.execute(select(RolePermission.role_id, RolePermission.permission_id))).all())
|
new_rows = [
|
||||||
new_rows = []
|
{
|
||||||
for role_code, codes in data.role_permissions.items():
|
"role_id": role_ids[role_code],
|
||||||
for code in codes:
|
"permission_id": permission_ids[code],
|
||||||
pair = (role_ids[role_code], permission_ids[code])
|
"granted_by": None,
|
||||||
if pair in existing:
|
"granted_at": func.now(),
|
||||||
stat.unchanged += 1
|
}
|
||||||
else:
|
for role_code, codes in data.role_permissions.items()
|
||||||
stat.inserted += 1
|
for code in codes
|
||||||
new_rows.append(
|
if not matrix_filled or role_code in new_roles or code in new_permissions
|
||||||
{"role_id": pair[0], "permission_id": pair[1], "granted_by": None, "granted_at": func.now()}
|
]
|
||||||
)
|
|
||||||
if new_rows:
|
if new_rows:
|
||||||
|
stat.inserted += len(new_rows)
|
||||||
# .values(): func.now() допустим только во встроенном VALUES, не в executemany
|
# .values(): func.now() допустим только во встроенном VALUES, не в executemany
|
||||||
statement = (
|
statement = (
|
||||||
pg_insert(RolePermission)
|
pg_insert(RolePermission)
|
||||||
@ -226,7 +241,7 @@ async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport:
|
|||||||
[{"name": o.name, "is_head_office": o.is_head_office, "is_active": o.is_active} for o in data.regional_offices],
|
[{"name": o.name, "is_head_office": o.is_head_office, "is_active": o.is_active} for o in data.regional_offices],
|
||||||
report.stat("regional_office"),
|
report.stat("regional_office"),
|
||||||
)
|
)
|
||||||
await _upsert(
|
new_roles = await _upsert(
|
||||||
session,
|
session,
|
||||||
Role,
|
Role,
|
||||||
"code",
|
"code",
|
||||||
@ -242,7 +257,7 @@ async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport:
|
|||||||
],
|
],
|
||||||
report.stat("role"),
|
report.stat("role"),
|
||||||
)
|
)
|
||||||
await _upsert(
|
new_permissions = await _upsert(
|
||||||
session,
|
session,
|
||||||
Permission,
|
Permission,
|
||||||
"code",
|
"code",
|
||||||
@ -258,7 +273,9 @@ async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport:
|
|||||||
],
|
],
|
||||||
report.stat("permission"),
|
report.stat("permission"),
|
||||||
)
|
)
|
||||||
await _apply_role_permissions(session, data, report.stat("role_permission"))
|
await _apply_role_permissions(
|
||||||
|
session, data, report.stat("role_permission"), new_roles=new_roles, new_permissions=new_permissions
|
||||||
|
)
|
||||||
await _apply_chart_of_accounts(session, data, report.stat("chart_of_account"))
|
await _apply_chart_of_accounts(session, data, report.stat("chart_of_account"))
|
||||||
await apply_elimination_rules(
|
await apply_elimination_rules(
|
||||||
session, data, report.stat("elimination_rule"), report.stat("elimination_rule_account")
|
session, data, report.stat("elimination_rule"), report.stat("elimination_rule_account")
|
||||||
|
|||||||
@ -133,7 +133,7 @@ class TableStat:
|
|||||||
inserted: int = 0
|
inserted: int = 0
|
||||||
updated: int = 0
|
updated: int = 0
|
||||||
unchanged: int = 0
|
unchanged: int = 0
|
||||||
#: шаг не выполнялся (например, матрица прав уже заполнена); текст — для отчёта CLI
|
#: шаг выполнен не полностью или пропущен (матрица прав уже заполнена); текст — для отчёта CLI
|
||||||
skipped_reason: str | None = None
|
skipped_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@ -148,10 +148,12 @@ class SeedReport:
|
|||||||
|
|
||||||
def format(self) -> str:
|
def format(self) -> str:
|
||||||
lines = ["Seed применён (вставлено / обновлено / без изменений):"]
|
lines = ["Seed применён (вставлено / обновлено / без изменений):"]
|
||||||
lines += [
|
for name, stat in self.tables.items():
|
||||||
f" {name}: пропущено — {stat.skipped_reason}"
|
counts = f"{stat.inserted} / {stat.updated} / {stat.unchanged}"
|
||||||
if stat.skipped_reason is not None
|
if stat.skipped_reason is None:
|
||||||
else f" {name}: {stat.inserted} / {stat.updated} / {stat.unchanged}"
|
lines.append(f" {name}: {counts}")
|
||||||
for name, stat in self.tables.items()
|
elif stat.inserted:
|
||||||
]
|
lines.append(f" {name}: {counts} (только связи новых ролей и прав; {stat.skipped_reason})")
|
||||||
|
else:
|
||||||
|
lines.append(f" {name}: пропущено — {stat.skipped_reason}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|||||||
@ -2,8 +2,9 @@
|
|||||||
# ASSUMPTION(OQ-16): в ТЗ матрица помечена «будет разработана» (сноска 29) — это ЧЕРНОВИК; администратор правит её в UI.
|
# ASSUMPTION(OQ-16): в ТЗ матрица помечена «будет разработана» (сноска 29) — это ЧЕРНОВИК; администратор правит её в UI.
|
||||||
# Пояснения в скобках в таблице §10 («свой РФ», «состав ГСК», «INITIATED…CONSOLIDATION_RF» и т. п.) — условия
|
# Пояснения в скобках в таблице §10 («свой РФ», «состав ГСК», «INITIATED…CONSOLIDATION_RF» и т. п.) — условия
|
||||||
# policy-слоя, а не отдельные права, поэтому здесь не представлены.
|
# policy-слоя, а не отдельные права, поэтому здесь не представлены.
|
||||||
# Ключ — role.code из roles.yaml, значения — permission.code из permissions.yaml. Upsert по (роль, право);
|
# Ключ — role.code из roles.yaml, значения — permission.code из permissions.yaml; granted_by = NULL (выдано seed'ом).
|
||||||
# granted_by = NULL (выдано seed'ом). Права, которых в файле нет, seed не отзывает.
|
# Матрица загружается целиком только в ПУСТУЮ role_permission; в заполненную — лишь пары ролей/прав, впервые созданных
|
||||||
|
# этим запуском seed (снятые администратором права не возвращаются). Права, которых в файле нет, seed не отзывает.
|
||||||
ADMIN:
|
ADMIN:
|
||||||
- DIRECTORY_VIEW
|
- DIRECTORY_VIEW
|
||||||
- DIRECTORY_EDIT
|
- DIRECTORY_EDIT
|
||||||
|
|||||||
@ -10,7 +10,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import yaml
|
import yaml
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import delete, func, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from scripts.seed import SeedError, SeedReport, load_seed, run_seed
|
from scripts.seed import SeedError, SeedReport, load_seed, run_seed
|
||||||
@ -370,3 +370,59 @@ def test_cli_help() -> None:
|
|||||||
|
|
||||||
assert result.returncode == 0
|
assert result.returncode == 0
|
||||||
assert "--dev" in result.stdout
|
assert "--dev" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_reseed_grants_only_pairs_of_new_roles_and_permissions(db_session: AsyncSession, tmp_path: Path) -> None:
|
||||||
|
"""Матрица непуста: добавляются только связи ролей/прав, вставленных этим прогоном (их снять не могли, ревью R-3);
|
||||||
|
снятая администратором связь существующих роли и права не возвращается."""
|
||||||
|
seed_dir = _copy_seed(tmp_path)
|
||||||
|
await run_seed(db_session, seed_dir=seed_dir)
|
||||||
|
expert_view = (
|
||||||
|
select(RolePermission)
|
||||||
|
.join(Role, Role.id == RolePermission.role_id)
|
||||||
|
.join(Permission, Permission.id == RolePermission.permission_id)
|
||||||
|
.where(Role.code == "EXPERT", Permission.code == "EX_VIEW")
|
||||||
|
)
|
||||||
|
await db_session.delete((await db_session.execute(expert_view)).scalar_one())
|
||||||
|
await db_session.flush()
|
||||||
|
core = seed_dir / "core"
|
||||||
|
with (core / "permissions.yaml").open("a", encoding="utf-8") as file:
|
||||||
|
file.write('- code: AUDIT_EXPORT\n object: AUDIT\n action: EXPORT\n name: "Аудит: выгрузка"\n')
|
||||||
|
with (core / "roles.yaml").open("a", encoding="utf-8") as file:
|
||||||
|
file.write("- code: AUDITOR\n name: Аудитор\n is_system: true\n is_active: true\n")
|
||||||
|
matrix_path = core / "role_permissions.yaml"
|
||||||
|
matrix = yaml.safe_load(matrix_path.read_text(encoding="utf-8"))
|
||||||
|
matrix["ADMIN"].append("AUDIT_EXPORT") # существующая роль × новое право
|
||||||
|
matrix["AUDITOR"] = ["AUDIT_VIEW"] # новая роль × существующее право
|
||||||
|
matrix_path.write_text(yaml.safe_dump(matrix, allow_unicode=True), encoding="utf-8")
|
||||||
|
|
||||||
|
report = await run_seed(db_session, seed_dir=seed_dir)
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(Role.code, Permission.code)
|
||||||
|
.join(RolePermission, RolePermission.role_id == Role.id)
|
||||||
|
.join(Permission, Permission.id == RolePermission.permission_id)
|
||||||
|
.where(Role.code.in_(["ADMIN", "AUDITOR"]), Permission.code.in_(["AUDIT_EXPORT", "AUDIT_VIEW"]))
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
assert set(rows) == {("ADMIN", "AUDIT_EXPORT"), ("ADMIN", "AUDIT_VIEW"), ("AUDITOR", "AUDIT_VIEW")}
|
||||||
|
assert (await db_session.execute(expert_view)).scalar_one_or_none() is None
|
||||||
|
assert await _count(db_session, RolePermission) == GRANT_COUNT - 1 + 2
|
||||||
|
stat = report.tables["role_permission"]
|
||||||
|
assert (stat.inserted, stat.updated, stat.unchanged) == (2, 0, 0)
|
||||||
|
assert stat.skipped_reason is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_reseed_refills_matrix_when_admin_removed_all_links(db_session: AsyncSession) -> None:
|
||||||
|
"""Пограничный случай: пустая `role_permission` — первичная загрузка, матрица заливается заново."""
|
||||||
|
await run_seed(db_session)
|
||||||
|
await db_session.execute(delete(RolePermission))
|
||||||
|
|
||||||
|
report = await run_seed(db_session)
|
||||||
|
|
||||||
|
assert await _count(db_session, RolePermission) == GRANT_COUNT
|
||||||
|
assert report.tables["role_permission"].inserted == GRANT_COUNT
|
||||||
|
assert report.tables["role_permission"].skipped_reason is None
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user