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