1from datetime import datetime, timedelta, tzinfo
2import re
3
4import pytest
5
6import falcon
7import falcon.testing as testing
8from falcon.util import http_date_to_dt, TimezoneGMT
9from falcon.util.compat import http_cookies
10
11
12UNICODE_TEST_STRING = u'Unicode_\xc3\xa6\xc3\xb8'
13
14
15class TimezoneGMTPlus1(tzinfo):
16
17    def utcoffset(self, dt):
18        return timedelta(hours=1)
19
20    def tzname(self, dt):
21        return 'GMT+1'
22
23    def dst(self, dt):
24        return timedelta(hours=1)
25
26
27GMT_PLUS_ONE = TimezoneGMTPlus1()
28
29
30class CookieResource:
31
32    def on_get(self, req, resp):
33        resp.set_cookie('foo', 'bar', domain='example.com', path='/')
34
35    def on_head(self, req, resp):
36        resp.set_cookie('foo', 'bar', max_age=300)
37        resp.set_cookie('bar', 'baz', http_only=False)
38        resp.set_cookie('bad', 'cookie')
39        resp.unset_cookie('bad')
40
41    def on_post(self, req, resp):
42        e = datetime(year=2050, month=1, day=1)  # naive
43        resp.set_cookie('foo', 'bar', http_only=False, secure=False, expires=e)
44        resp.unset_cookie('bad')
45
46    def on_put(self, req, resp):
47        e = datetime(year=2050, month=1, day=1, tzinfo=GMT_PLUS_ONE)  # aware
48        resp.set_cookie('foo', 'bar', http_only=False, secure=False, expires=e)
49        resp.unset_cookie('bad')
50
51
52class CookieResourceMaxAgeFloatString:
53
54    def on_get(self, req, resp):
55        resp.set_cookie(
56            'foofloat', 'bar', max_age=15.3, secure=False, http_only=False)
57        resp.set_cookie(
58            'foostring', 'bar', max_age='15', secure=False, http_only=False)
59
60
61@pytest.fixture()
62def client():
63    app = falcon.API()
64    app.add_route('/', CookieResource())
65    app.add_route('/test-convert', CookieResourceMaxAgeFloatString())
66
67    return testing.TestClient(app)
68
69
70# =====================================================================
71# Response
72# =====================================================================
73
74
75def test_response_base_case(client):
76    result = client.simulate_get('/')
77
78    cookie = result.cookies['foo']
79    assert cookie.name == 'foo'
80    assert cookie.value == 'bar'
81    assert cookie.domain == 'example.com'
82    assert cookie.http_only
83
84    # NOTE(kgriffs): Explicitly test for None to ensure
85    # falcon.testing.Cookie is returning exactly what we
86    # expect. Apps using falcon.testing.Cookie can be a
87    # bit more cavalier if they wish.
88    assert cookie.max_age is None
89    assert cookie.expires is None
90
91    assert cookie.path == '/'
92    assert cookie.secure
93
94
95def test_response_disable_secure_globally(client):
96    client.app.resp_options.secure_cookies_by_default = False
97    result = client.simulate_get('/')
98    cookie = result.cookies['foo']
99    assert not cookie.secure
100
101    client.app.resp_options.secure_cookies_by_default = True
102    result = client.simulate_get('/')
103    cookie = result.cookies['foo']
104    assert cookie.secure
105
106
107def test_response_complex_case(client):
108    result = client.simulate_head('/')
109
110    assert len(result.cookies) == 3
111
112    cookie = result.cookies['foo']
113    assert cookie.value == 'bar'
114    assert cookie.domain is None
115    assert cookie.expires is None
116    assert cookie.http_only
117    assert cookie.max_age == 300
118    assert cookie.path is None
119    assert cookie.secure
120
121    cookie = result.cookies['bar']
122    assert cookie.value == 'baz'
123    assert cookie.domain is None
124    assert cookie.expires is None
125    assert not cookie.http_only
126    assert cookie.max_age is None
127    assert cookie.path is None
128    assert cookie.secure
129
130    cookie = result.cookies['bad']
131    assert cookie.value == ''  # An unset cookie has an empty value
132    assert cookie.domain is None
133
134    assert cookie.expires < datetime.utcnow()
135
136    # NOTE(kgriffs): I know accessing a private attr like this is
137    # naughty of me, but we just need to sanity-check that the
138    # string is GMT.
139    assert cookie._expires.endswith('GMT')
140
141    assert cookie.http_only
142    assert cookie.max_age is None
143    assert cookie.path is None
144    assert cookie.secure
145
146
147def test_cookie_expires_naive(client):
148    result = client.simulate_post('/')
149
150    cookie = result.cookies['foo']
151    assert cookie.value == 'bar'
152    assert cookie.domain is None
153    assert cookie.expires == datetime(year=2050, month=1, day=1)
154    assert not cookie.http_only
155    assert cookie.max_age is None
156    assert cookie.path is None
157    assert not cookie.secure
158
159
160def test_cookie_expires_aware(client):
161    result = client.simulate_put('/')
162
163    cookie = result.cookies['foo']
164    assert cookie.value == 'bar'
165    assert cookie.domain is None
166    assert cookie.expires == datetime(year=2049, month=12, day=31, hour=23)
167    assert not cookie.http_only
168    assert cookie.max_age is None
169    assert cookie.path is None
170    assert not cookie.secure
171
172
173def test_cookies_setable(client):
174    resp = falcon.Response()
175
176    assert resp._cookies is None
177
178    resp.set_cookie('foo', 'wrong-cookie', max_age=301)
179    resp.set_cookie('foo', 'bar', max_age=300)
180    morsel = resp._cookies['foo']
181
182    assert isinstance(morsel, http_cookies.Morsel)
183    assert morsel.key == 'foo'
184    assert morsel.value == 'bar'
185    assert morsel['max-age'] == 300
186
187
188@pytest.mark.parametrize('cookie_name', ('foofloat', 'foostring'))
189def test_cookie_max_age_float_and_string(client, cookie_name):
190    # NOTE(tbug): Falcon implicitly converts max-age values to integers,
191    # to ensure RFC 6265-compliance of the attribute value.
192
193    result = client.simulate_get('/test-convert')
194
195    cookie = result.cookies[cookie_name]
196    assert cookie.value == 'bar'
197    assert cookie.domain is None
198    assert cookie.expires is None
199    assert not cookie.http_only
200    assert cookie.max_age == 15
201    assert cookie.path is None
202    assert not cookie.secure
203
204
205def test_response_unset_cookie(client):
206    resp = falcon.Response()
207    resp.unset_cookie('bad')
208    resp.set_cookie('bad', 'cookie', max_age=300)
209    resp.unset_cookie('bad')
210
211    morsels = list(resp._cookies.values())
212    len(morsels) == 1
213
214    bad_cookie = morsels[0]
215    bad_cookie['expires'] == -1
216
217    output = bad_cookie.OutputString()
218    assert 'bad=;' in output or 'bad="";' in output
219
220    match = re.search('expires=([^;]+)', output)
221    assert match
222
223    expiration = http_date_to_dt(match.group(1), obs_date=True)
224    assert expiration < datetime.utcnow()
225
226
227def test_cookie_timezone(client):
228    tz = TimezoneGMT()
229    assert tz.tzname(timedelta(0)) == 'GMT'
230
231
232# =====================================================================
233# Request
234# =====================================================================
235
236
237def test_request_cookie_parsing():
238    # testing with a github-ish set of cookies
239    headers = [
240        (
241            'Cookie',
242            """
243            logged_in=no;_gh_sess=eyJzZXXzaW9uX2lkIjoiN2;
244            tz=Europe/Berlin; _ga =GA1.2.332347814.1422308165;
245            tz2=Europe/Paris ; _ga2="line1\\012line2";
246            tz3=Europe/Madrid ;_ga3= GA3.2.332347814.1422308165;
247            _gat=1;
248            _octo=GH1.1.201722077.1422308165
249            """
250        ),
251    ]
252
253    environ = testing.create_environ(headers=headers)
254    req = falcon.Request(environ)
255
256    # NOTE(kgriffs): Test case-sensitivity
257    assert req.get_cookie_values('TZ') is None
258    assert 'TZ' not in req.cookies
259    with pytest.raises(KeyError):
260        req.cookies['TZ']
261
262    for name, value in [
263        ('logged_in', 'no'),
264        ('_gh_sess', 'eyJzZXXzaW9uX2lkIjoiN2'),
265        ('tz', 'Europe/Berlin'),
266        ('tz2', 'Europe/Paris'),
267        ('tz3', 'Europe/Madrid'),
268        ('_ga', 'GA1.2.332347814.1422308165'),
269        ('_ga2', 'line1\nline2'),
270        ('_ga3', 'GA3.2.332347814.1422308165'),
271        ('_gat', '1'),
272        ('_octo', 'GH1.1.201722077.1422308165'),
273    ]:
274        assert name in req.cookies
275        assert req.cookies[name] == value
276        assert req.get_cookie_values(name) == [value]
277
278
279def test_invalid_cookies_are_ignored():
280    vals = [chr(i) for i in range(0x1F)]
281    vals += [chr(i) for i in range(0x7F, 0xFF)]
282    vals += '()<>@,;:\\"/[]?={} \x09'.split()
283
284    for c in vals:
285        headers = [
286            (
287                'Cookie',
288                'good_cookie=foo;bad' + c + 'cookie=bar'
289            ),
290        ]
291
292        environ = testing.create_environ(headers=headers)
293        req = falcon.Request(environ)
294
295        assert req.cookies['good_cookie'] == 'foo'
296        assert 'bad' + c + 'cookie' not in req.cookies
297
298
299def test_duplicate_cookie():
300    headers = [
301        (
302            'Cookie',
303            'x=1;bad{cookie=bar; x=2;x=3 ; x=4;'
304        ),
305    ]
306
307    environ = testing.create_environ(headers=headers)
308    req = falcon.Request(environ)
309
310    assert req.cookies['x'] == '1'
311    assert req.get_cookie_values('x') == ['1', '2', '3', '4']
312
313
314def test_cookie_header_is_missing():
315    environ = testing.create_environ(headers={})
316
317    req = falcon.Request(environ)
318    assert req.cookies == {}
319    assert req.get_cookie_values('x') is None
320
321    # NOTE(kgriffs): Test again with a new object to cover calling in the
322    #   opposite order.
323    req = falcon.Request(environ)
324    assert req.get_cookie_values('x') is None
325    assert req.cookies == {}
326
327
328def test_unicode_inside_ascii_range():
329    resp = falcon.Response()
330
331    # should be ok
332    resp.set_cookie('non_unicode_ascii_name_1', 'ascii_value')
333    resp.set_cookie(u'unicode_ascii_name_1', 'ascii_value')
334    resp.set_cookie('non_unicode_ascii_name_2', u'unicode_ascii_value')
335    resp.set_cookie(u'unicode_ascii_name_2', u'unicode_ascii_value')
336
337
338@pytest.mark.parametrize(
339    'name',
340    (
341        UNICODE_TEST_STRING,
342        UNICODE_TEST_STRING.encode('utf-8'),
343        42
344    )
345)
346def test_non_ascii_name(name):
347    resp = falcon.Response()
348    with pytest.raises(KeyError):
349        resp.set_cookie(name, 'ok_value')
350
351
352@pytest.mark.parametrize(
353    'value',
354    (
355        UNICODE_TEST_STRING,
356        UNICODE_TEST_STRING.encode('utf-8'),
357        42
358    )
359)
360def test_non_ascii_value(value):
361    resp = falcon.Response()
362
363    # NOTE(tbug): we need to grab the exception to check
364    # that it is not instance of UnicodeEncodeError, so
365    # we cannot simply use pytest.raises
366    try:
367        resp.set_cookie('ok_name', value)
368    except ValueError as e:
369        assert isinstance(e, ValueError)
370        assert not isinstance(e, UnicodeEncodeError)
371    else:
372        pytest.fail('set_bad_cookie_value did not fail as expected')
373