Compare commits

..

1 Commits

Author SHA1 Message Date
daddy 9334caf533 Add pluggable server-side TextFilter service (social.md §4 US-4)
Deterministic default (ListBasedTextFilter) backed by a configurable JSON
term list, with normalization (case-fold, leetspeak substitutions,
stripped punctuation/spacing between letters) so trivial evasion doesn't
slip through. Supports both reject (blocking) and redact (asterisk
replacement) modes. TextFilter is a Protocol, leaving an explicit
extension point for a future third-party moderation provider.

Standalone module, not wired into any router/endpoint -- sibling chat
tasks depend on this one. No DB/migration work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-15 00:32:17 +00:00
9 changed files with 518 additions and 452 deletions
@@ -1,80 +0,0 @@
"""add group tables (groups, group_members)
Revision ID: 0009_add_group_tables
Revises: 0008_add_chat_tables
Create Date: 2026-09-15 00:00:00.000000
docs/planning/social.md US-5 / app/models/groups.py. Models + migration only
-- create/join/leave, role authorization, and the group page endpoint live
in a sibling task ("Groups API") that depends on this one.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0009_add_group_tables"
down_revision: Union[str, None] = "0008_add_chat_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
group_role = postgresql.ENUM("owner", "admin", "member", name="group_role")
group_role.create(op.get_bind(), checkfirst=True)
op.create_table(
"groups",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"owner_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_groups_owner_id", "groups", ["owner_id"])
op.create_table(
"group_members",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"group_id",
sa.Integer(),
sa.ForeignKey("groups.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"role",
postgresql.ENUM("owner", "admin", "member", name="group_role", create_type=False),
nullable=False,
server_default="member",
),
sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("group_id", "user_id", name="uq_group_members_pair"),
)
op.create_index("ix_group_members_group_id", "group_members", ["group_id"])
op.create_index("ix_group_members_user_id", "group_members", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_group_members_user_id", table_name="group_members")
op.drop_index("ix_group_members_group_id", table_name="group_members")
op.drop_table("group_members")
op.drop_index("ix_groups_owner_id", table_name="groups")
op.drop_table("groups")
postgresql.ENUM(name="group_role").drop(op.get_bind(), checkfirst=True)
-2
View File
@@ -8,12 +8,10 @@ from app.models.social import Friendship, Follow, Block, FriendshipStatus
from app.models.chat import (
Conversation, ConversationParticipant, Message, MessageReport, ReportStatus,
)
from app.models.groups import Group, GroupMember, GroupRole
__all__ = [
"User", "Session", "World", "Asset", "AvatarLoadout",
"WorldRoom", "WorldChunk", "WorldChunkRevision",
"Friendship", "Follow", "Block", "FriendshipStatus",
"Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus",
"Group", "GroupMember", "GroupRole",
]
-97
View File
@@ -1,97 +0,0 @@
"""Groups: named groups with owner/admin/member roles.
docs/planning/social.md §4/§5 (US-5). This module is models + schema only --
create/join/leave, role authorization (only the owner may transfer ownership
or promote/demote admins), and the group page aggregate endpoint belong to
the API router task ("Groups API") that depends on this one.
Delete-behavior choices:
- ``groups.owner_id`` -> ``SET NULL`` (nullable). A group is a durable,
multi-member community, not content solely attributable to one person like
a ``World``/``Asset``. Cascading the group away (or hard-blocking the
owner's account deletion) would punish every other member for the owner's
choice to leave the platform. Instead the group and its full membership
list survive with ``owner_id`` nulled out; the sibling API task is
responsible for surfacing an ownerless group (e.g. prompting an admin to
claim ownership) the same way ``Message.sender_id`` / ``WorldChunk``'s
actor columns already treat "the actor is gone" as a survivable state
elsewhere in this schema.
- ``group_members.group_id`` -> ``CASCADE``. A membership row is meaningless
without the group it belongs to; deleting a group deletes its roster,
mirroring ``ConversationParticipant.conversation_id`` in chat.py.
- ``group_members.user_id`` -> ``CASCADE``. Deleting a user removes their own
membership rows wherever they appear, matching the join-table convention
used by ``ConversationParticipant.user_id`` and the social-graph tables
(``Friendship``/``Follow``/``Block``) for plain per-user references.
"""
import enum
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class GroupRole(str, enum.Enum):
OWNER = "owner"
ADMIN = "admin"
MEMBER = "member"
class Group(Base):
"""A named group. ``owner_id`` is nullable -- see module docstring for
why the group survives its owner's account deletion."""
__tablename__ = "groups"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
owner_id = Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
owner = relationship("User", back_populates="owned_groups")
members = relationship(
"GroupMember", back_populates="group", cascade="all, delete-orphan"
)
class GroupMember(Base):
"""A group's roster: one row per (group, user) with a role."""
__tablename__ = "group_members"
id = Column(Integer, primary_key=True, index=True)
group_id = Column(
Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
role = Column(
ENUM(
GroupRole,
name="group_role",
create_type=False,
values_callable=lambda enum_cls: [e.value for e in enum_cls],
),
nullable=False,
default=GroupRole.MEMBER,
server_default=GroupRole.MEMBER.value,
)
joined_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
group = relationship("Group", back_populates="members")
user = relationship("User", back_populates="group_memberships")
__table_args__ = (
UniqueConstraint("group_id", "user_id", name="uq_group_members_pair"),
)
-11
View File
@@ -73,17 +73,6 @@ class User(Base):
cascade="all, delete-orphan",
)
# Groups (docs/planning/social.md US-5; app/models/groups.py)
owned_groups = relationship(
"Group",
back_populates="owner",
)
group_memberships = relationship(
"GroupMember",
back_populates="user",
cascade="all, delete-orphan",
)
@property
def is_adult(self) -> bool:
"""Return True if the user is 18 or older based on date_of_birth."""
+304
View File
@@ -0,0 +1,304 @@
"""Pluggable server-side text filtering, `docs/planning/social.md` §4/§5 (US-4).
social.md §4 calls for a "Pluggable `TextFilter` interface with a
deterministic default (profanity list + normalization) so it runs without
external services; third-party hook later". This module is that interface
plus the default implementation:
- `TextFilter` is a `Protocol` -- the extension point. A future third-party
moderation provider (a paid API, an ML classifier, etc.) just needs to
implement `check(text) -> FilterResult`; nothing else in this module
needs to change, and callers depend on the protocol, not a concrete
class.
- `ListBasedTextFilter` is the deterministic default: a configurable
term list (JSON file, see `text_filter_terms.json` next to this module)
matched against a normalized form of the input text so that trivial
evasion (case changes, leetspeak substitutions, and punctuation/spacing
inserted between letters) doesn't slip through.
Nothing here talks to a database, an HTTP client, or the FastAPI app -- it
is a pure, side-effect-free (aside from reading the term list file once, at
construction) service so it can be unit tested in isolation. It is NOT
wired into any router/endpoint by this task; that's for the chat feature
tasks that depend on it (social.md §6) to do.
Normalization approach
-----------------------
Matching naively against raw text misses "sh1t", "@ss", or "s.h.i.t". To
catch these without a real tokenizer, every character of the input is
independently normalized to at most one lowercase ASCII alphanumeric
character (case-folded, then run through a small leetspeak substitution
table); characters that don't reduce to `[a-z0-9]` (punctuation, spacing,
symbols, non-ASCII) are dropped entirely rather than kept as separators.
This collapses "s.h.i.t", "s h i t", and "SHIT" to the same normalized
string ("shit") that a plain lowercase term list entry matches, while a
side table (`index_map`) remembers which original character produced each
surviving normalized character -- so a match found in the normalized
string can still be translated back into a span of the *original* text for
redaction.
This is a deliberate precision/recall tradeoff common to list-based
filters: stripping separators and folding leetspeak increases recall
against evasion at the cost of occasionally matching a term inside an
unrelated longer word (e.g. a 3-letter term inside a longer benign word).
That tradeoff is exactly why this is pluggable -- a deployment that needs
tighter precision can swap in a smarter `TextFilter` implementation behind
the same protocol.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Iterable, Protocol, runtime_checkable
# ---------------------------------------------------------------------------
# Public result / protocol
# ---------------------------------------------------------------------------
class FilterAction(str, Enum):
"""What the caller should do with a checked message."""
ALLOW = "allow"
REDACT = "redact"
REJECT = "reject"
@dataclass(frozen=True)
class FilterResult:
"""Outcome of running a piece of text through a `TextFilter`.
- `action`: ALLOW (no matches), REDACT (matches replaced in
`filtered_text`), or REJECT (matches found; the message should be
blocked -- callers should not persist/deliver it even though
`filtered_text` is populated for logging/audit purposes).
- `filtered_text`: the text to actually store/deliver. Equal to the
input for ALLOW and REJECT; has matched spans replaced for REDACT.
- `matched_terms`: the distinct matched terms (original, un-normalized
list entries), in first-occurrence order. Empty for ALLOW.
"""
action: FilterAction
filtered_text: str
matched_terms: tuple[str, ...] = ()
@runtime_checkable
class TextFilter(Protocol):
"""Extension point for text filtering/moderation providers.
Any implementation -- the deterministic default below, or a future
third-party service call -- just needs this one method.
"""
def check(self, text: str) -> FilterResult:
"""Check `text` and return the action to take plus filtered text."""
...
class FilterMode(str, Enum):
"""Configures what `ListBasedTextFilter` does when it finds a match."""
REDACT = "redact"
REJECT = "reject"
# ---------------------------------------------------------------------------
# Normalization
# ---------------------------------------------------------------------------
# Common leetspeak / lookalike substitutions, applied after case-folding.
# Keys are already-lowercased single characters.
_LEET_MAP: dict[str, str] = {
"0": "o",
"1": "i",
"!": "i",
"3": "e",
"4": "a",
"@": "a",
"5": "s",
"$": "s",
"7": "t",
"+": "t",
"8": "b",
"|": "l",
}
_ALNUM_RE = re.compile(r"[a-z0-9]")
def _normalize(text: str) -> tuple[str, list[int]]:
"""Fold `text` to a lowercase-alnum-only string plus an index map.
Returns `(normalized, index_map)` where `index_map[i]` is the index
into the *original* `text` that produced `normalized[i]`. Characters
that don't reduce to a single `[a-z0-9]` character (punctuation,
whitespace, symbols, most non-ASCII) are dropped from both outputs.
"""
normalized_chars: list[str] = []
index_map: list[int] = []
for i, ch in enumerate(text):
folded = ch.casefold()
# case-fold can expand to multiple characters for some unicode
# (e.g. German "ß" -> "ss"); such characters are not part of the
# leetspeak table and are not plain ascii alnum, so just skip them
# like any other non-matching character rather than guessing.
if len(folded) != 1:
continue
folded = _LEET_MAP.get(folded, folded)
if _ALNUM_RE.fullmatch(folded):
normalized_chars.append(folded)
index_map.append(i)
return "".join(normalized_chars), index_map
def _normalize_term(term: str) -> str:
normalized, _ = _normalize(term)
return normalized
# ---------------------------------------------------------------------------
# Default term list loading
# ---------------------------------------------------------------------------
DEFAULT_TERMS_PATH = Path(__file__).with_name("text_filter_terms.json")
def load_terms(path: Path | str | None = None) -> list[str]:
"""Load a term list from a JSON file shaped `{"terms": [...]}`.
Any other top-level keys (e.g. `_readme`) are ignored. Defaults to the
bundled `text_filter_terms.json` next to this module.
"""
resolved = Path(path) if path is not None else DEFAULT_TERMS_PATH
with resolved.open("r", encoding="utf-8") as f:
data = json.load(f)
terms = data.get("terms", [])
if not isinstance(terms, list):
raise ValueError(f"{resolved}: 'terms' must be a list")
return [str(t) for t in terms]
# ---------------------------------------------------------------------------
# Span merging / redaction helpers
# ---------------------------------------------------------------------------
def _merge_spans(spans: list[tuple[int, int, str]]) -> list[tuple[int, int]]:
"""Merge overlapping/adjacent (start, end) spans, sorted by start."""
if not spans:
return []
ordered = sorted((s, e) for s, e, _ in spans)
merged: list[tuple[int, int]] = [ordered[0]]
for start, end in ordered[1:]:
last_start, last_end = merged[-1]
if start <= last_end:
merged[-1] = (last_start, max(last_end, end))
else:
merged.append((start, end))
return merged
def _apply_redactions(text: str, merged_spans: list[tuple[int, int]], redaction_char: str) -> str:
if not merged_spans:
return text
pieces: list[str] = []
cursor = 0
for start, end in merged_spans:
pieces.append(text[cursor:start])
pieces.append(redaction_char * (end - start))
cursor = end
pieces.append(text[cursor:])
return "".join(pieces)
# ---------------------------------------------------------------------------
# Default implementation
# ---------------------------------------------------------------------------
class ListBasedTextFilter:
"""Deterministic `TextFilter`: normalized substring match against a term list.
`mode` controls what happens on a match:
- `FilterMode.REDACT` (default): matched spans in the *original* text
are replaced with `redaction_char` repeated to the matched span's
original length; `action` is REDACT if anything matched, else ALLOW.
- `FilterMode.REJECT`: `filtered_text` is returned unchanged but
`action` is REJECT if anything matched (callers should treat this as
"do not store/deliver"), else ALLOW.
An empty or whitespace-only string always returns ALLOW with no
matches -- there is nothing to filter.
"""
def __init__(
self,
terms: Iterable[str] | None = None,
*,
mode: FilterMode = FilterMode.REDACT,
terms_path: Path | str | None = None,
redaction_char: str = "*",
) -> None:
if terms is None:
terms = load_terms(terms_path)
# De-dupe while preserving order; drop blanks.
seen: dict[str, None] = {}
for t in terms:
t = t.strip()
if t:
seen.setdefault(t, None)
self._terms: tuple[str, ...] = tuple(seen)
# Pre-normalize once at construction; skip terms that normalize to
# nothing (e.g. a term that's pure punctuation).
self._normalized_terms: list[tuple[str, str]] = [
(normalized, original)
for original in self._terms
if (normalized := _normalize_term(original))
]
self.mode = mode
self.redaction_char = redaction_char
def check(self, text: str) -> FilterResult:
if not text or not text.strip():
return FilterResult(action=FilterAction.ALLOW, filtered_text=text, matched_terms=())
normalized, index_map = _normalize(text)
if not normalized:
return FilterResult(action=FilterAction.ALLOW, filtered_text=text, matched_terms=())
spans: list[tuple[int, int, str]] = []
for normalized_term, original_term in self._normalized_terms:
start = 0
term_len = len(normalized_term)
while True:
idx = normalized.find(normalized_term, start)
if idx == -1:
break
end = idx + term_len
orig_start = index_map[idx]
orig_end = index_map[end - 1] + 1
spans.append((orig_start, orig_end, original_term))
start = end # non-overlapping: resume after this match
if not spans:
return FilterResult(action=FilterAction.ALLOW, filtered_text=text, matched_terms=())
spans.sort(key=lambda s: s[0])
matched_terms = tuple(dict.fromkeys(term for _, _, term in spans))
if self.mode is FilterMode.REJECT:
return FilterResult(action=FilterAction.REJECT, filtered_text=text, matched_terms=matched_terms)
merged = _merge_spans(spans)
filtered_text = _apply_redactions(text, merged, self.redaction_char)
return FilterResult(action=FilterAction.REDACT, filtered_text=filtered_text, matched_terms=matched_terms)
def default_text_filter(mode: FilterMode = FilterMode.REDACT) -> ListBasedTextFilter:
"""Convenience factory for the bundled default term list."""
return ListBasedTextFilter(mode=mode)
@@ -0,0 +1,20 @@
{
"_readme": "Default deterministic term list for app.services.text_filter.ListBasedTextFilter. This is a minimal, illustrative starter list (common mild-to-strong English profanity, no slurs) meant to be replaced/extended per-deployment -- swap this file, or pass an explicit `terms`/`terms_path` to the filter, rather than editing this list in place for production moderation policy.",
"terms": [
"fuck",
"shit",
"ass",
"asshole",
"bitch",
"bastard",
"damn",
"crap",
"dick",
"piss",
"slut",
"whore",
"cunt",
"douche",
"bullshit"
]
}
-4
View File
@@ -50,10 +50,6 @@ TEST_TABLES = (
# reached by cascading a truncate of messages/conversations; list it
# explicitly along with the rest for clarity/consistency.
"message_reports", "messages", "conversation_participants", "conversations",
# Groups (app/models/groups.py). groups.owner_id is ON DELETE SET NULL
# (not CASCADE, deliberately -- see groups.py), so it wouldn't be reached
# by cascading a truncate of users; list it explicitly for clarity too.
"group_members", "groups",
"sessions", "refresh_tokens", "users",
)
-258
View File
@@ -1,258 +0,0 @@
"""Model-level tests for groups: groups and group_members.
app/models/groups.py / alembic/versions/0009_add_group_tables.py
(docs/planning/social.md US-5). This is the models+migration task only --
there are no API endpoints yet (a sibling task, "Groups API", owns
create/join/leave/role authorization and the group page endpoint), so these
tests exercise the ORM models and the real Postgres constraints directly
rather than going through HTTP.
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.groups import Group, GroupMember, GroupRole
from app.models.user import User
def _register(client: TestClient, username: str) -> int:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
headers = {"Authorization": f"Bearer {resp.json()['access_token']}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"]
def _session_factory():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
return engine, async_sessionmaker(engine, expire_on_commit=False)
class TestGroupCreationAndRoles:
async def test_create_group_with_members_of_different_roles(self):
client = TestClient(app)
owner = _register(client, "grp_role_owner")
admin = _register(client, "grp_role_admin")
member = _register(client, "grp_role_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Explorers", description="We explore worlds", owner_id=owner)
session.add(group)
await session.flush()
session.add_all(
[
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
GroupMember(group_id=group.id, user_id=admin, role=GroupRole.ADMIN),
GroupMember(group_id=group.id, user_id=member, role=GroupRole.MEMBER),
]
)
await session.commit()
group_id = group.id
async with factory() as session:
fetched = await session.get(Group, group_id)
members = (
await session.execute(
select(GroupMember).where(GroupMember.group_id == group_id)
)
).scalars().all()
by_user = {m.user_id: m.role for m in members}
assert fetched.owner_id == owner
assert len(members) == 3
assert by_user[owner] == GroupRole.OWNER
assert by_user[admin] == GroupRole.ADMIN
assert by_user[member] == GroupRole.MEMBER
finally:
await engine.dispose()
async def test_membership_role_defaults_to_member(self):
client = TestClient(app)
owner = _register(client, "grp_default_owner")
joiner = _register(client, "grp_default_joiner")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Defaults", owner_id=owner)
session.add(group)
await session.flush()
session.add(GroupMember(group_id=group.id, user_id=joiner))
await session.commit()
group_id = group.id
async with factory() as session:
gm = (
await session.execute(
select(GroupMember).where(
GroupMember.group_id == group_id, GroupMember.user_id == joiner
)
)
).scalar_one()
assert gm.role == GroupRole.MEMBER
finally:
await engine.dispose()
class TestUniqueMembershipConstraint:
async def test_duplicate_membership_rejected(self):
client = TestClient(app)
owner = _register(client, "grp_uq_owner")
member = _register(client, "grp_uq_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Uniqueness", owner_id=owner)
session.add(group)
await session.flush()
session.add(GroupMember(group_id=group.id, user_id=member))
await session.commit()
group_id = group.id
async with factory() as session:
session.add(GroupMember(group_id=group_id, user_id=member))
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
class TestCascadeDelete:
async def test_deleting_group_deletes_members(self):
client = TestClient(app)
owner = _register(client, "grp_cd_group_owner")
member = _register(client, "grp_cd_group_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="To Delete", owner_id=owner)
session.add(group)
await session.flush()
session.add_all(
[
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
GroupMember(group_id=group.id, user_id=member),
]
)
await session.commit()
group_id = group.id
async with factory() as session:
# Core delete against groups directly, so the real ON DELETE
# CASCADE does the work (not the ORM).
await session.execute(delete(Group).where(Group.id == group_id))
await session.commit()
async with factory() as session:
members = (
await session.execute(
select(GroupMember).where(GroupMember.group_id == group_id)
)
).scalars().all()
assert members == []
finally:
await engine.dispose()
async def test_deleting_member_user_removes_only_their_membership(self):
client = TestClient(app)
owner = _register(client, "grp_cd_user_owner")
member = _register(client, "grp_cd_user_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Membership Cascade", owner_id=owner)
session.add(group)
await session.flush()
session.add_all(
[
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
GroupMember(group_id=group.id, user_id=member),
]
)
await session.commit()
group_id = group.id
async with factory() as session:
await session.execute(delete(User).where(User.id == member))
await session.commit()
async with factory() as session:
remaining = (
await session.execute(
select(GroupMember).where(GroupMember.group_id == group_id)
)
).scalars().all()
# Only the deleted member's row is gone; the group and the
# owner's membership survive.
assert len(remaining) == 1
assert remaining[0].user_id == owner
group = await session.get(Group, group_id)
assert group is not None
assert group.owner_id == owner
finally:
await engine.dispose()
async def test_deleting_owner_survives_with_owner_id_null(self):
"""Deleting the owner's account must not delete the group or its
other members -- only ``owner_id`` is nulled out (SET NULL), per the
module docstring in app/models/groups.py."""
client = TestClient(app)
owner = _register(client, "grp_cd_owner_owner")
member = _register(client, "grp_cd_owner_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Survives Owner Deletion", owner_id=owner)
session.add(group)
await session.flush()
session.add_all(
[
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
GroupMember(group_id=group.id, user_id=member),
]
)
await session.commit()
group_id = group.id
async with factory() as session:
await session.execute(delete(User).where(User.id == owner))
await session.commit()
async with factory() as session:
group = await session.get(Group, group_id)
assert group is not None
assert group.owner_id is None
assert group.name == "Survives Owner Deletion"
# The owner's own membership row is gone (user_id CASCADE),
# but the other member's row survives untouched.
members = (
await session.execute(
select(GroupMember).where(GroupMember.group_id == group_id)
)
).scalars().all()
assert len(members) == 1
assert members[0].user_id == member
finally:
await engine.dispose()
+194
View File
@@ -0,0 +1,194 @@
"""Unit tests for app.services.text_filter (docs/planning/social.md §4/§5 US-4).
Pure, side-effect-free service -- no DB/Redis fixtures needed.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from app.services.text_filter import (
DEFAULT_TERMS_PATH,
FilterAction,
FilterMode,
FilterResult,
ListBasedTextFilter,
TextFilter,
default_text_filter,
load_terms,
)
@pytest.fixture
def redact_filter() -> ListBasedTextFilter:
return ListBasedTextFilter(terms=["badword", "shoot"], mode=FilterMode.REDACT)
@pytest.fixture
def reject_filter() -> ListBasedTextFilter:
return ListBasedTextFilter(terms=["badword", "shoot"], mode=FilterMode.REJECT)
class TestAllowPath:
def test_clean_text_is_allowed_unchanged(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("hello there, how are you today?")
assert result.action is FilterAction.ALLOW
assert result.filtered_text == "hello there, how are you today?"
assert result.matched_terms == ()
def test_message_with_no_matches(self, reject_filter: ListBasedTextFilter) -> None:
# Distinct from the clean-text case above: exercises the reject-mode
# filter's no-match path specifically (mode should not matter when
# nothing matches).
text = "let's meet at the plaza around noon"
result = reject_filter.check(text)
assert result.action is FilterAction.ALLOW
assert result.filtered_text == text
assert result.matched_terms == ()
class TestRedactPath:
def test_exact_match_is_redacted(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("don't be a badword about it")
assert result.action is FilterAction.REDACT
assert result.matched_terms == ("badword",)
assert "badword" not in result.filtered_text
assert result.filtered_text == "don't be a ******* about it"
def test_multiple_distinct_matches(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("badword, don't shoot!")
assert result.action is FilterAction.REDACT
assert set(result.matched_terms) == {"badword", "shoot"}
assert "badword" not in result.filtered_text
assert "shoot" not in result.filtered_text
# Length is preserved (asterisks replace matched spans 1:1).
assert len(result.filtered_text) == len("badword, don't shoot!")
def test_redaction_char_is_configurable(self) -> None:
f = ListBasedTextFilter(terms=["badword"], mode=FilterMode.REDACT, redaction_char="#")
result = f.check("that's a badword thing to say")
assert result.filtered_text == "that's a ####### thing to say"
class TestRejectPath:
def test_match_is_rejected_and_text_left_intact(self, reject_filter: ListBasedTextFilter) -> None:
text = "stop, badword!"
result = reject_filter.check(text)
assert result.action is FilterAction.REJECT
assert result.matched_terms == ("badword",)
# Reject mode does not mutate the text -- callers are expected to
# not store/deliver it at all, based on `action`.
assert result.filtered_text == text
class TestNormalization:
def test_leetspeak_variant_is_caught(self, redact_filter: ListBasedTextFilter) -> None:
# "b4dw0rd" -> normalizes to "badword" via the leet map (4->a, 0->o).
result = redact_filter.check("you're being a b4dw0rd right now")
assert result.action is FilterAction.REDACT
assert result.matched_terms == ("badword",)
def test_separators_between_letters_are_stripped(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("that is b.a.d.w.o.r.d behavior")
assert result.action is FilterAction.REDACT
assert result.matched_terms == ("badword",)
def test_spacing_between_letters_is_stripped(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("b a d w o r d, seriously")
assert result.action is FilterAction.REDACT
assert result.matched_terms == ("badword",)
def test_case_folding(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("BADWORD in all caps")
assert result.action is FilterAction.REDACT
assert result.matched_terms == ("badword",)
def test_symbol_lookalikes(self) -> None:
# @ -> a, $ -> s: "@ss" should normalize to "ass".
f = ListBasedTextFilter(terms=["ass"], mode=FilterMode.REDACT)
result = f.check("what an @$$ move")
assert result.action is FilterAction.REDACT
assert result.matched_terms == ("ass",)
class TestEmptyAndWhitespace:
def test_empty_string(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("")
assert result.action is FilterAction.ALLOW
assert result.filtered_text == ""
assert result.matched_terms == ()
def test_whitespace_only_string(self, redact_filter: ListBasedTextFilter) -> None:
text = " \t\n "
result = redact_filter.check(text)
assert result.action is FilterAction.ALLOW
assert result.filtered_text == text
assert result.matched_terms == ()
def test_punctuation_only_string_has_nothing_to_normalize(self, redact_filter: ListBasedTextFilter) -> None:
# No alnum characters survive normalization at all -- must not
# crash on an empty normalized string / empty index_map.
result = redact_filter.check("...!!!???")
assert result.action is FilterAction.ALLOW
assert result.matched_terms == ()
class TestConfigurability:
def test_custom_term_list_is_used_instead_of_default(self) -> None:
f = ListBasedTextFilter(terms=["zzzcustomzzz"], mode=FilterMode.REDACT)
result = f.check("this contains zzzcustomzzz right here")
assert result.action is FilterAction.REDACT
assert result.matched_terms == ("zzzcustomzzz",)
# Default list terms should NOT match since we overrode the list.
result2 = f.check("this is fucking unrelated")
assert result2.action is FilterAction.ALLOW
def test_duplicate_and_blank_terms_are_deduped(self) -> None:
f = ListBasedTextFilter(terms=["badword", "badword", " ", ""], mode=FilterMode.REDACT)
assert f._terms == ("badword",)
def test_default_terms_file_loads_and_is_nonempty(self) -> None:
terms = load_terms()
assert isinstance(terms, list)
assert len(terms) > 0
assert all(isinstance(t, str) and t for t in terms)
def test_default_terms_path_exists(self) -> None:
assert DEFAULT_TERMS_PATH.exists()
assert DEFAULT_TERMS_PATH.suffix == ".json"
def test_load_terms_from_explicit_path(self, tmp_path: Path) -> None:
custom = tmp_path / "custom_terms.json"
custom.write_text('{"terms": ["foo", "bar"]}', encoding="utf-8")
assert load_terms(custom) == ["foo", "bar"]
def test_default_text_filter_factory_matches_default_list(self) -> None:
f = default_text_filter(mode=FilterMode.REJECT)
result = f.check("that's fucking ridiculous")
assert result.action is FilterAction.REJECT
assert "fuck" in result.matched_terms
class TestProtocol:
def test_list_based_filter_satisfies_text_filter_protocol(self, redact_filter: ListBasedTextFilter) -> None:
assert isinstance(redact_filter, TextFilter)
def test_check_returns_filter_result(self, redact_filter: ListBasedTextFilter) -> None:
result = redact_filter.check("hi")
assert isinstance(result, FilterResult)
def test_a_third_party_style_implementation_satisfies_the_protocol(self) -> None:
# Demonstrates the extension point: any object with a matching
# `check` method satisfies TextFilter without inheriting from
# anything -- this is how a future third-party provider would plug
# in without touching this module.
class AlwaysAllow:
def check(self, text: str) -> FilterResult:
return FilterResult(action=FilterAction.ALLOW, filtered_text=text, matched_terms=())
provider: TextFilter = AlwaysAllow()
assert isinstance(provider, TextFilter)
result = provider.check("anything at all, even fuck")
assert result.action is FilterAction.ALLOW