Skip to content

Commit 871e53e

Browse files
Backend/requests: Fix malformed ics attachment (#8646)
1 parent e9e46c2 commit 871e53e

2 files changed

Lines changed: 62 additions & 23 deletions

File tree

app/backend/src/couchers/email/calendar_events.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,15 @@ def create_host_request_ics(
3232

3333

3434
def create_host_request_event(
35-
host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
35+
host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext, sequence: int = 0
3636
) -> Event:
3737
"""Creates an ics event for a host request."""
3838

3939
event = Event()
4040
event.uid = get_host_request_event_uid(host_request.host_request_id)
4141

4242
# Explicitly allow later sequencing of a cancellation with SEQUENCE:1
43-
event.extra.append(ContentLine(name="SEQUENCE", value="0"))
43+
event.extra.append(ContentLine(name="SEQUENCE", value=str(sequence)))
4444

4545
if hosting:
4646
event.name = get_emails_i18next().localize(
@@ -76,15 +76,12 @@ def create_host_request_cancellation_attachment(
7676
def create_host_request_cancellation_ics(
7777
host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
7878
) -> str:
79-
event = create_host_request_event(host_request, other_name, hosting, loc_context)
79+
event = create_host_request_event(host_request, other_name, hosting, loc_context, sequence=1)
8080
event.name = get_emails_i18next().localize(
8181
"calendar_events.title_cancelled", loc_context.locale, {"title": event.name}
8282
)
8383
event.status = "CANCELLED"
8484

85-
# Sequence cancellation is after creation (which uses SEQUENCE:0)
86-
event.extra.append(ContentLine(name="SEQUENCE", value="1"))
87-
8885
# METHOD:PUBLISH means this is part of a stream of calendar event information.
8986
# Gmail™ will immediately remove the event from the user's calendar.
9087
# METHOD:CANCEL might leave the event in cancelled state or not work.

app/backend/src/tests/test_calendar_events.py

Lines changed: 59 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44
import ics
55
import pytest
66

7-
from couchers.email.calendar_events import create_host_request_ics
7+
from couchers.email.calendar_events import create_host_request_cancellation_ics, create_host_request_ics
88
from couchers.i18n.context import LocalizationContext
99
from couchers.proto import conversations_pb2, requests_pb2
1010
from couchers.proto.requests_pb2 import HostRequest
1111
from couchers.utils import today
1212
from tests.fixtures.db import generate_user
13-
from tests.fixtures.misc import email_fields, mock_notification_email
13+
from tests.fixtures.misc import Moderator, email_fields, mock_notification_email
1414
from tests.fixtures.sessions import requests_session
1515
from tests.test_requests import valid_request_text
1616

@@ -20,47 +20,79 @@ def _(testconfig):
2020
pass
2121

2222

23-
def test_ics_content():
23+
def test_initial_ics_content():
2424
host_request = HostRequest(
2525
host_request_id=42, from_date="2000-01-01", to_date="2000-01-02", hosting_city="New York"
2626
)
2727

2828
ics = create_host_request_ics(
2929
host_request, other_name="Bob", hosting=True, loc_context=LocalizationContext.en_utc()
3030
)
31-
ics = ics.replace("\r\n", "\n")
31+
assert _normalize_ics(ics) == _normalize_ics("""
32+
BEGIN:VCALENDAR
33+
VERSION:2.0
34+
PRODID:-//Couchers.org//Couchers//EN
35+
BEGIN:VEVENT
36+
SEQUENCE:0
37+
DTSTART;VALUE=DATE:20000101
38+
DTEND;VALUE=DATE:20000103
39+
DESCRIPTION:<stripped>/42
40+
LOCATION:New York
41+
SUMMARY:Hosting Bob
42+
UID:host_request.42@<stripped>
43+
URL:<stripped>/42
44+
END:VEVENT
45+
METHOD:PUBLISH
46+
END:VCALENDAR
47+
""")
3248

33-
# Strip the domain in the UID, which depends on environment variables
34-
ics = re.sub(
35-
r"^UID:.*@(.*)$", lambda match: match[0].removesuffix(match[1]) + "<stripped>", ics, flags=re.MULTILINE
36-
)
3749

38-
# Strip the domain in the URL and DESCRIPTION, which depends on environment variables
39-
ics = re.sub(r"^(DESCRIPTION|URL):(.*)/(\d+)", r"\1:<stripped>/\3", ics, flags=re.MULTILINE)
50+
def test_cancellation_ics_content():
51+
host_request = HostRequest(
52+
host_request_id=42, from_date="2000-01-01", to_date="2000-01-02", hosting_city="New York"
53+
)
4054

41-
assert (
42-
ics
43-
== """
55+
ics = create_host_request_cancellation_ics(
56+
host_request, other_name="Bob", hosting=True, loc_context=LocalizationContext.en_utc()
57+
)
58+
assert _normalize_ics(ics) == _normalize_ics("""
4459
BEGIN:VCALENDAR
4560
VERSION:2.0
4661
PRODID:-//Couchers.org//Couchers//EN
4762
BEGIN:VEVENT
48-
SEQUENCE:0
63+
SEQUENCE:1
4964
DTSTART;VALUE=DATE:20000101
5065
DTEND;VALUE=DATE:20000103
5166
DESCRIPTION:<stripped>/42
5267
LOCATION:New York
53-
SUMMARY:Hosting Bob
68+
STATUS:CANCELLED
69+
SUMMARY:Cancelled: Hosting Bob
5470
UID:host_request.42@<stripped>
5571
URL:<stripped>/42
5672
END:VEVENT
5773
METHOD:PUBLISH
5874
END:VCALENDAR
59-
""".strip()
75+
""")
76+
77+
78+
def _normalize_ics(ics: str) -> str:
79+
# Normalize whitespace:
80+
# - The ics library produces '\r\n', in-code literals are '\n'
81+
# - In-code literals have start/end newlines and indentation.
82+
ics = ics.replace("\r\n", "\n").strip()
83+
84+
# Strip the domain in the UID, which depends on environment variables
85+
ics = re.sub(
86+
r"^UID:.*@(.*)$", lambda match: match[0].removesuffix(match[1]) + "<stripped>", ics, flags=re.MULTILINE
6087
)
6188

89+
# Strip the domain in the URL and DESCRIPTION, which depends on environment variables
90+
ics = re.sub(r"^(DESCRIPTION|URL):(.*)/(\d+)", r"\1:<stripped>/\3", ics, flags=re.MULTILINE)
91+
92+
return ics
93+
6294

63-
def test_host_request_attachments(db, moderator):
95+
def test_host_request_attachments(db, moderator: Moderator):
6496
host, host_token = generate_user(complete_profile=True)
6597
surfer, surfer_token = generate_user(complete_profile=True)
6698

@@ -100,6 +132,7 @@ def test_host_request_attachments(db, moderator):
100132
e = email_fields(mock)
101133
assert "accept" in e.subject and host.name in e.subject
102134
ics_event = _get_email_ics_attachment_calendar_event(e)
135+
assert _get_ics_event_sequence(ics_event) == 0
103136
assert not ics_event.status
104137
assert ics_event.begin.date() == from_date
105138
assert ics_event.end.date() == (to_date + timedelta(days=1))
@@ -120,6 +153,7 @@ def test_host_request_attachments(db, moderator):
120153
e = email_fields(mock)
121154
assert "confirm" in e.subject and surfer.name in e.subject
122155
ics_event = _get_email_ics_attachment_calendar_event(e)
156+
assert _get_ics_event_sequence(ics_event) == 0
123157
assert not ics_event.status
124158
assert ics_event.name == f"Hosting {surfer.name}"
125159

@@ -137,6 +171,7 @@ def test_host_request_attachments(db, moderator):
137171
e = email_fields(mock)
138172
assert "cancel" in e.subject and surfer.name in e.subject
139173
ics_event = _get_email_ics_attachment_calendar_event(e)
174+
assert _get_ics_event_sequence(ics_event) == 1
140175
assert ics_event.status == "CANCELLED"
141176

142177

@@ -147,3 +182,10 @@ def _get_email_ics_attachment_calendar_event(e) -> ics.Event:
147182
ics_calendar = ics.Calendar(ics_attachment.data.decode("utf-8"))
148183
assert len(ics_calendar.events) == 1
149184
return next(iter(ics_calendar.events))
185+
186+
187+
def _get_ics_event_sequence(event: ics.Event) -> int | None:
188+
for x in event.extra:
189+
if x.name == "SEQUENCE":
190+
return int(x.value)
191+
return None

0 commit comments

Comments
 (0)