mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:27:17 +01:00
Some checks are pending
CI / check (push) Waiting to run
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
228 lines
8.5 KiB
Python
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())
|