contact: report an undecodable PHOTO to Sentry
The rescue had been eating the strict decoder's ArgumentError
silently. Warning grade, and at the read rather than the arrival: no
PUT probe reads PHOTO, and a stored card can predate any report.

Assisted-by: GLM-5.3 via pi
change syyprrzlqurqmzsnszplpowztzovsxsu
commit b2479187125cc9990ff4fd742053f53b36482fdd
author Alpha Chen <alpha@kejadlen.dev>
date
parent wprmnzol
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 1c288e2..f48d513 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -1,5 +1,6 @@
 require "base64"
 require "digest"
+require "sentry-ruby"
 
 require "pro_tacts/birthday"
 require "pro_tacts/vcard"
@@ -161,6 +162,15 @@ module ProTacts
     # PHOTO, an undecodable one, or one that sniffs to no known format
     # — the initials an avatar falls back to, an ordinary absence
     # rather than an error, exactly like the other accessors' nils.
+    # The undecodable nil is the one departure, and reports on its way
+    # there — warning-grade, carrying no card content — because a
+    # PHOTO this server cannot read is news, the URI form included:
+    # legal vCard (RFC 2426 section 3.1.4) that no client here sends
+    # yet. At the read rather than the arrival, unlike the parser's
+    # reports (Web#report_broken_assumptions): no arrival probe reads
+    # PHOTO, and a stored card can predate the report, while the
+    # decode runs only where an avatar renders and Sentry groups the
+    # repeats into one issue.
     #: () -> Photo?
     def photo
       property = properties.find { it.name.casecmp?("PHOTO") }
@@ -168,7 +178,8 @@ module ProTacts
 
       begin
         bytes = Base64.strict_decode64(property.value)
-      rescue ArgumentError
+      rescue ArgumentError => error
+        Sentry.capture_exception(error, level: :warning)
         return
       end
 
diff --git a/sig/gems/sentry.rbs b/sig/gems/sentry.rbs
index 198f223..919beef 100644
--- a/sig/gems/sentry.rbs
+++ b/sig/gems/sentry.rbs
@@ -1,5 +1,7 @@
-# Sentry is reached at exactly two points: the exception-capturing
-# middleware web.rb mounts, and the message the not_found handler sends.
+# Sentry is reached wherever a handler has something to report: the
+# exception-capturing middleware web.rb mounts, the 404 and arrival
+# messages web.rb and store.rb send, and the photo decode refusal
+# contact.rb captures.
 #
 # Events are left untyped. SentryScrubber walks whatever before_send
 # hands it, feature-detecting as it goes, and typing that shape would
@@ -7,6 +9,8 @@
 module Sentry
   def self.capture_message: (String message, ?level: Symbol) -> untyped
 
+  def self.capture_exception: (Exception exception, ?level: Symbol) -> untyped
+
   module Rack
     class CaptureExceptions
     end
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index 2e730c7..abeb54e 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -8,6 +8,9 @@ require "pro_tacts/contact"
 require "pro_tacts/vcard"
 
 class ContactTest < Minitest::Test
+  include Sentry::TestHelper
+  include SentryMessages
+
   CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nEND:VCARD\r\n"
 
   STRUCTURED = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;;;\r\n" \
@@ -24,6 +27,16 @@ class ContactTest < Minitest::Test
     ProTacts::Contact.for(id:, stored: ProTacts::VCard.new(vcard), birthday:)
   end
 
+  # The decode's refusal reports (see #photo); the transport this pins
+  # holds it for the assertions below, and teardown clears it.
+  def setup
+    setup_sentry
+  end
+
+  def teardown
+    teardown_sentry_test
+  end
+
   def test_the_etag_hashes_the_card
     assert_equal %("#{Digest::SHA256.hexdigest(CARD)}"), contact.etag
   end
@@ -260,6 +273,31 @@ class ContactTest < Minitest::Test
     assert_nil contact(not_an_image).photo
   end
 
+  # The departure from the accessor's ordinary nils: a value the
+  # strict decoder refuses reports on its way to nil — warning-grade,
+  # bad-input level, no card content — so a PHOTO this server cannot
+  # read is news rather than initials with no why.
+  def test_an_undecodable_photo_reports_the_refusal
+    undecodable = CARD.sub("FN:Aiden\r\n", "PHOTO;ENCODING=b:not base64!!\r\n")
+
+    assert_nil contact(undecodable).photo
+
+    assert_equal 1, sentry_events.length
+    event = sentry_events.fetch(0)
+    assert_equal :warning, event.level
+    assert_equal "ArgumentError", event.exception.values.first.type
+  end
+
+  # The other nils stay quiet: an image that decodes but sniffs to no
+  # known format is an allowlist gap with no exception to name, an
+  # ordinary absence exactly like no PHOTO at all.
+  def test_a_photo_of_no_known_format_stays_quiet
+    unknown = CARD.sub("FN:Aiden\r\n", "PHOTO;ENCODING=b:#{Base64.strict_encode64("RIFF____WEBP")}\r\n")
+
+    assert_nil contact(unknown).photo
+    assert_empty sentry_events
+  end
+
   # One PHOTO property carrying a PNG payload, for the sniff's rows.
   def photo_property(payload)
     "PHOTO;ENCODING=b;TYPE=JPEG:#{Base64.strict_encode64(payload)}\r\n"