signal-bot/tests/test_pack_add_invariants.py
Krzysztof kuhy Rudnicki aefffb2fd9
Some checks are pending
CI / check (push) Waiting to run
feat: add a photo to the album from the chat, tagged, stripped and verified
Send a photo captioned "!add_photo alfa wakacje" in one of the album's own
groups and it lands in the pack tagged with both, retrievable through !alfa
and the new !losowe immediately -- no restart, and no tagger session on the PC.
A label matching nobody becomes a custom tag, and !losowe is the only door to
one, which is what keeps a live swap a *data* swap: it can never register a
trigger or widen a command's groups.

No typed label travels. Labels become pseudonym keys in a new registry
arg_resolver, at the dispatcher, before the line that logs the command name --
so no handler, journal entry, command_uses row or reply body ever holds a word
somebody typed. The reply is counts and pseudonyms: "dodano · p1: 4 · t1: 1".

Stripping is exiftool with a *positive* check on the output, never an exit
code. ffmpeg was tried first and fails silently on photos: given a JPEG with a
real APP1 Exif segment it returned a byte-identical file, GPS intact, exit 0.
The surviving-tag allowlist is per tag *name* rather than per group, because
measurement showed a group-level "must be empty" rule rejects every format
except JPEG -- a valid PNG always reports its IHDR, an MP4 some fifty QuickTime
tags that are the container itself. Codec configuration (CompressorName,
VendorID, the HEVC parameter set) is on the list by decision: CompressorName
lives in the avc1 sample description and exiftool cannot write it at all, so
the alternative was refusing every video and every iPhone HEIC. Location and
identity tags are still refused, and the e2e run re-reads the stored files to
prove a QuickTime geotag did not survive.

.webm and .svg stay on the format allowlist and are stored *unstripped*:
exiftool 13.55 can write neither, and its SVG reader does not even surface
<metadata>. That is a deliberate, documented hole, not an oversight.

Two things this had to defuse along the way:
- personal_data_sources now reads custom.json, so "!add_photo kot <photo>"
  would have outlawed the word "kot" and refused every future commit, fixable
  only by hand-editing an album the committer cannot see. Minting now refuses
  a word that is already a command, and the gate subtracts the repo's own
  alias vocabulary from custom labels only -- curated people.json names are
  never excused.
- signal_bot/external.py is now the single subprocess site in the repository,
  so ruff's S603 needs exactly one scoped per-file-ignore instead of one per
  module that shells out. A test asserts nothing else imports subprocess.

A failed add changes nothing: media and the three manifests are written, the
album is reloaded, and only a load that succeeds is published; a load that
fails rolls all four back and keeps serving the pack that was already working,
without raising -- Restart=always would turn a ConfigError into a loop.

977 tests, 100% branch coverage, no omits and no suppressions. scripts/e2e.sh
gains 8 scenarios driven against the real exiftool on media generated at run
time, and asserts the invariants a reply cannot: that a refused upload is not
stored, that a message with no attachment leaves custom.json byte-identical,
and that signal-cli's own copy of every ingested attachment is gone.

Closes TODO-add-photo.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqqE22jp94B1ypwxppdr1o
2026-08-31 11:20:00 +02:00

228 lines
8.5 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The guarantees !add_photo exists to keep, each asserted on its own.
Separate from test_pack_add.py because these are not "does the feature work"
-- they are the properties DOCS-photo-pack.md promises, and they should fail
loudly and by name when one of them stops holding.
"""
from __future__ import annotations
import datetime as dt
import json
import logging
from typing import TYPE_CHECKING
import httpx
from signal_bot.animals import register_animal_commands
from signal_bot.dispatcher import Dispatcher
from signal_bot.http_client import ImageFetcher
from signal_bot.pack import TAGS_FILE
from signal_bot.pack_add import COMMAND_NAME, AddResolver
from signal_bot.pack_command import UNKNOWN
from signal_bot.pack_custom import CUSTOM_FILE
from signal_bot.pack_holder import PackHolder
from signal_bot.pack_load import load
from signal_bot.pack_replies import FAILED
from signal_bot.pack_store import ADDS_FILE
from signal_bot.registry import CommandRegistry
from tests import pack_fixture
from tests.conftest import WATCHED_GROUP, envelope
from tests.pack_add_fixture import (
JPEG_ONE,
NOT_MEDIA,
FakeStripper,
UndeletableTransport,
Wiring,
_registry,
_run,
holder,
)
if TYPE_CHECKING:
from pathlib import Path
import pytest
from signal_bot.config import Config
from signal_bot.discovery import GroupDirectory
from signal_bot.store import Store
from signal_bot.transport_fake import FakeTransport
__all__ = ["holder"]
async def test_a_typed_label_reaches_no_log_line_no_row_and_no_reply(
holder: PackHolder,
transport: FakeTransport,
store: Store,
config: Config,
directory: GroupDirectory,
caplog: pytest.LogCaptureFixture,
) -> None:
"""The scrubbing seam, on all three surfaces, through the real dispatcher.
Driven end to end rather than by calling the handler: the log line that
names a command is written by the dispatcher, and the command_uses row is
written by the dispatcher, so a handler-level test could not see either.
"""
registry, _ = _registry(holder, FakeStripper())
dispatcher = Dispatcher(
config=config,
directory=directory,
registry=registry,
store=store,
transport=transport,
clock=lambda: dt.datetime(2026, 8, 31, 12, 0, tzinfo=dt.UTC),
)
transport.payloads["0"] = JPEG_ONE
frame = envelope(text="!dodaj sekretnaetykieta", attachments=("image/jpeg",))
with caplog.at_level(logging.DEBUG):
await dispatcher.handle(frame)
assert "dodano" in transport.texts[-1]
assert "sekretnaetykieta" not in caplog.text
assert all("sekretnaetykieta" not in text for text in transport.texts)
# The row exists and is keyed by the pseudonym, not by anything typed.
assert store.uses_today(WATCHED_GROUP, "uuid-alice", COMMAND_NAME, "2026-08-31") == 1
assert "sekretnaetykieta" not in COMMAND_NAME
async def test_the_container_copy_is_deleted_whether_accepted_or_refused(
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
) -> None:
transport.payloads["a1"] = JPEG_ONE
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a1",))
assert transport.deleted == ["a1"]
transport.payloads["a2"] = NOT_MEDIA
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a2",))
assert transport.deleted == ["a1", "a2"]
async def test_the_adder_is_recorded_for_a_future_undo(
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
) -> None:
transport.payloads["a1"] = JPEG_ONE
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a1",))
document = json.loads((holder.pack.directory / ADDS_FILE).read_text(encoding="utf-8"))
entry = next(iter(document.values()))
assert entry == {"by": "uuid-alice", "at": 1756600000}
async def test_nothing_reads_adds_json_at_load_time(
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
) -> None:
"""A corrupt audit file must not be able to take the album down."""
transport.payloads["a1"] = JPEG_ONE
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a1",))
(holder.pack.directory / ADDS_FILE).write_text("{ corrupt", encoding="utf-8")
assert holder.reload() is True
async def test_a_swap_that_fails_rolls_everything_back_and_keeps_serving(
holder: PackHolder,
transport: FakeTransport,
store: Store,
config: Config,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The branch nobody remembers: written, unloadable, and must not raise."""
root = holder.pack.directory
before_pack = holder.pack
before_tags = (root / TAGS_FILE).read_text(encoding="utf-8")
before_media = sorted(p.name for p in (root / "media").iterdir())
monkeypatch.setattr(PackHolder, "reload", lambda _self: False)
transport.payloads["a1"] = JPEG_ONE
await _run(Wiring(holder, transport, store, config), "!dodaj alfa wakacje", attachments=("a1",))
assert transport.texts[-1] == FAILED
assert holder.pack is before_pack
assert (root / TAGS_FILE).read_text(encoding="utf-8") == before_tags
assert sorted(p.name for p in (root / "media").iterdir()) == before_media
assert not (root / CUSTOM_FILE).exists()
# --------------------------------------------------------------------------
# The resolver on its own
# --------------------------------------------------------------------------
def test_the_pending_mint_is_cleared_between_messages(holder: PackHolder) -> None:
"""Dispatch is sequential, so exactly one message's mints are outstanding."""
resolver = AddResolver(holder, CommandRegistry())
assert resolver(("wakacje",)) == ("t1",)
assert resolver.pending == {"t1": "wakacje"}
assert resolver(("alfa",)) == ("p1",)
assert resolver.pending == {}
def test_two_new_labels_in_one_message_get_different_keys(holder: PackHolder) -> None:
resolver = AddResolver(holder, CommandRegistry())
assert resolver(("wakacje", "gory")) == ("t1", "t2")
def test_a_known_custom_label_resolves_without_minting(tmp_path: Path) -> None:
"""A label the album already has is reused, never allocated a second key."""
root = pack_fixture.build(tmp_path / "pack")
(root / CUSTOM_FILE).write_text(json.dumps({"t1": ["wakacje"]}), encoding="utf-8")
holder = PackHolder(load(root))
resolver = AddResolver(holder, CommandRegistry())
assert resolver(("wakacje",)) == ("t1",)
assert resolver.pending == {}
async def test_a_delete_that_fails_does_not_lose_the_photo(
holder: PackHolder, store: Store, config: Config, caplog: pytest.LogCaptureFixture
) -> None:
"""The album write already succeeded; a stuck container copy is a log line."""
transport = UndeletableTransport()
transport.payloads["a1"] = JPEG_ONE
before = len(holder.pack.matching(frozenset({"p1"})))
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a1",))
assert len(holder.pack.matching(frozenset({"p1"}))) == before + 1
assert "could not delete" in caplog.text
def test_no_decorator_is_applied_so_there_is_no_cap(holder: PackHolder) -> None:
"""kuhy: NO CAP, NO LIMITS -- no cooldown, no size cap, no daily limit."""
registry, _ = _registry(holder, FakeStripper())
command = registry.commands[0]
assert command.name == COMMAND_NAME
assert command.groups == holder.pack.groups
assert command.arg_resolver is not None
def test_a_label_that_is_already_a_command_is_refused(holder: PackHolder) -> None:
"""Otherwise one message wedges the personal-data gate permanently.
scripts/personal_data_sources feeds custom labels to the gate. Minting
"kot" would put a word that is in aliases.json and DOCS-commands.md onto
the forbidden list, and every commit after that would be refused until
somebody hand-edited an album the committer cannot see.
"""
registry = CommandRegistry()
register_animal_commands(registry, _fetcher())
resolver = AddResolver(holder, registry)
assert resolver(("kot",)) == (UNKNOWN,)
assert resolver.pending == {}
def test_a_label_that_is_not_a_command_still_mints(holder: PackHolder) -> None:
registry = CommandRegistry()
register_animal_commands(registry, _fetcher())
resolver = AddResolver(holder, registry)
assert resolver(("wakacje",)) == ("t1",)
assert resolver.pending == {"t1": "wakacje"}
def _fetcher() -> ImageFetcher:
"""An image fetcher whose client is never called: only triggers matter."""
return ImageFetcher(httpx.AsyncClient())