birthdays: carry unrenderable ones across a rewrite
macOS Contacts drops BDAY lines it cannot render from every card it
writes, so put reading a no-BDAY rewrite as a deletion was losing
birthdays the client never showed. The carry is gated on shape, not
spelling — the reduced no-year forms macOS renders stay deletable.
Assisted-by: GLM-5.3 via pi
diff --git a/docs/plans/2026-09-01-birthdays-across-a-rewrite.md b/docs/plans/2026-09-01-birthdays-across-a-rewrite.md
new file mode 100644
index 0000000..5ae276b
--- /dev/null
+++ b/docs/plans/2026-09-01-birthdays-across-a-rewrite.md
@@ -0,0 +1,94 @@
+# Unrenderable birthdays ride across a rewrite
+
+2026-09-01. macOS Contacts renders two of RFC 6350 section 4.3.1's six
+birthday shapes: a full date, and a month with a day. For the rest —
+year and month, year alone, month alone, day alone — it shows nothing,
+and the next card it writes carries no BDAY at all. Editing an
+unrelated field is enough; all three observed losses came from adding
+a note.
+
+## The old deletion rule guarded the wrong half
+
+`Store#put` treated a submitted card with no BDAY as a deletion, with
+one exception: the model row was kept when it held a shape no client
+was served (`existing && !existing.served?`). That guards the model's
+half. But a birthday in an unmodeled *spelling* never becomes a model
+row — `Birthday.subtract` leaves the line in the card verbatim, per
+RFC 6352 section 6.3.2.2 — and there is nothing in the birthdays table
+to fall back on. The client rewrites the card, and the only copy goes
+with it. The rule protected the shapes the model holds and lost the
+ones the card holds, which is backwards: the card-held ones are
+exactly those no client can round-trip.
+
+They also cannot simply move into the birthdays table. The model has
+no spelling that `compose` would put back on the wire for these
+shapes, so a promoted birthday would vanish from every served card
+instead of surviving in one — a read-side regression dressed as a fix.
+
+## The divider is the shape, not the spelling
+
+A rewrite with no BDAY means two different things, and "did the
+client render this birthday" decides between them:
+
+- macOS renders the unmodeled no-year spellings too (`--0412`,
+ `--04-12`), so a rewrite that omits one is a user removing a
+ birthday they could see. That deletion is honored.
+- The four unserved shapes render nothing, so no client can have
+ removed them. `put` carries those lines across the rewrite — the
+ new stored card is the submission plus the stored BDAY lines,
+ verbatim with their folds.
+
+The carried spellings are a whitelist (`Birthday::UNRENDERED_VALUES`):
+the four partial shapes, dashed as RFC 6350 section 4.3.1's examples
+spell them, components in range. A value off the list — served in
+any spelling, unpadded, out of range, unrecognizable — dies with the
+rewrite that drops it, so garbage never becomes immortal. The day
+`to_line` learns a verified spelling for one of the four — the
+month-alone probe is still unrun — is the day its spelling leaves
+the list; the tests pin the correspondence.
+
+## What nobody recognizes is reported, not swallowed
+
+A BDAY line that parses to a value neither modelable, rendered, nor
+carried is unexpected input, and it meets two quiet fates: stored
+verbatim with no one told (RFC 6352 section 6.3.2.2 keeps it, but
+keeping is not reporting), or dropped by a rewrite that could not
+have shown it. Both moments report to Sentry — the arrival in
+`Store#put`'s split, the loss in its carry branch. The messages carry
+no card content, per SentryScrubber's line: the values stay on the
+machine, where the admin view shows them raw.
+
+The classifiers themselves cannot raise — the whitelist matches or
+does not, and `from_property`'s out-of-range rescue is what read
+paths depend on — so there is no error to swallow on the way to a
+classification. What is left is the classification, and the
+unrecognized one is never silent.
+
+## Where the layers sit
+
+The surgery is not the model's. `Birthday` is a partial date and the
+property-level reading of one (`from_property`, `to_line`, the
+whitelist, the rendered set); `VCard` is the card — parsed beside its
+bytes, Enumerable over its lines, one verb for taking a property's
+lines and one for putting lines back; and `Store` owns the policy —
+the deletion rule, the carry, and both reports — over the two.
+Migration 002 spells its own split out against `VCard` and `Birthday`
+directly, so what it did to databases that already ran it cannot
+drift with the app's internals.
+
+## The cost, taken deliberately
+
+A birthday no client renders is now client-undeletable, the same
+terms the model-row half of the rule already had: a PUT carrying any
+BDAY replaces it, and deleting the contact removes it, but a rewrite
+alone never will. The alternative is the silent loss the probe
+recorded, which is worse for a family address book — the birthdays
+these shapes hold are the partial ones ("1985, maybe April") that
+nowhere else in the card is recorded.
+
+## The wire consequences
+
+The carried card is not the submitted bytes, so the PUT answer omits
+the strong ETag (RFC 6352 section 6.3.2.3) and the client refetches —
+which is what shows it the birthday survived its edit. The change log
+and ctag move with the composed card as they already did.
diff --git a/lib/pro_tacts/birthday.rb b/lib/pro_tacts/birthday.rb
index 4f60289..67a8510 100644
--- a/lib/pro_tacts/birthday.rb
+++ b/lib/pro_tacts/birthday.rb
@@ -18,9 +18,10 @@ module ProTacts
# with all three components required, and RFC 2426 section 3.1.5 builds
# BDAY on it. So a birthday is database state that no stored card
# carries, composed into the served card on read and subtracted out of
- # a submitted one on write — Store owns that surgery, over the line
- # rules VCard provides and the property-level reading below; the full
- # reasoning is docs/plans/2026-08-31-partial-birthdays.md.
+ # a submitted one on write — Store owns that surgery, over the card
+ # VCard reads and the property-level reading below; the full reasoning
+ # is docs/plans/2026-08-31-partial-birthdays.md and
+ # docs/plans/2026-09-01-birthdays-across-a-rewrite.md.
#
# The signature lives in sig/pro_tacts/birthday.rbs: a Data class has
# no constant super class for the inline syntax to read.
@@ -42,6 +43,30 @@ module ProTacts
OMIT_YEAR_DATE = /\A1604-(\d{2})-(\d{2})\z/ #: Regexp
OMIT_YEAR = "1604" #: String
+ # The BDAY values no client renders, spelled whole: the four
+ # partial shapes #to_line has no form for, dashed as RFC 6350
+ # section 4.3.1's examples spell them, components in range, each
+ # pattern anchored alone so no edit to one can loosen another's.
+ # A value off the list is not carried across a rewrite, by the
+ # same probe: the served shapes in any spelling were visible, so
+ # their absence is a deletion, and an unrecognized or out-of-range
+ # value is cleaner dying with the rewrite than living forever
+ # (docs/macos-contacts.md, "A birthday the client cannot render is
+ # dropped from the card").
+ UNRENDERED_VALUES = [
+ /\A\d{4}-(?:0[1-9]|1[0-2])\z/, # year and month
+ /\A\d{4}\z/, # year alone
+ /\A--(?:0[1-9]|1[0-2])\z/, # month alone
+ /\A---(?:0[1-9]|[12]\d|3[01])\z/, # day alone
+ ] #: Array[Regexp]
+
+ # The reduced no-year values macOS reads on a card it did not write
+ # and converts to the sentinel on its own
+ # (docs/macos-contacts.md, "Birthdays without a year"). Together
+ # with #from_property's two spellings this is the rendered set: a
+ # BDAY a client could see, and so could have deleted.
+ REDUCED_DATE = /\A--(?:0[1-9]|1[0-2])-?(?:0[1-9]|[12]\d|3[01])\z/ #: Regexp
+
# The only constructor, so no birthday exists in a shape the grammar
# rejects: one `in` clause per shape RFC 6350 section 4.3.1 admits,
# each component's range standing where the component stands, so the
@@ -73,6 +98,8 @@ module ProTacts
# full date, and Apple's 1604 sentinel for a month and day. The other
# four shapes have no verified spelling and reach no client — this
# returns nil for them and no card grows a BDAY on their behalf.
+ # UNRENDERED_VALUES is exactly those four shapes' spellings, so
+ # widening this means narrowing that.
def to_line
if year && month && day
format("BDAY:%04d-%02d-%02d", year, month, day)
@@ -119,5 +146,26 @@ module ProTacts
name, value = property.parameters.fetch(0)
name.casecmp?("X-APPLE-OMIT-YEAR") && value == OMIT_YEAR
end
+
+ # Whether a BDAY value is one no client renders — the spellings a
+ # rewrite carries across, so the birthday survives a client that
+ # drops what it cannot display. A predicate rather than a reader:
+ # nothing downstream wants the shape as a model, and a whitelist
+ # cannot raise on what it does not recognize.
+ def self.unrendered_value?(value)
+ UNRENDERED_VALUES.any? { value.match?(it) }
+ end
+
+ # Whether a client renders this BDAY property as a birthday: one of
+ # the two spellings #to_line emits (#from_property reads exactly
+ # those), or a reduced no-year value macOS additionally reads. A
+ # client that renders a BDAY can have deleted it, so its absence
+ # from a rewrite is a removal; a property that is neither rendered
+ # nor carried is neither, and the rewrite that drops it reports
+ # the loss — Store#put, to Sentry.
+ def self.rendered?(property)
+ !from_property(property).nil? ||
+ (property.group.nil? && property.parameters.empty? && property.value.match?(REDUCED_DATE))
+ end
end
end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 7bac3f6..152950f 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -1,10 +1,11 @@
-
require "digest"
require "pathname"
require "sequel"
+require "sentry-ruby"
require "pro_tacts/birthday"
require "pro_tacts/contact"
+require "pro_tacts/vcard"
require "pro_tacts/vcard/parser"
Sequel.extension :migration
@@ -188,23 +189,18 @@ module ProTacts
#
# What is stored is the card minus its birthday, which moves into
# the birthdays table — vCard 3.0 cannot carry a partial date, so
- # the card never carries BDAY and every read composes it back in
- # (docs/plans/2026-08-31-partial-birthdays.md). The Contact this
- # returns is the composed one, and the logged etag is its hash, so
- # a client's token describes the card it downloads. A submitted
- # BDAY in a spelling the model does not recompose stays in the
- # card byte for byte and empties the model, keeping RFC 6352
- # section 6.3.2.2's promise; a submitted card with no BDAY at all
- # deletes the birthday only when the model held a shape a client
- # could have seen, because one it never served is not something
- # the client could have removed.
+ # no stored card carries the model's BDAY and every read composes
+ # it back in (docs/plans/2026-08-31-partial-birthdays.md). The
+ # Contact this returns is the composed one, and the logged etag is
+ # its hash, so a client's token describes the card it downloads.
#
# The strings are UTF-8 by contract, and the adapter holds the store
# to it: the sqlite3 gem encodes every bound value to UTF-8, so a
# binary-flagged byte above 7 bits raises at the bind, and bytes
# that are not UTF-8 at all stop at the insert, which SQLite refuses
# to store as text. The body — the one binary input — is relabelled
- # where it is read, in write_card.
+ # where it is read, in write_card, and refused at the card itself
+ # before that: VCard raises on bytes that are not text.
#: (String id, String vcard) -> Contact
def put(id, vcard)
# The birthday half of the split a write makes, one arm per shape
@@ -214,21 +210,34 @@ module ProTacts
# line that will not parse, more than one — is data the model
# cannot recompose and stays in the card byte for byte, with the
# model emptied so nothing composes a second BDAY beside it
- # (RFC 6352 section 6.3.2.2). No BDAY at all is nothing to split:
- # the birthday to carry is whatever an unseen model row already
- # holds, and a served one was the user's deletion. Bytes that are
- # not UTF-8 raise at the card before any of this runs — a PUT
- # has already answered them with a 412 by then.
+ # (RFC 6352 section 6.3.2.2). No BDAY at all is a client's
+ # rewrite, and macOS Contacts drops the BDAY lines it cannot
+ # render from every card it writes (docs/macos-contacts.md):
+ # what the stored card held in a spelling no client renders
+ # rides across the rewrite, what it held that a client could
+ # see was the user's deletion, an unseen model row survives as
+ # nothing a client ever saw, and what nobody recognizes is
+ # reported rather than lost in silence.
existing = birthday_of(id)
birthday, stored =
case VCard.new(vcard).partition { it.names?("BDAY") }
in [[line], others]
+ report_unrecognized_bday_lines([line])
property = line.property
birthday = property && Birthday.from_property(property)
[birthday, birthday ? others.map(&:verbatim).join : vcard]
in [[], _]
- [existing && !existing.served? ? existing : nil, vcard]
- in [_, _]
+ # The rewrite arm: carry the unrendered lines out of the
+ # stored card, report the unrecognized ones' loss, and keep
+ # whatever an unseen model row holds.
+ carried, lost = carried_and_lost_bday_lines(stored_vcard(id))
+ kept = existing && !existing.served? ? existing : nil
+ report_lost_bday_lines(lost)
+ [kept, VCard.new(vcard).insert(carried)]
+ in [lines, _]
+ # More than one BDAY: cardinality-broken data, kept verbatim
+ # and reported like any other unrecognized line.
+ report_unrecognized_bday_lines(lines)
[nil, vcard]
end
@@ -361,6 +370,64 @@ module ProTacts
Sequel::Migrator.run(@database, MIGRATIONS.to_s)
end
+ # The card currently stored for an id, or nil for a card being
+ # created. A plain `first` read: the primary key leaves `sole`
+ # nothing to catch, and no row is the ordinary answer.
+ #: (String id) -> String?
+ def stored_vcard(id)
+ row = cards.where(id:).first
+ row && row.fetch(:vcard).to_s
+ end
+
+ # A stored card's BDAY lines in two piles: the verbatim lines a
+ # rewrite carries across — values no client renders — and the ones
+ # it drops unwitnessed, which no client renders and no whitelist
+ # recognizes. Lines a client rendered are in neither pile: their
+ # absence is a deletion the rewrite already honors.
+ #: (String? vcard) -> [Array[String], Array[VCard::Line]]
+ def carried_and_lost_bday_lines(vcard)
+ return [[], []] if vcard.nil?
+
+ bdays, = VCard.new(vcard).partition { it.names?("BDAY") }
+ carried, rest = bdays.partition { |line|
+ line.property && Birthday.unrendered_value?(line.property.value)
+ }
+ lost = rest.reject { |line| line.property && Birthday.rendered?(line.property) }
+ [carried.map(&:verbatim), lost]
+ end
+
+ # The arrival report: a BDAY line this server can neither model,
+ # recognize as rendered, nor recognize as carried is unexpected
+ # input, and storing it verbatim would be the last anyone heard of
+ # it. The message carries no card content
+ # (ProTacts::SentryScrubber's line); the values stay on the
+ # machine, where the admin view shows them raw.
+ #: (Array[VCard::Line] lines) -> void
+ def report_unrecognized_bday_lines(lines)
+ unrecognized = lines.count { |line|
+ line.property.nil? || (!Birthday.rendered?(line.property) && !Birthday.unrendered_value?(line.property.value))
+ }
+ return if unrecognized.zero?
+
+ Sentry.capture_message(
+ "a submitted card carried #{unrecognized} BDAY line(s) no client renders and no whitelist recognizes",
+ level: :warning,
+ )
+ end
+
+ # The loss report, the rewrite's half of the arrival one: a stored
+ # BDAY no client renders and no whitelist recognizes is about to be
+ # dropped, and nobody would know.
+ #: (Array[VCard::Line] lines) -> void
+ def report_lost_bday_lines(lines)
+ return if lines.empty?
+
+ Sentry.capture_message(
+ "a rewrite dropped #{lines.length} BDAY line(s) no client renders and no whitelist carries",
+ level: :warning,
+ )
+ end
+
# The etag comes back out of the card rather than out of a column, so
# a row can never carry one that disagrees with the bytes beside it.
# The card is composed with its birthday first, so both halves
diff --git a/sig/pro_tacts/birthday.rbs b/sig/pro_tacts/birthday.rbs
index a73636a..aec6546 100644
--- a/sig/pro_tacts/birthday.rbs
+++ b/sig/pro_tacts/birthday.rbs
@@ -6,6 +6,8 @@ module ProTacts
FULL_DATE: Regexp
OMIT_YEAR_DATE: Regexp
OMIT_YEAR: String
+ UNRENDERED_VALUES: Array[Regexp]
+ REDUCED_DATE: Regexp
attr_reader year: Integer?
attr_reader month: Integer?
@@ -20,5 +22,9 @@ module ProTacts
def self.from_property: (VCard::Property property) -> Birthday?
def self.apple_no_year?: (VCard::Property property) -> bool
+
+ def self.unrendered_value?: (String value) -> bool
+
+ def self.rendered?: (VCard::Property property) -> bool
end
end
diff --git a/test/pro_tacts/test_birthday.rb b/test/pro_tacts/test_birthday.rb
index b256673..00433dd 100644
--- a/test/pro_tacts/test_birthday.rb
+++ b/test/pro_tacts/test_birthday.rb
@@ -21,7 +21,7 @@ class BirthdayTest < Minitest::Test
error = assert_raises(ArgumentError) { ProTacts::Birthday.new(year: 1985, day: 12) }
assert_match "not a shape", error.message
- assert_raises(ArgumentError) { ProTacts::Birthday.new() }
+ assert_raises(ArgumentError) { ProTacts::Birthday.new }
end
def test_components_out_of_range_are_refused
@@ -98,7 +98,8 @@ class BirthdayTest < Minitest::Test
end
# A component out of range is unmodeled, not exceptional: from_property
- # reads nil and the line stays wherever it was.
+ # reads nil and the line stays wherever it was. Store reports the value
+ # as unrecognized, so the swallow is not silent.
def test_an_out_of_range_date_reads_as_nil
assert_nil ProTacts::Birthday.from_property(property("1985-13-40"))
end
@@ -115,4 +116,47 @@ class BirthdayTest < Minitest::Test
assert_nil ProTacts::Birthday.from_property(unmodeled), unmodeled.value
end
end
+
+ ## the values no client renders
+
+ def test_unrendered_value_accepts_the_four_shapes_spellings
+ ["1985-04", "1985", "--04", "---12"].each do |value|
+ assert ProTacts::Birthday.unrendered_value?(value), value
+ end
+ end
+
+ # The rendered set matches none of the four, and a value the grammar
+ # will not read matches nothing. The padded pair pins the anchors —
+ # each pattern is anchored alone, so no spelling matches inside a
+ # longer or padded value.
+ def test_unrendered_value_refuses_everything_else
+ ["1985-04-12", "--0412", "--04-12", "19850412", "1985-4", "1985-13", "--00", "", "x1985", "1985-04\n"].each do |value|
+ refute ProTacts::Birthday.unrendered_value?(value), value
+ end
+ end
+
+ ## the values a client renders
+
+ # from_property's two spellings, the reduced no-year values macOS
+ # additionally reads, and their case: a client that renders a BDAY
+ # can have deleted it, so its absence from a rewrite is a removal.
+ def test_rendered_reads_the_spellings_clients_show
+ [property("1985-04-12"), property("1985-04-12T23:10:00Z"),
+ property("1604-04-12", parameters: [["X-APPLE-OMIT-YEAR", "1604"]]),
+ property("--0412"), property("--04-12")].each do |rendered|
+ assert ProTacts::Birthday.rendered?(rendered), rendered.value
+ end
+ end
+
+ # What no client renders — the carried shapes, the out-of-range, the
+ # unrecognizable — is not rendered, and a rewrite dropping it is
+ # something Store reports rather than a deletion it honors.
+ def test_rendered_refuses_what_no_client_shows
+ [property("1985-04"), property("1985"), property("--04"), property("---12"),
+ property("1985-13"), property("--0432"), property("19850412"),
+ property("banana"),
+ property("--0412", group: "item1")].each do |unrendered|
+ refute ProTacts::Birthday.rendered?(unrendered), unrendered.value
+ end
+ end
end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index f7c874e..443855c 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -15,6 +15,23 @@ class StoreTest < Minitest::Test
# A store on disk rather than in memory: WAL, the busy timeout, and
# reopening a database are all part of what is under test.
+ # minitest 6 ships no mock, so the Sentry capture is stubbed by hand:
+ # the bound original is swapped back in an ensure, so one assertion
+ # cannot leak into the next. Sentry is never initialized under test,
+ # so the original is inert anyway — this only makes it observable.
+ # Returns what was captured.
+ def capturing_sentry
+ messages = []
+ original = Sentry.method(:capture_message)
+ Sentry.define_singleton_method(:capture_message) { |message, **| messages << message }
+ begin
+ yield messages
+ ensure
+ Sentry.define_singleton_method(:capture_message, original)
+ end
+ messages
+ end
+
def with_store(cards = {})
Dir.mktmpdir do |dir|
ProTacts::Store.connect(Pathname.new(dir) / "contacts.db") do |store|
@@ -526,6 +543,110 @@ class StoreTest < Minitest::Test
end
end
+ # The card's half of the same rule. A BDAY in a shape no client
+ # renders lives in the card — the model has no spelling for it — and
+ # macOS Contacts drops those lines from every card it writes, so a
+ # rewrite that omits the BDAY carries them across or loses them
+ # (docs/macos-contacts.md, "A birthday the client cannot render is
+ # dropped from the card").
+ def test_a_birthday_no_client_renders_survives_a_rewrite_that_drops_it
+ ["BDAY:1985-04", "BDAY:1985", "BDAY:--04", "BDAY:---12"].each do |line|
+ with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n")}) do |store|
+ edited = AIDEN.sub("FN:Aiden", "FN:Aiden Smith")
+
+ store.put("aiden", edited)
+
+ assert_equal edited.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n"), store.contact("aiden").vcard, line
+ assert_nil birthday_row(store, "aiden"), line
+ end
+ end
+ end
+
+ # The divider is the shape, not the spelling: macOS renders --0412,
+ # so a rewrite without it has removed a birthday the client could
+ # see, and the deletion is honored. Honoring it is what keeps these
+ # deletable at all.
+ def test_a_birthday_a_client_renders_is_deleted_by_a_rewrite_without_it
+ with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "BDAY:--0412\r\nEND:VCARD\r\n")}) do |store|
+ store.put("aiden", AIDEN)
+
+ assert_equal AIDEN, store.contact("aiden").vcard
+ assert_nil birthday_row(store, "aiden")
+ end
+ end
+
+ # A submission carrying any BDAY replaces what was there, carried
+ # line included: PUT is a whole-card replace, not a merge.
+ def test_a_submitted_birthday_replaces_the_carried_line
+ with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04\r\nEND:VCARD\r\n")}) do |store|
+ born = AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n")
+
+ store.put("aiden", born)
+
+ assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), birthday_row(store, "aiden")
+ assert_equal born, store.contact("aiden").vcard
+ end
+ end
+
+ # The loss report, the rewrite's half of the arrival one: a stored
+ # BDAY no client renders and no whitelist recognizes is about to be
+ # dropped, and nobody would know.
+ def test_a_rewrite_dropping_an_unrecognized_bday_is_reported
+ with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "BDAY:1985-13\r\nEND:VCARD\r\n")}) do |store|
+ messages = capturing_sentry {
+ store.put("aiden", AIDEN.sub("FN:Aiden", "FN:Aiden Smith"))
+ }
+
+ assert_equal 1, messages.length
+ assert_match /BDAY/, messages.fetch(0)
+ end
+ end
+
+ # A rewrite's quiet cases: a carried line survives (asserted above),
+ # a rendered one was deleted by a user who could see it, and a card
+ # with no BDAY at all has nothing to say.
+ def test_a_rewrite_over_known_bdays_stays_quiet
+ ["BDAY:--0412", "BDAY:1985-04"].each do |line|
+ with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n")}) do |store|
+ messages = capturing_sentry {
+ store.put("aiden", AIDEN.sub("FN:Aiden", "FN:Aiden Smith"))
+ }
+
+ assert_empty messages, line
+ end
+ end
+ end
+
+ # The arrival report: a submitted BDAY this server can neither model,
+ # recognize as rendered, nor recognize as carried is unexpected input,
+ # and storing it verbatim would be the last anyone heard of it.
+ # Known forms — the modeled spellings, the reduced values macOS
+ # reads, the carried shapes — stay quiet.
+ def test_an_unrecognized_bday_arriving_is_reported
+ ["BDAY:1985-13", "BDAY:--0432", "BDAY:19850412", "BDAY:1985-4"].each do |line|
+ with_store({}) do |store|
+ messages = capturing_sentry {
+ store.put("aiden", AIDEN.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n"))
+ }
+
+ assert_equal 1, messages.length, line
+ end
+ end
+ end
+
+ def test_a_bday_the_server_knows_arrives_quietly
+ ["BDAY:1985-04-12", "BDAY:1985-04-12T23:10:00Z", "BDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12",
+ "BDAY:--0412", "BDAY:--04-12", "BDAY:1985-04", "BDAY:1985"].each do |line|
+ with_store({}) do |store|
+ messages = capturing_sentry {
+ store.put("aiden", AIDEN.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n"))
+ }
+
+ assert_empty messages, line
+ end
+ end
+ end
+
# The card is made of text and says so itself: bytes that are not
# UTF-8 raise at the card, before any walk — the web's PUT has
# already answered them with a 412 by the time one reaches a caller
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 85e933c..b942ddd 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -328,6 +328,27 @@ class WebTest < Minitest::Test
end
end
+ # The same omission for the rewrite that carries a birthday across:
+ # the stored card is the submission plus the BDAY line macOS dropped,
+ # so the answer cannot claim the tag — the client refetches, and the
+ # refetch is what shows the birthday survived its edit.
+ def test_a_put_that_carries_a_birthday_back_goes_without_the_strong_etag
+ unrendered = card("new", "New").sub("END:VCARD\r\n", "BDAY:1985-04\r\nEND:VCARD\r\n")
+
+ with_contacts({}) do
+ put_request "new", unrendered, "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
+
+ edited = card("new", "New Smith")
+ put_request "new", edited, "CONTENT_TYPE" => VCARD
+
+ assert_equal 204, last_response.status
+ assert_nil last_response["ETag"]
+
+ get "/dav/addressbook/new.vcf"
+ assert_includes last_response.body, "BDAY:1985-04\r\nEND:VCARD"
+ end
+ end
+
# The preconditions of RFC 6352 section 6.3.2.1, marshalled as 412s
# in the DAV:error form RFC 4918 section 16 defines.
def test_put_of_a_non_vcard_media_type_names_its_precondition