From b3eb14140cbb3d2d3b2b99f651d558ea6bf33a96 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Fri, 8 May 2026 21:52:34 -0400 Subject: [PATCH] =?UTF-8?q?admin=20Posts=20(NOTE=5FUNLOCK):=20readonly=20i?= =?UTF-8?q?nput=20+=20'No=20response=20needed'=20placeholder=20+=20secUser?= =?UTF-8?q?=20focus=20glow=20+=20buddy=20btn=20suppressed=20+=20view=20POS?= =?UTF-8?q?T=20403=20+=20Line.admin=5Fsolicited=20listener=20nukes=20erran?= =?UTF-8?q?t=20writes;=20share=20Lines:=20drop=20ts=20suffix,=20author=20?= =?UTF-8?q?=3D=20sharer=20(adman=20fallback=20for=20anon=20legacy),=20sile?= =?UTF-8?q?nt=20no-op=20on=20re-share=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - billboard/0005 adds Line.admin_solicited (BooleanField default False); RunPython backfills existing note_unlock Lines to True. Note.grant_if_new sets admin_solicited=True on its system prose. - billboard.models post_save signal: any Line saved on a Post.kind=NOTE_UNLOCK without admin_solicited=True is deleted (defense-in-depth alongside the view guard). - billboard.views.view_post hard-rejects POST on NOTE_UNLOCK kind (HTTP 403) — clean view-level contract; the post_save listener is the safety net for ORM/API paths that bypass it. - templates/apps/billboard/post.html: NOTE_UNLOCK branch renders the input as readonly w. 'No response needed at this time' placeholder + no method/action; user_post branch keeps the regular composer. Buddy panel include guarded behind `{% if post.kind != 'note_unlock' %}` — friend invites don't apply to admin threads. - SCSS: .post-line-form input.form-control[readonly]:focus uses --secUser glow (cooler than the regular --terUser composer focus). - share_post: drop the iso-timestamp suffix on Line.text (just 'Shared with {email}'); author = request.user (anon legacy fallback to adman so AnonymousUser doesn't break the FK); re-share of an already-in-shared_with recipient is a silent no-op (no second Line, brief: null in JSON response). Buddy panel JS now reads data-sharer-name from server-rendered display_name so the optimistic _appendLine matches the post-refresh state. - new ITs: test_admin_posts (PostRejectsAdminWritesTest, UnsolicitedLineListenerTest, NoteGrantSetsAdminSolicitedTest) — 7 tests; share_post tests rewritten for the new contract (drop ts, author=sharer, silent re-share dedup) — 12 tests; new FT test_admin_post_readonly w. AdminPostInputReadonlyTest + AdminPostHasNoBuddyBtnTest + UserPostInputUnaffectedTest — 4 tests. 827 ITs + 18 buddy/sharing FTs green. Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migrations/0005_line_admin_solicited.py | 34 +++++ src/apps/billboard/models.py | 21 +++ .../tests/integrated/test_admin_posts.py | 126 ++++++++++++++++++ .../tests/integrated/test_share_post.py | 38 +++++- src/apps/billboard/views.py | 57 +++++--- src/apps/drama/models.py | 5 +- .../test_admin_post_readonly.py | 118 ++++++++++++++++ src/static_src/scss/_billboard.scss | 8 ++ .../billboard/_partials/_buddy_panel.html | 13 +- src/templates/apps/billboard/post.html | 58 +++++--- 10 files changed, 427 insertions(+), 51 deletions(-) create mode 100644 src/apps/billboard/migrations/0005_line_admin_solicited.py create mode 100644 src/apps/billboard/tests/integrated/test_admin_posts.py create mode 100644 src/functional_tests/test_admin_post_readonly.py diff --git a/src/apps/billboard/migrations/0005_line_admin_solicited.py b/src/apps/billboard/migrations/0005_line_admin_solicited.py new file mode 100644 index 0000000..4cf4da7 --- /dev/null +++ b/src/apps/billboard/migrations/0005_line_admin_solicited.py @@ -0,0 +1,34 @@ +# Adds Line.admin_solicited (BooleanField) to discriminate +# system-authored Lines (Note.grant_if_new) from user writes on +# NOTE_UNLOCK Posts. The post_save signal nukes any Line on a +# NOTE_UNLOCK Post that lacks admin_solicited=True — defense-in-depth +# alongside the view_post POST guard. Backfill: existing NOTE_UNLOCK +# Lines (the only system-authored kind at this point) get True; all +# others default False. + +from django.db import migrations, models + + +def backfill(apps, schema_editor): + Line = apps.get_model("billboard", "Line") + Line.objects.filter(post__kind="note_unlock").update(admin_solicited=True) + + +def reverse_noop(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("billboard", "0004_post_title_line_author_created_at"), + ] + + operations = [ + migrations.AddField( + model_name="line", + name="admin_solicited", + field=models.BooleanField(default=False), + ), + migrations.RunPython(backfill, reverse_noop), + ] diff --git a/src/apps/billboard/models.py b/src/apps/billboard/models.py index f281a35..d42c7b5 100644 --- a/src/apps/billboard/models.py +++ b/src/apps/billboard/models.py @@ -1,6 +1,8 @@ import uuid from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver from django.urls import reverse from django.utils import timezone @@ -63,6 +65,10 @@ class Line(models.Model): related_name="authored_lines", ) created_at = models.DateTimeField(auto_now_add=True) + # System-authored Lines on NOTE_UNLOCK Posts must set this True; the + # post_save signal below deletes any Line on a NOTE_UNLOCK Post w.o. + # this flag (defense-in-depth alongside view_post's POST guard). + admin_solicited = models.BooleanField(default=False) class Meta: ordering = ("created_at", "id") @@ -149,3 +155,18 @@ class Brief(models.Model): "square_url": square_url, "created_at": self.created_at.isoformat(), } + + +# ── Listener: nuke unsolicited Lines on NOTE_UNLOCK Posts ───────────────── +# Defense-in-depth alongside view_post's POST guard. A Line saved on a +# NOTE_UNLOCK Post that lacks admin_solicited=True (e.g. a stray ORM-level +# write or an API path that bypasses the view) gets deleted right after +# the save. Note.grant_if_new sets admin_solicited=True on its Lines so +# legitimate system prose survives. + +@receiver(post_save, sender=Line) +def _delete_unsolicited_admin_post_lines(sender, instance, created, **kwargs): + if not created: + return + if instance.post.kind == Post.KIND_NOTE_UNLOCK and not instance.admin_solicited: + instance.delete() diff --git a/src/apps/billboard/tests/integrated/test_admin_posts.py b/src/apps/billboard/tests/integrated/test_admin_posts.py new file mode 100644 index 0000000..0bfb59b --- /dev/null +++ b/src/apps/billboard/tests/integrated/test_admin_posts.py @@ -0,0 +1,126 @@ +"""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) diff --git a/src/apps/billboard/tests/integrated/test_share_post.py b/src/apps/billboard/tests/integrated/test_share_post.py index 8dcfdfe..efd7a1e 100644 --- a/src/apps/billboard/tests/integrated/test_share_post.py +++ b/src/apps/billboard/tests/integrated/test_share_post.py @@ -85,16 +85,42 @@ class SharePostAsyncTest(TestCase): self.assertEqual(body["brief"]["kind"], "share_invite") self.assertIn("alice@test.io", body["line_text"]) - def test_async_share_line_text_dedupes_via_timestamp(self): - """Two consecutive shares of the same email must not collide on the - Line.unique_together(post, text) constraint — the share line should - carry a timestamp suffix.""" + def test_async_reshare_same_recipient_is_silent_noop(self): + """Sharing the same recipient twice is a silent no-op — Post.shared_with + M2M is idempotent so a second add is meaningless, and we don't want a + duplicate Line cluttering the thread. Response is 200 with brief=null.""" User.objects.create(email="alice@test.io") self._share_async("alice@test.io") - # Second share — should append a second distinct Line, not 500. + self.assertEqual(self.post.lines.count(), 1) + before_brief_count = Brief.objects.filter(owner=self.sharer).count() + response = self._share_async("alice@test.io") + self.assertEqual(response.status_code, 200) - self.assertEqual(self.post.lines.count(), 2) + self.assertEqual(response.json()["brief"], None) + # No second Line, no second Brief. + self.assertEqual(self.post.lines.count(), 1) + self.assertEqual( + Brief.objects.filter(owner=self.sharer).count(), + before_brief_count, + ) + + def test_async_share_line_text_drops_timestamp(self): + """The share Line's text is plain "Shared with X" — no "at " + suffix (timestamp display lives on the per-Line `