signal-bot/tests/test_selftest_attachments.py
Krzysztof kuhy Rudnicki 21ccf8d8eb
Some checks are pending
CI / check (push) Waiting to run
fix: find exiftool the way the service does, and self-test with a photo
Two things the first live run found, neither of which any test could have.

**exiftool was invisible to the unit.** Arch's perl-image-exiftool installs to
/usr/bin/vendor_perl/exiftool. That is on an interactive shell's PATH, because
perl's profile.d puts it there, and it is *not* on a systemd user unit's, whose
default is "/usr/local/bin:/usr/bin". So shutil.which found it in every test
and in every shell, and the first real !add_photo under the unit answered
"exiftool is not installed; refusing to store .jpg" -- with exiftool plainly
installed. media_strip.locate() now tries PATH and then the known install
locations, and install_service.sh verifies the result through the bot's own
resolver under the unit's PATH rather than through `command -v`, which answers
for the wrong shell. Fixing the lookup rather than setting PATH in the unit:
the latter is a note somebody has to remember on the next machine.

The absent-exiftool test had to blind *both* lookups to keep covering its
branch -- on a machine that has the package the fallback finds it, which is
precisely how the gap survived.

**The self-test could not carry a photo.** Signal never delivers an account
its own sends: measured against the live container, a single-device account
gets no sync copy either, and a sent attachment is not added to the container's
attachment store (only inbound ones are). So !add_photo could not be exercised
the way every other command is. selftest.build_frame and POST /_selftest now
take attachment ids that the container's store already holds, so the handler
fetches them back over the real GET /v1/attachments/{id} -- the ingest path
under test is the live one, not a hand-fed buffer. Still fail-closed on
SELFTEST_GROUPS.

Verified live, against real Signal and the real container, in the testbot
group, on a throwaway album scoped to that group so the private one was
neither read nor written:

  - a geotagged JPEG (GPS + Make) went in as "!dodaj alfa wakacjelive"
  - the stored file reports zero non-structural tags, and neither the GPS
    marker nor the Make string survives in its bytes
  - GET /v1/attachments/<id> now 404s and the file is gone from disk, so the
    second copy really is deleted
  - the typed label appears zero times in the journal
  - !losowe wakacjelive, !alfa and an unknown label all answered without error

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

117 lines
4.4 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Self-testing a command that needs a *photo*, not just text.
Split out of test_selftest.py at the 250-line cap. This is the half that
exists because of !add_photo: Signal never delivers an account its own sends
-- measured against the live container on 2026-08-31, and a single-device
account gets no sync copy either -- so the only way to put a photo in front of
the bot is to name one the container's own attachment store already holds and
let the handler fetch it back over the real API.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import httpx
import pytest
from signal_bot.bot import build_bot
from signal_bot.liveness import Liveness
from signal_bot.selftest import attachment_entry, build_frame, run
from tests.conftest import WATCHED_GROUP
if TYPE_CHECKING:
from signal_bot.bot import Bot
from signal_bot.config import Config
from signal_bot.discovery import GroupDirectory
from signal_bot.registry import CommandContext
from signal_bot.store import Store
from signal_bot.transport_fake import FakeTransport
GROUP_ID = "group.internal-id"
OTHER = "+48000000000"
def _bot(config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory) -> Bot:
"""The production object graph, which is the point of self-testing at all."""
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(200)))
return build_bot(config, client, store, transport, directory, Liveness(config, client))
def _client(entries: list[dict[str, object]]) -> httpx.AsyncClient:
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=entries)
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
def test_a_frame_can_carry_an_attachment() -> None:
"""What makes !add_photo verifiable without a second human.
Signal never delivers an account its own sends -- measured against the
live container on 2026-08-31, and a single-device account gets no sync
copy either -- so this is the only way to put a photo in front of the bot.
"""
body = json.loads(build_frame(GROUP_ID, OTHER, "!dodaj alfa", ["abc.jpg"]))["envelope"]
attachments = body["dataMessage"]["attachments"]
assert attachments == [{"contentType": "image/jpeg", "id": "abc.jpg"}]
def test_a_frame_without_attachments_has_no_attachments_key() -> None:
"""Byte-for-byte what it emitted before, for every existing caller."""
body = json.loads(build_frame(GROUP_ID, OTHER, "!papież"))["envelope"]
assert "attachments" not in body["dataMessage"]
def test_the_attachment_sits_at_the_top_level_not_in_a_quote() -> None:
"""quote.attachments is the *quoted* message's media; taking it would let
a self-test re-file a photo somebody else posted."""
payload = json.loads(build_frame(GROUP_ID, OTHER, "!dodaj alfa", ["a.jpg"]))
message = payload["envelope"]["dataMessage"]
assert "attachments" in message
assert "quote" not in message
@pytest.mark.parametrize(
("attachment_id", "expected"),
[
("photo.jpg", "image/jpeg"),
("clip.mp4", "video/mp4"),
("shot.png", "image/png"),
# No suffix to go on, and it does not matter: !add_photo sniffs the
# magic bytes and ignores what the envelope claims to be carrying.
("no-suffix", "application/octet-stream"),
],
)
def test_the_content_type_is_guessed_from_the_id(attachment_id: str, expected: str) -> None:
assert attachment_entry(attachment_id)["contentType"] == expected
async def test_run_passes_attachments_through_to_the_dispatcher(
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
) -> None:
"""End to end through the real wiring: the id reaches the handler."""
bot = _bot(config, store, transport, directory)
entries = [
{"name": "testbot", "internal_id": WATCHED_GROUP, "members": [config.phone_number, OTHER]}
]
seen: list[tuple[str, ...]] = []
async def capture(ctx: CommandContext) -> None:
seen.append(ctx.message.attachment_ids)
bot.dispatcher.registry.register("probe", ["!probe"], capture, "probe", takes_args=True)
await run(
bot,
_client(entries),
"testbot",
"!probe",
env={"SELFTEST_GROUPS": "testbot"},
attachments=["staged.jpg"],
)
assert seen == [("staged.jpg",)]