mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-06 01:24:59 +03:00
🔒 feat(channels): enforce policy invariants P1-P5
SPEC-HUB-0027 §3.2/§12, этап 3. Инварианты включаются на изменении, а не только на создании: непродуктовый канал больше не может нести checkout, attribution или обязательную идентичность. Проверка идёт по итоговому состоянию, а не по переданным полям. Поэтому снять продукт и выключить коммерческие флаги можно одним запросом, а запрос, оставляющий канал в запрещённой комбинации, отклоняется целиком — имя из того же PATCH тоже не сохраняется. Данные приведены заранее, иначе первый же PATCH имени упёрся бы в ошибку политики. Отчёт нашёл один нарушитель — непродуктовый канал edevs с включёнными checkout и attribution. По решению владельца оба флага сняты, продукт не назначался. Та же миграция чинит дефолты модели, которые сами нарушали P1-P2: канал создаётся без продукта, а attribution и checkout были включены по умолчанию. Нарушения P3-P5 миграция не исправляет, а останавливает выкат с перечнем каналов: снятие обязательной идентичности меняет смысл канала. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
cedd7d44f7
commit
2b97f08b19
5 files changed
+254
-8
No files matched your search
@@ -0,0 +1,62 @@
|
||||
# SPEC-HUB-0027 §3.2/§12, ADR-HUB-0037 §7 — этап 3.
|
||||
#
|
||||
# Приводит существующие каналы в соответствие P1-P5 и меняет дефолты модели,
|
||||
# которые сами по себе нарушали P1-P2: канал создаётся без продукта, а
|
||||
# attribution и checkout были включены по умолчанию.
|
||||
#
|
||||
# Решение владельца по унаследованным непродуктовым каналам: выключить
|
||||
# checkout и attribution, а не назначать продукт. Поэтому миграция чинит ровно
|
||||
# P1 и P2. Нарушения P3-P5 она не трогает и останавливает выкат: их
|
||||
# исправление меняет смысл канала (обязательная идентичность), и выбирать за
|
||||
# владельца здесь нельзя.
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def relax_non_product_channels(apps, schema_editor):
|
||||
Channel = apps.get_model("channels", "Channel")
|
||||
|
||||
blocked = list(
|
||||
Channel.objects.filter(requires_authenticated_product_identity=True)
|
||||
.filter(
|
||||
models.Q(product__isnull=True)
|
||||
| models.Q(allow_anonymous_sessions=True)
|
||||
| models.Q(allow_self_reported_contact=True)
|
||||
)
|
||||
.values_list("id", "code")
|
||||
)
|
||||
if blocked:
|
||||
listed = ", ".join(f"{code} (id={channel_id})" for channel_id, code in blocked)
|
||||
raise RuntimeError(
|
||||
"Каналы нарушают P3-P5 и требуют решения владельца до включения "
|
||||
f"инвариантов: {listed}"
|
||||
)
|
||||
|
||||
Channel.objects.filter(product__isnull=True).filter(
|
||||
models.Q(allow_checkout_actions=True) | models.Q(allow_sales_attribution=True)
|
||||
).update(allow_checkout_actions=False, allow_sales_attribution=False)
|
||||
|
||||
|
||||
def noop(apps, schema_editor):
|
||||
# Прежние значения не восстанавливаются: они были нарушением инварианта.
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('channels', '0004_remove_channel_ai_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='channel',
|
||||
name='allow_checkout_actions',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='channel',
|
||||
name='allow_sales_attribution',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.RunPython(relax_non_product_channels, noop),
|
||||
]
|
||||
@@ -16,13 +16,15 @@ class Channel(models.Model):
|
||||
# LLM-провайдер канала (ADR-HUB-0020).
|
||||
provider_integration = models.ForeignKey("integrations.Integration", on_delete=models.PROTECT, related_name="channels", null=True, blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
# Политика канала (SPEC-HUB-0010 §4.2). Значения по умолчанию соответствуют
|
||||
# поведению публичных sales-каналов; support-каналы переключают флаги при seed.
|
||||
# Политика канала (SPEC-HUB-0010 §4.2, SPEC-HUB-0027 §3.2). Дефолты обязаны
|
||||
# удовлетворять инвариантам P1-P5 при product = null: канал создаётся без
|
||||
# продукта, поэтому коммерческие флаги по умолчанию выключены. Продуктовый
|
||||
# канал включает их явно — пресетом SALES или политикой в запросе.
|
||||
requires_authenticated_product_identity = models.BooleanField(default=False)
|
||||
allow_anonymous_sessions = models.BooleanField(default=True)
|
||||
allow_self_reported_contact = models.BooleanField(default=True)
|
||||
allow_sales_attribution = models.BooleanField(default=True)
|
||||
allow_checkout_actions = models.BooleanField(default=True)
|
||||
allow_sales_attribution = models.BooleanField(default=False)
|
||||
allow_checkout_actions = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
@@ -170,11 +170,11 @@ def update_channel(
|
||||
*, context: TenantContext, channel: Channel, update: ChannelUpdate
|
||||
) -> Channel:
|
||||
"""Порядок обработки §6.5: блокировка, права по каждому полю, целевое
|
||||
состояние, конфликты, одна транзакция.
|
||||
состояние, инварианты, конфликты, одна транзакция.
|
||||
|
||||
Инварианты P1-P5 на изменении включаются этапом 3: до устранения нарушений
|
||||
в существующих данных первый же PATCH имени упёрся бы в ошибку политики
|
||||
(SPEC §12). На создании они действуют уже сейчас — см. create_channel.
|
||||
Инварианты проверяются по итоговому состоянию, а не по переданным полям:
|
||||
выключить продукт и коммерческие флаги можно одним запросом, а вот запрос,
|
||||
оставляющий канал в запрещённой комбинации, отклоняется целиком.
|
||||
"""
|
||||
locked = Channel.objects.select_for_update().get(
|
||||
id=channel.id, organization_id=context.organization_id
|
||||
@@ -232,6 +232,13 @@ def update_channel(
|
||||
setattr(locked, name, value)
|
||||
changed.append(name)
|
||||
|
||||
# Инварианты по целевому состоянию — до записи: нарушение отклоняет запрос
|
||||
# целиком, частичного применения не остаётся даже в памяти (§3.2, §6.5).
|
||||
require_valid_policy(
|
||||
policy=ChannelPolicy.from_channel(locked),
|
||||
has_product=locked.product_id is not None,
|
||||
)
|
||||
|
||||
if changed:
|
||||
locked.save(update_fields=[*changed, "updated_at"])
|
||||
return locked
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""SPEC-HUB-0027 §12 — приведение данных перед включением инвариантов P1-P5.
|
||||
|
||||
Схема между `channels.0004` и `channels.0005` не меняется: `AlterField` правит
|
||||
только Python-дефолты. Весь риск миграции сосредоточен в функции приведения
|
||||
данных, поэтому она проверяется напрямую на реальном реестре моделей — это
|
||||
честнее, чем поднимать историческое состояние, где `identity` откатывается
|
||||
рассинхронно со схемой БД.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
|
||||
from django.apps import apps
|
||||
from django.test import TestCase
|
||||
|
||||
from hub_platform.channels.models import Channel
|
||||
from hub_platform.identity.models import Organization
|
||||
from hub_platform.products.models import Product
|
||||
|
||||
migration = importlib.import_module(
|
||||
"hub_platform.channels.migrations.0005_enforce_policy_invariants"
|
||||
)
|
||||
|
||||
|
||||
class PolicyInvariantMigrationTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.organization = Organization.objects.create(name="Edevs", slug="edevs")
|
||||
self.product = Product.objects.create(
|
||||
organization=self.organization, code="foxray", name="FoxRay"
|
||||
)
|
||||
|
||||
def _channel(self, code: str, **fields) -> Channel:
|
||||
return Channel.objects.create(
|
||||
organization=self.organization, code=code, name=code, **fields
|
||||
)
|
||||
|
||||
def _run(self) -> None:
|
||||
migration.relax_non_product_channels(apps, None)
|
||||
|
||||
def test_clears_commercial_flags_on_non_product_channels(self) -> None:
|
||||
# Ровно случай канала edevs: непродуктовый, но с checkout и attribution.
|
||||
violating = self._channel(
|
||||
"edevs", allow_checkout_actions=True, allow_sales_attribution=True
|
||||
)
|
||||
|
||||
self._run()
|
||||
|
||||
violating.refresh_from_db()
|
||||
self.assertFalse(violating.allow_checkout_actions)
|
||||
self.assertFalse(violating.allow_sales_attribution)
|
||||
# Остальные флаги непродуктового канала не трогаются.
|
||||
self.assertTrue(violating.allow_anonymous_sessions)
|
||||
self.assertTrue(violating.allow_self_reported_contact)
|
||||
|
||||
def test_leaves_product_channels_untouched(self) -> None:
|
||||
product_channel = self._channel(
|
||||
"foxray-sales",
|
||||
product=self.product,
|
||||
allow_checkout_actions=True,
|
||||
allow_sales_attribution=True,
|
||||
)
|
||||
|
||||
self._run()
|
||||
|
||||
product_channel.refresh_from_db()
|
||||
self.assertTrue(product_channel.allow_checkout_actions)
|
||||
self.assertTrue(product_channel.allow_sales_attribution)
|
||||
|
||||
def test_stops_the_rollout_on_p3_p5_violations(self) -> None:
|
||||
# Обязательная идентичность без продукта — смена смысла канала,
|
||||
# выбирать за владельца нельзя, поэтому выкат останавливается.
|
||||
self._channel("broken", requires_authenticated_product_identity=True)
|
||||
untouched = self._channel(
|
||||
"edevs", allow_checkout_actions=True, allow_sales_attribution=True
|
||||
)
|
||||
|
||||
with self.assertRaises(RuntimeError) as error:
|
||||
self._run()
|
||||
|
||||
self.assertIn("broken", str(error.exception))
|
||||
untouched.refresh_from_db()
|
||||
self.assertTrue(untouched.allow_checkout_actions)
|
||||
|
||||
def test_is_idempotent(self) -> None:
|
||||
self._channel("edevs", allow_checkout_actions=True)
|
||||
|
||||
self._run()
|
||||
self._run()
|
||||
|
||||
self.assertEqual(
|
||||
Channel.objects.filter(
|
||||
product__isnull=True, allow_checkout_actions=True
|
||||
).count(),
|
||||
0,
|
||||
)
|
||||
@@ -577,3 +577,84 @@ class ChannelCountersTests(ChannelApiTestCase):
|
||||
channel = _make_channel(self.organization, code="main", name="Основной")
|
||||
response = self.client.get(f"/api/v1/channels/{channel.id}/counters/?period=1y")
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
|
||||
class ChannelPolicyInvariantTests(ChannelApiTestCase):
|
||||
"""§3.2 — P1-P5 на изменении (этап 3)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.orphan = _make_channel(
|
||||
self.organization, code="partners", name="Партнёрская линия"
|
||||
)
|
||||
self.product_channel = _make_channel(
|
||||
self.organization,
|
||||
code="foxray-sales",
|
||||
name="FoxRay — продажи",
|
||||
product=self.product,
|
||||
allow_sales_attribution=True,
|
||||
allow_checkout_actions=True,
|
||||
)
|
||||
|
||||
def test_rejects_commercial_flag_on_non_product_channel(self) -> None:
|
||||
response = self.patch_channel(
|
||||
self.orphan.id, policy={"allowCheckoutActions": True}
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(
|
||||
{item["rule"] for item in response.json()["violations"]}, {"P1"}
|
||||
)
|
||||
self.orphan.refresh_from_db()
|
||||
self.assertFalse(self.orphan.allow_checkout_actions)
|
||||
|
||||
def test_rename_of_compliant_channel_is_not_blocked_by_policy(self) -> None:
|
||||
# Ровно тот сценарий, ради которого этап 3 шёл после приведения данных.
|
||||
response = self.patch_channel(self.orphan.id, name="Партнёры")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_detaching_product_names_the_flags_to_turn_off(self) -> None:
|
||||
response = self.patch_channel(self.product_channel.id, productId=None)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
fields = {item["field"] for item in response.json()["violations"]}
|
||||
self.assertEqual(fields, {"allowCheckoutActions", "allowSalesAttribution"})
|
||||
self.product_channel.refresh_from_db()
|
||||
self.assertEqual(self.product_channel.product_id, self.product.id)
|
||||
|
||||
def test_detaching_product_with_flags_off_succeeds_in_one_request(self) -> None:
|
||||
# Инварианты считаются по итоговому состоянию, а не по переданным полям.
|
||||
response = self.patch_channel(
|
||||
self.product_channel.id,
|
||||
productId=None,
|
||||
policy={"allowCheckoutActions": False, "allowSalesAttribution": False},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.product_channel.refresh_from_db()
|
||||
self.assertIsNone(self.product_channel.product_id)
|
||||
self.assertFalse(self.product_channel.allow_checkout_actions)
|
||||
|
||||
def test_partial_application_is_impossible(self) -> None:
|
||||
response = self.patch_channel(
|
||||
self.orphan.id,
|
||||
name="Новое имя",
|
||||
policy={"allowSalesAttribution": True},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.orphan.refresh_from_db()
|
||||
# Имя не сохранилось вместе с отклонённым флагом.
|
||||
self.assertEqual(self.orphan.name, "Партнёрская линия")
|
||||
self.assertFalse(self.orphan.allow_sales_attribution)
|
||||
|
||||
def test_authenticated_identity_requires_disabling_anonymous_flags(self) -> None:
|
||||
response = self.patch_channel(
|
||||
self.product_channel.id,
|
||||
policy={"requiresAuthenticatedProductIdentity": True},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(
|
||||
{item["rule"] for item in response.json()["violations"]}, {"P4", "P5"}
|
||||
)
|
||||
Reference in new issue
Block a user