"""ITs for admin-Post (kind=NOTE_UNLOCK) write protection. Three guards stack: 1. post.html input is `readonly` w. "No response needed…" placeholder (FT covers this — `functional_tests/test_admin_post_readonly.py`). 2. view_post POST handler hard-rejects writes (HTTP 403). This file's PostRejectsAdminWritesTest. 3. post_save signal nukes any Line saved on a NOTE_UNLOCK Post that lacks `admin_solicited=True` — defense-in-depth for paths that bypass the view (raw API, ORM, etc.). UnsolicitedLineListenerTest. Bug A — May 2026. """ from django.test import TestCase from django.urls import reverse from apps.billboard.models import Brief, Line, Post from apps.drama.models import Note from apps.lyric.models import User, get_or_create_adman class PostRejectsAdminWritesTest(TestCase): """POST /billboard/post// → HTTP 403, no Line appended.""" def setUp(self): self.user = User.objects.create(email="admin-rej@test.io") self.client.force_login(self.user) Note.grant_if_new(self.user, "stargazer") self.admin_post = Post.objects.get( owner=self.user, kind=Post.KIND_NOTE_UNLOCK, ) self.line_count_before = Line.objects.filter(post=self.admin_post).count() def test_post_to_admin_post_returns_403(self): resp = self.client.post( reverse("billboard:view_post", args=[self.admin_post.id]), data={"text": "errant response"}, ) self.assertEqual(resp.status_code, 403) def test_post_to_admin_post_does_not_append_line(self): self.client.post( reverse("billboard:view_post", args=[self.admin_post.id]), data={"text": "errant response"}, ) self.assertEqual( Line.objects.filter(post=self.admin_post).count(), self.line_count_before, ) def test_post_to_user_post_still_succeeds(self): """Regression: kind=USER_POST still accepts compose.""" user_post = Post.objects.create( owner=self.user, kind=Post.KIND_USER_POST, title="composing", ) Line.objects.create(post=user_post, text="seed", author=self.user) resp = self.client.post( reverse("billboard:view_post", args=[user_post.id]), data={"text": "valid append"}, ) # 302 redirect on success self.assertEqual(resp.status_code, 302) self.assertTrue( Line.objects.filter(post=user_post, text="valid append").exists(), ) class UnsolicitedLineListenerTest(TestCase): """post_save signal deletes any Line saved on a NOTE_UNLOCK Post without `admin_solicited=True`. Note.grant_if_new sets it; everything else defaults to False, so a stray ORM-level write gets nuked.""" def setUp(self): self.user = User.objects.create(email="listener@test.io") Note.grant_if_new(self.user, "stargazer") self.admin_post = Post.objects.get( owner=self.user, kind=Post.KIND_NOTE_UNLOCK, ) def test_unsolicited_line_on_note_unlock_post_is_deleted(self): unsolicited = Line.objects.create( post=self.admin_post, text="errant ORM write", author=self.user, # admin_solicited defaults to False ) # Signal fires post_save; the Line should be gone. self.assertFalse( Line.objects.filter(pk=unsolicited.pk).exists(), "Unsolicited Line on NOTE_UNLOCK Post must be deleted", ) def test_admin_solicited_line_on_note_unlock_post_persists(self): """The Note grant Lines are admin_solicited=True — must NOT be nuked.""" adman = get_or_create_adman() line = Line.objects.create( post=self.admin_post, text="valid system prose", author=adman, admin_solicited=True, ) self.assertTrue(Line.objects.filter(pk=line.pk).exists()) def test_unsolicited_line_on_user_post_persists(self): """User-typed Lines on user_post posts default to admin_solicited=False and must NOT be nuked — the listener only guards NOTE_UNLOCK.""" up = Post.objects.create( owner=self.user, kind=Post.KIND_USER_POST, title="x", ) line = Line.objects.create( post=up, text="user-typed line", author=self.user, ) self.assertTrue(Line.objects.filter(pk=line.pk).exists()) class NoteGrantSetsAdminSolicitedTest(TestCase): """Note.grant_if_new must persist Lines with admin_solicited=True so they survive the listener pass.""" def test_grant_creates_line_with_admin_solicited_true(self): u = User.objects.create(email="grant@test.io") Note.grant_if_new(u, "stargazer") post = Post.objects.get(owner=u, kind=Post.KIND_NOTE_UNLOCK) # Exactly one Line on a fresh grant line = post.lines.get() self.assertTrue(line.admin_solicited)