fix(contacts): close PR #719 second-pass review gaps

- _merge_vcard_properties no longer silently drops the existing EMAIL /
  TEL line when contact_data supplies a dict/list shape: the input is
  unhandled by the text merge, so the original line is preserved
  instead of being consumed and replaced with nothing.
- Extracted _parse_bday so the update path validates ISO format the
  same way create does. Invalid → keep existing BDAY line (or skip on
  add-new) rather than writing a malformed one.
- Added _safe_vcard_value to escape newlines per RFC 6350 §3.4 at every
  interpolation site in _merge_vcard_properties, blocking value-driven
  property injection (e.g. NOTE: containing a literal \n + EMAIL:).
- Removed dead "organization" alias references from _merge_vcard_properties:
  unreachable since update_contact normalises before calling.
- New regression tests pin all four behaviours (dict-email preserves
  existing line, list-tel ditto, invalid-bday-update preserves original,
  invalid-bday-add-new is dropped, newline-in-note no injection).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-04-26 02:24:16 +02:00
co-authored by Claude Opus 4.7
parent e2283ff28c
commit e1c776716c
2 changed files with 151 additions and 40 deletions
+59
View File
@@ -273,3 +273,62 @@ class TestMergeVcardProperties:
assert "ORG:Acme" in result
assert "TEL:555-1234" in result
assert "NOTE:keep me" in result
def test_dict_email_input_preserves_existing_line(self):
"""Regression: a dict-form email on update used to consume the existing
EMAIL: line and write nothing, silently deleting the contact's email.
Now the original line is preserved when the input shape isn't a plain str.
"""
existing = (
"BEGIN:VCARD\nVERSION:3.0\nUID:merge-test\nFN:Alice\n"
"EMAIL;TYPE=HOME:alice@example.com\nEND:VCARD\n"
)
result = self._merge(
existing, {"email": {"value": "work@example.com", "type": ["WORK"]}}
)
assert "EMAIL;TYPE=HOME:alice@example.com" in result
def test_list_tel_input_preserves_existing_line(self):
"""Same regression as the email branch but for TEL — a list-shaped tel
input must not silently drop the existing phone number.
"""
existing = (
"BEGIN:VCARD\nVERSION:3.0\nUID:merge-test\nFN:Alice\n"
"TEL;TYPE=HOME:555-0001\nEND:VCARD\n"
)
result = self._merge(
existing, {"tel": [{"value": "555-9999", "type": ["WORK"]}]}
)
assert "TEL;TYPE=HOME:555-0001" in result
def test_invalid_bday_on_update_preserves_existing_line(self):
"""A non-ISO BDAY string must not produce a malformed vCard line on update.
We share validation with the create path; invalid → keep the existing line.
"""
existing = (
"BEGIN:VCARD\nVERSION:3.0\nUID:merge-test\nFN:Alice\n"
"BDAY:1990-05-01\nEND:VCARD\n"
)
result = self._merge(existing, {"bday": "not-a-date"})
assert "BDAY:1990-05-01" in result
assert "BDAY:not-a-date" not in result
def test_invalid_bday_on_add_new_is_dropped(self):
"""No existing BDAY + invalid input → no BDAY line appended (vs. raw write)."""
existing = "BEGIN:VCARD\nVERSION:3.0\nUID:merge-test\nFN:Alice\nEND:VCARD\n"
result = self._merge(existing, {"bday": "not-a-date"})
assert "BDAY" not in result
def test_newline_in_note_does_not_inject_property(self):
"""Regression: a literal newline in a value must not terminate the line
and inject a fresh vCard property.
"""
existing = "BEGIN:VCARD\nVERSION:3.0\nUID:merge-test\nFN:Alice\nEND:VCARD\n"
result = self._merge(
existing, {"note": "harmless\nEMAIL:attacker@evil.example"}
)
# The injected property must not appear as a real EMAIL line.
lines = result.splitlines()
assert "EMAIL:attacker@evil.example" not in lines
# The note value is preserved with newlines escaped per RFC 6350.
assert any(line.startswith("NOTE:") and "\\n" in line for line in lines)