signal-bot/tests/test_media_strip.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

171 lines
6.5 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The stripper, driven against stub binaries rather than the real exiftool.
Stubs, not mocks of ``subprocess``: the thing worth testing is that the gate
reads the *output back* and refuses on what it finds there, and a stub that
really writes a file and really prints a report exercises that end to end
without needing exiftool installed. ``scripts/e2e.sh`` runs the real one.
"""
from __future__ import annotations
import stat
from typing import TYPE_CHECKING
import pytest
from signal_bot.media_sniff import JPEG, MP4, SVG, WEBM, MediaKind
from signal_bot.media_strip import FALLBACK_PATHS, ExiftoolStripper, locate
if TYPE_CHECKING:
from pathlib import Path
CLEAN_REPORT = """[ExifTool] ExifToolVersion : 13.55
[File] FileName : out.jpg
[File] MIMEType : image/jpeg
[Composite] ImageSize : 48x48
"""
# A geotag as a phone actually writes it into an mp4: a QuickTime atom, which
# a gate looking only at "GPS" would wave straight through.
LEAKY_REPORT = """[File] FileName : out.mp4
[QuickTime] MajorBrand : MP4 Base Media v1
[QuickTime] GPSCoordinates : 52.2297 21.0122 0
"""
# Lines the parser must simply skip rather than trip over.
NOISY_REPORT = """Warning: something exiftool felt like saying
[File] FileName : out.jpg
======== out.jpg
[Composite] ImageSize : 48x48
"""
STUB = """#!/usr/bin/env python3
import sys
args = sys.argv[1:]
if "-all=" in args:
{strip}
else:
sys.stdout.write({report!r})
sys.exit(0)
"""
def _stub(tmp_path: Path, name: str, *, strip: str, report: str = CLEAN_REPORT) -> str:
"""Write an executable stand-in for exiftool and return its path."""
path = tmp_path / name
path.write_text(STUB.format(strip=strip, report=report), encoding="utf-8")
path.chmod(path.stat().st_mode | stat.S_IEXEC)
return str(path)
COPIES = 'open(args[args.index("-o") + 1], "wb").write(open(args[-1], "rb").read())'
WRITES_NOTHING = "pass"
FAILS = "sys.exit(3)"
def test_a_clean_file_comes_back_stripped(tmp_path: Path) -> None:
stripper = ExiftoolStripper(_stub(tmp_path, "ok", strip=COPIES))
assert stripper.strip(b"\xff\xd8\xffdata", JPEG) == b"\xff\xd8\xffdata"
def test_a_tag_that_survived_the_strip_refuses_the_upload(tmp_path: Path) -> None:
"""The whole point: the exit code was 0 and the file is still dirty."""
stripper = ExiftoolStripper(_stub(tmp_path, "leaky", strip=COPIES, report=LEAKY_REPORT))
assert stripper.strip(b"\x00\x00\x00\x20ftypmp42", MP4) is None
def test_lines_that_are_not_tags_are_skipped(tmp_path: Path) -> None:
stripper = ExiftoolStripper(_stub(tmp_path, "noisy", strip=COPIES, report=NOISY_REPORT))
assert stripper.strip(b"\xff\xd8\xffdata", JPEG) == b"\xff\xd8\xffdata"
def test_a_strip_that_exits_nonzero_stores_nothing(tmp_path: Path) -> None:
stripper = ExiftoolStripper(_stub(tmp_path, "fails", strip=FAILS))
assert stripper.strip(b"\xff\xd8\xffdata", JPEG) is None
def test_a_strip_that_writes_no_output_stores_nothing(tmp_path: Path) -> None:
"""Exit 0 and no file is exactly the shape of the ffmpeg regression."""
stripper = ExiftoolStripper(_stub(tmp_path, "silent", strip=WRITES_NOTHING))
assert stripper.strip(b"\xff\xd8\xffdata", JPEG) is None
def test_a_report_that_fails_to_run_stores_nothing(tmp_path: Path) -> None:
path = tmp_path / "half"
path.write_text(
"#!/usr/bin/env python3\n"
"import sys\n"
"args = sys.argv[1:]\n"
'if "-all=" in args:\n'
' open(args[args.index("-o") + 1], "wb").write(open(args[-1], "rb").read())\n'
" sys.exit(0)\n"
"sys.exit(4)\n",
encoding="utf-8",
)
path.chmod(path.stat().st_mode | stat.S_IEXEC)
assert ExiftoolStripper(str(path)).strip(b"\xff\xd8\xffdata", JPEG) is None
def test_a_missing_binary_refuses_rather_than_raising(tmp_path: Path) -> None:
stripper = ExiftoolStripper(str(tmp_path / "not-here"))
assert stripper.strip(b"\xff\xd8\xffdata", JPEG) is None
def test_no_exiftool_at_all_refuses_the_upload(monkeypatch: pytest.MonkeyPatch) -> None:
"""Never stores it dirty instead: a strip that cannot run is a refusal.
Both lookups have to be blinded, not just PATH -- on a machine that has
perl-image-exiftool the fallback list finds it and this branch is never
reached, which is exactly how it went uncovered until the live run.
"""
monkeypatch.setattr("signal_bot.media_strip.shutil.which", lambda _: None)
monkeypatch.setattr("signal_bot.media_strip.Path.is_file", lambda self: False)
assert ExiftoolStripper().strip(b"\xff\xd8\xffdata", JPEG) is None
@pytest.mark.parametrize("kind", [SVG, WEBM])
def test_the_two_unwritable_formats_are_stored_as_they_arrived(
tmp_path: Path, kind: MediaKind
) -> None:
"""kuhy's decision of 2026-08-31, and it must not need exiftool at all."""
stripper = ExiftoolStripper(str(tmp_path / "never-invoked"))
payload = b"<svg><metadata>a name</metadata></svg>"
assert stripper.strip(payload, kind) == payload
def test_locate_prefers_path(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("signal_bot.media_strip.shutil.which", lambda _: "/somewhere/exiftool")
assert locate() == "/somewhere/exiftool"
def test_locate_falls_back_to_the_known_install_locations(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The live bug of 2026-08-31, pinned.
Arch's perl-image-exiftool installs to /usr/bin/vendor_perl, which is on
an interactive shell's PATH and not on a systemd unit's -- so the
stripper passed every test and refused every upload in production.
"""
monkeypatch.setattr("signal_bot.media_strip.shutil.which", lambda _: None)
monkeypatch.setattr(
"signal_bot.media_strip.Path.is_file",
lambda self: str(self) == "/usr/bin/vendor_perl/exiftool",
)
assert locate() == "/usr/bin/vendor_perl/exiftool"
def test_locate_returns_none_when_it_really_is_absent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("signal_bot.media_strip.shutil.which", lambda _: None)
monkeypatch.setattr("signal_bot.media_strip.Path.is_file", lambda self: False)
assert locate() is None
def test_the_arch_vendor_perl_path_is_on_the_fallback_list() -> None:
"""Named explicitly: this is the one that actually bit."""
assert "/usr/bin/vendor_perl/exiftool" in FALLBACK_PATHS