Coverage for /home/marco/Code/django-simple-captcha/captcha/tests/tests.py: 91.53%
338 statements
« prev ^ index » next coverage.py v7.6.5, created at 2024-11-15 09:22 +0100
« prev ^ index » next coverage.py v7.6.5, created at 2024-11-15 09:22 +0100
1import datetime
2import json
3import os
4import re
5from io import BytesIO
6from unittest.mock import call, patch
8from PIL import Image
9from testfixtures import LogCapture
11import django
12from django.core import management
13from django.core.exceptions import ImproperlyConfigured
14from django.test import TestCase, override_settings
15from django.urls import reverse
16from django.utils import timezone
17from django.utils.translation import gettext_lazy
19from captcha.conf import settings
20from captcha.models import CaptchaStore
23@override_settings(ROOT_URLCONF="captcha.tests.urls")
24class CaptchaCase(TestCase):
25 def setUp(self):
26 self.stores = {}
27 self.__current_settings_dictionary = settings.CAPTCHA_WORDS_DICTIONARY
28 self.__current_settings_punctuation = settings.CAPTCHA_PUNCTUATION
30 tested_helpers = [
31 "captcha.helpers.math_challenge",
32 "captcha.helpers.random_char_challenge",
33 "captcha.helpers.unicode_challenge",
34 ]
35 if os.path.exists("/usr/share/dict/words"): 35 ↛ 36line 35 didn't jump to line 36 because the condition on line 35 was never true
36 settings.CAPTCHA_WORDS_DICTIONARY = "/usr/share/dict/words"
37 settings.CAPTCHA_PUNCTUATION = ";-,."
38 tested_helpers.append("captcha.helpers.word_challenge")
39 tested_helpers.append(
40 "captcha.helpers.huge_words_and_punctuation_challenge"
41 )
42 for helper in tested_helpers:
43 challenge, response = settings._callable_from_string(helper)()
44 (
45 self.stores[helper.rsplit(".", 1)[-1].replace("_challenge", "_store")],
46 _,
47 ) = CaptchaStore.objects.get_or_create(
48 challenge=challenge, response=response
49 )
50 challenge, response = settings.get_challenge()()
51 self.stores["default_store"], _ = CaptchaStore.objects.get_or_create(
52 challenge=challenge, response=response
53 )
54 self.default_store = self.stores["default_store"]
56 def tearDown(self):
57 settings.CAPTCHA_WORDS_DICTIONARY = self.__current_settings_dictionary
58 settings.CAPTCHA_PUNCTUATION = self.__current_settings_punctuation
60 def _assertFormError(self, response, form_name, *args, **kwargs):
61 self.assertFormError(response.context.get(form_name), *args, **kwargs)
63 def __extract_hash_and_response(self, r):
64 hash_ = re.findall(r'value="([0-9a-f]+)"', str(r.content))[0]
65 response = CaptchaStore.objects.get(hashkey=hash_).response
66 return hash_, response
68 def test_image(self):
69 for key in [store.hashkey for store in self.stores.values()]:
70 response = self.client.get(reverse("captcha-image", kwargs=dict(key=key)))
71 self.assertEqual(response.status_code, 200)
72 self.assertTrue(response.has_header("content-type"))
73 self.assertEqual(response["content-type"], "image/png")
75 def test_audio(self):
76 if not settings.CAPTCHA_FLITE_PATH: 76 ↛ 78line 76 didn't jump to line 78 because the condition on line 76 was always true
77 return
78 for key in (
79 self.stores.get("math_store").hashkey,
80 self.stores.get("math_store").hashkey,
81 self.default_store.hashkey,
82 ):
83 response = self.client.get(reverse("captcha-audio", kwargs=dict(key=key)))
84 self.assertEqual(response.status_code, 200)
85 self.assertTrue(response.ranged_file.size > 1024)
86 self.assertTrue(response.has_header("content-type"))
87 self.assertEqual(response["content-type"], "audio/wav")
89 def test_form_submit(self):
90 r = self.client.get(reverse("captcha-test"))
91 self.assertEqual(r.status_code, 200)
92 hash_, response = self.__extract_hash_and_response(r)
94 r = self.client.post(
95 reverse("captcha-test"),
96 dict(
97 captcha_0=hash_,
98 captcha_1=response,
99 subject="xxx",
100 sender="asasd@asdasd.com",
101 ),
102 )
103 self.assertEqual(r.status_code, 200)
104 self.assertTrue(str(r.content).find("Form validated") > 0)
106 r = self.client.post(
107 reverse("captcha-test"),
108 dict(
109 captcha_0=hash_,
110 captcha_1=response,
111 subject="xxx",
112 sender="asasd@asdasd.com",
113 ),
114 )
115 self.assertEqual(r.status_code, 200)
116 self.assertFalse(str(r.content).find("Form validated") > 0)
118 def test_modelform(self):
119 r = self.client.get(reverse("captcha-test-model-form"))
120 self.assertEqual(r.status_code, 200)
121 hash_, response = self.__extract_hash_and_response(r)
123 r = self.client.post(
124 reverse("captcha-test-model-form"),
125 dict(
126 captcha_0=hash_,
127 captcha_1=response,
128 subject="xxx",
129 sender="asasd@asdasd.com",
130 ),
131 )
132 self.assertEqual(r.status_code, 200)
133 self.assertTrue(str(r.content).find("Form validated") > 0)
135 r = self.client.post(
136 reverse("captcha-test-model-form"),
137 dict(
138 captcha_0=hash_,
139 captcha_1=response,
140 subject="xxx",
141 sender="asasd@asdasd.com",
142 ),
143 )
144 self.assertEqual(r.status_code, 200)
145 self.assertFalse(str(r.content).find("Form validated") > 0)
147 def test_wrong_submit(self):
148 for urlname in ("captcha-test", "captcha-test-model-form"):
149 r = self.client.get(reverse(urlname))
150 self.assertEqual(r.status_code, 200)
151 r = self.client.post(
152 reverse(urlname),
153 dict(
154 captcha_0="abc",
155 captcha_1="wrong response",
156 subject="xxx",
157 sender="asasd@asdasd.com",
158 ),
159 )
160 self._assertFormError(r, "form", "captcha", gettext_lazy("Invalid CAPTCHA"))
162 def test_deleted_expired(self):
163 self.default_store.expiration = timezone.now() - datetime.timedelta(minutes=5)
164 self.default_store.save()
165 hash_ = self.default_store.hashkey
166 r = self.client.post(
167 reverse("captcha-test"),
168 dict(
169 captcha_0=hash_,
170 captcha_1=self.default_store.response,
171 subject="xxx",
172 sender="asasd@asdasd.com",
173 ),
174 )
176 self.assertEqual(r.status_code, 200)
177 self.assertFalse("Form validated" in str(r.content))
179 # expired -> deleted
180 try:
181 CaptchaStore.objects.get(hashkey=hash_)
182 self.fail()
183 except Exception:
184 pass
186 def test_custom_error_message(self):
187 r = self.client.get(reverse("captcha-test-custom-error-message"))
188 self.assertEqual(r.status_code, 200)
189 # Wrong answer
190 r = self.client.post(
191 reverse("captcha-test-custom-error-message"),
192 dict(captcha_0="abc", captcha_1="wrong response"),
193 )
194 self._assertFormError(r, "form", "captcha", "TEST CUSTOM ERROR MESSAGE")
195 # empty answer
196 r = self.client.post(
197 reverse("captcha-test-custom-error-message"),
198 dict(captcha_0="abc", captcha_1=""),
199 )
200 self._assertFormError(
201 r, "form", "captcha", gettext_lazy("This field is required.")
202 )
204 def test_repeated_challenge(self):
205 CaptchaStore.objects.create(challenge="xxx", response="xxx")
206 try:
207 CaptchaStore.objects.create(challenge="xxx", response="xxx")
208 except Exception:
209 self.fail()
211 @patch("captcha.tests.tests.random_color_challenge")
212 def test_custom_letters_color(self, color_challenge_func_mock):
213 color_challenge_func_mock.return_value = "#ffffff"
214 _current_captcha_challenge_func = settings.CAPTCHA_CHALLENGE_FUNCT
216 settings.CAPTCHA_LETTER_COLOR_FUNCT = (
217 "captcha.tests.tests.random_color_challenge"
218 )
219 settings.CAPTCHA_CHALLENGE_FUNCT = "captcha.tests.tests.random_char_challenge"
221 challenge, response = settings.get_challenge()()
222 _store, _ = CaptchaStore.objects.get_or_create(
223 challenge=challenge, response=response
224 )
226 _response = self.client.get(
227 reverse("captcha-image", kwargs=dict(key=_store.hashkey))
228 )
229 assert _response.status_code == 200
231 _calls = []
232 for index, char in enumerate(challenge):
233 _calls.append(call(index, challenge))
234 color_challenge_func_mock.assert_has_calls(_calls, any_order=True)
236 settings.CAPTCHA_LETTER_COLOR_FUNCT = None
237 settings.CAPTCHA_CHALLENGE_FUNCT = _current_captcha_challenge_func
239 def test_repeated_challenge_form_submit(self):
240 __current_challange_function = settings.CAPTCHA_CHALLENGE_FUNCT
241 for urlname in ("captcha-test", "captcha-test-model-form"):
242 settings.CAPTCHA_CHALLENGE_FUNCT = "captcha.tests.trivial_challenge"
244 r1 = self.client.get(reverse(urlname))
245 r2 = self.client.get(reverse(urlname))
246 self.assertEqual(r1.status_code, 200)
247 self.assertEqual(r2.status_code, 200)
248 if re.findall(r'value="([0-9a-f]+)"', str(r1.content)): 248 ↛ 251line 248 didn't jump to line 251 because the condition on line 248 was always true
249 hash_1 = re.findall(r'value="([0-9a-f]+)"', str(r1.content))[0]
250 else:
251 self.fail()
253 if re.findall(r'value="([0-9a-f]+)"', str(r2.content)): 253 ↛ 256line 253 didn't jump to line 256 because the condition on line 253 was always true
254 hash_2 = re.findall(r'value="([0-9a-f]+)"', str(r2.content))[0]
255 else:
256 self.fail()
257 try:
258 store_1 = CaptchaStore.objects.get(hashkey=hash_1)
259 store_2 = CaptchaStore.objects.get(hashkey=hash_2)
260 except Exception:
261 self.fail()
263 self.assertTrue(store_1.pk != store_2.pk)
264 self.assertTrue(store_1.response == store_2.response)
265 self.assertTrue(hash_1 != hash_2)
267 r1 = self.client.post(
268 reverse(urlname),
269 dict(
270 captcha_0=hash_1,
271 captcha_1=store_1.response,
272 subject="xxx",
273 sender="asasd@asdasd.com",
274 ),
275 )
276 self.assertEqual(r1.status_code, 200)
277 self.assertTrue(str(r1.content).find("Form validated") > 0)
279 try:
280 store_2 = CaptchaStore.objects.get(hashkey=hash_2)
281 except Exception:
282 self.fail()
284 r2 = self.client.post(
285 reverse(urlname),
286 dict(
287 captcha_0=hash_2,
288 captcha_1=store_2.response,
289 subject="xxx",
290 sender="asasd@asdasd.com",
291 ),
292 )
293 self.assertEqual(r2.status_code, 200)
294 self.assertTrue(str(r2.content).find("Form validated") > 0)
295 settings.CAPTCHA_CHALLENGE_FUNCT = __current_challange_function
297 def test_custom_generator(self):
298 r = self.client.get(reverse("test_custom_generator"))
299 hash_, response = self.__extract_hash_and_response(r)
300 self.assertEqual(response, "111111")
302 def test_refresh_view(self):
303 r = self.client.get(
304 reverse("captcha-refresh"), HTTP_X_REQUESTED_WITH="XMLHttpRequest"
305 )
306 try:
307 new_data = json.loads(str(r.content, encoding="ascii"))
308 self.assertTrue("image_url" in new_data)
309 self.assertTrue("audio_url" in new_data)
310 except Exception:
311 self.fail()
313 def test_content_length(self):
314 for key in [store.hashkey for store in self.stores.values()]:
315 response = self.client.get(reverse("captcha-image", kwargs=dict(key=key)))
316 self.assertTrue(response.has_header("content-length"))
317 self.assertTrue(response["content-length"].isdigit())
318 self.assertTrue(int(response["content-length"]))
320 def test_test_mode_issue15(self):
321 __current_test_mode_setting = settings.CAPTCHA_TEST_MODE
322 settings.CAPTCHA_TEST_MODE = False
323 r = self.client.get(reverse("captcha-test"))
324 self.assertEqual(r.status_code, 200)
325 r = self.client.post(
326 reverse("captcha-test"),
327 dict(
328 captcha_0="abc",
329 captcha_1="wrong response",
330 subject="xxx",
331 sender="asasd@asdasd.com",
332 ),
333 )
334 self._assertFormError(r, "form", "captcha", gettext_lazy("Invalid CAPTCHA"))
336 settings.CAPTCHA_TEST_MODE = True
337 # Test mode, only 'PASSED' is accepted
338 r = self.client.get(reverse("captcha-test"))
339 self.assertEqual(r.status_code, 200)
340 r = self.client.post(
341 reverse("captcha-test"),
342 dict(
343 captcha_0="abc",
344 captcha_1="wrong response",
345 subject="xxx",
346 sender="asasd@asdasd.com",
347 ),
348 )
349 self._assertFormError(r, "form", "captcha", gettext_lazy("Invalid CAPTCHA"))
351 r = self.client.get(reverse("captcha-test"))
352 self.assertEqual(r.status_code, 200)
353 r = self.client.post(
354 reverse("captcha-test"),
355 dict(
356 captcha_0="abc",
357 captcha_1="passed",
358 subject="xxx",
359 sender="asasd@asdasd.com",
360 ),
361 )
362 self.assertTrue(str(r.content).find("Form validated") > 0)
363 settings.CAPTCHA_TEST_MODE = __current_test_mode_setting
365 def test_get_version(self):
366 import captcha
368 captcha.get_version()
370 def test_missing_value(self):
371 r = self.client.get(reverse("captcha-test-non-required"))
372 self.assertEqual(r.status_code, 200)
373 hash_, response = self.__extract_hash_and_response(r)
375 # Empty response is okay when required is False
376 r = self.client.post(
377 reverse("captcha-test-non-required"),
378 dict(subject="xxx", sender="asasd@asdasd.com"),
379 )
380 self.assertEqual(r.status_code, 200)
381 self.assertTrue(str(r.content).find("Form validated") > 0)
383 # But a valid response is okay, too
384 r = self.client.get(reverse("captcha-test-non-required"))
385 self.assertEqual(r.status_code, 200)
386 hash_, response = self.__extract_hash_and_response(r)
388 r = self.client.post(
389 reverse("captcha-test-non-required"),
390 dict(
391 captcha_0=hash_,
392 captcha_1=response,
393 subject="xxx",
394 sender="asasd@asdasd.com",
395 ),
396 )
397 self.assertEqual(r.status_code, 200)
398 self.assertTrue(str(r.content).find("Form validated") > 0)
400 def test_autocomplete_off(self):
401 r = self.client.get(reverse("captcha-test"))
402 captcha_input = (
403 '<input type="text" name="captcha_1" autocomplete="off" '
404 'spellcheck="false" autocorrect="off" '
405 'autocapitalize="off" id="id_captcha_1" required />'
406 )
407 if django.VERSION >= (5, 0): 407 ↛ 414line 407 didn't jump to line 414 because the condition on line 407 was always true
408 captcha_input = (
409 '<input type="text" name="captcha_1" autocomplete="off" '
410 'spellcheck="false" autocorrect="off" '
411 'autocapitalize="off" id="id_captcha_1" '
412 "required />"
413 )
414 self.assertContains(r, captcha_input, html=True)
416 def test_issue201_autocomplete_off_on_hiddeninput(self):
417 r = self.client.get(reverse("captcha-test"))
419 # Inspect the response context to find out the captcha key.
420 key = r.context["form"]["captcha"].field.widget._key
422 # Assety that autocomplete=off is set on the hidden captcha field.
423 if django.VERSION >= (5, 0): 423 ↛ 434line 423 didn't jump to line 434 because the condition on line 423 was always true
424 self.assertInHTML(
425 (
426 f'<input type="hidden" name="captcha_0" value="{key}" '
427 'id="id_captcha_0" autocomplete="off" '
428 "required />"
429 ),
430 str(r.content),
431 )
433 else:
434 self.assertInHTML(
435 (
436 f'<input type="hidden" name="captcha_0" value="{key}" '
437 'id="id_captcha_0" autocomplete="off" required />'
438 ),
439 str(r.content),
440 )
442 def test_transparent_background(self):
443 __current_test_mode_setting = settings.CAPTCHA_BACKGROUND_COLOR
444 settings.CAPTCHA_BACKGROUND_COLOR = "transparent"
445 for key in [store.hashkey for store in self.stores.values()]:
446 response = self.client.get(reverse("captcha-image", kwargs=dict(key=key)))
447 self.assertEqual(response.status_code, 200)
448 self.assertTrue(response.has_header("content-type"))
449 self.assertEqual(response["content-type"], "image/png")
451 settings.CAPTCHA_BACKGROUND_COLOR = __current_test_mode_setting
453 def test_expired_captcha_returns_410(self):
454 for key in [store.hashkey for store in self.stores.values()]:
455 response = self.client.get(reverse("captcha-image", kwargs=dict(key=key)))
456 self.assertEqual(response.status_code, 200)
457 CaptchaStore.objects.filter(hashkey=key).delete()
458 response = self.client.get(reverse("captcha-image", kwargs=dict(key=key)))
459 self.assertEqual(response.status_code, 410)
461 def test_id_prefix(self):
462 r = self.client.get(reverse("captcha-test-id-prefix"))
463 self.assertTrue(
464 '<label for="form1_id_captcha1_1">Captcha1:</label>' in str(r.content)
465 )
466 self.assertTrue('id="form1_id_captcha1_1"' in str(r.content))
467 self.assertTrue(
468 '<label for="form2_id_captcha2_1">Captcha2:</label>' in str(r.content)
469 )
470 self.assertTrue('id="form2_id_captcha2_1"' in str(r.content))
472 def test_image_size(self):
473 __current_test_mode_setting = settings.CAPTCHA_IMAGE_SIZE
474 for key in [store.hashkey for store in self.stores.values()]:
475 settings.CAPTCHA_IMAGE_SIZE = (201, 97)
476 response = self.client.get(reverse("captcha-image", kwargs=dict(key=key)))
477 self.assertEqual(response.status_code, 200)
478 self.assertTrue(response.has_header("content-type"))
479 self.assertEqual(response["content-type"], "image/png")
480 self.assertEqual(Image.open(BytesIO(response.content)).size, (201, 97))
482 settings.CAPTCHA_IMAGE_SIZE = __current_test_mode_setting
484 def test_multiple_fonts(self):
485 vera = os.path.join(os.path.dirname(__file__), "..", "fonts", "Vera.ttf")
486 __current_test_mode_setting = settings.CAPTCHA_FONT_PATH
487 settings.CAPTCHA_FONT_PATH = vera
489 for key in [store.hashkey for store in self.stores.values()]:
490 response = self.client.get(reverse("captcha-image", kwargs=dict(key=key)))
491 self.assertEqual(response.status_code, 200)
492 self.assertEqual(response["content-type"], "image/png")
494 settings.CAPTCHA_FONT_PATH = [vera, vera, vera]
495 for key in [store.hashkey for store in self.stores.values()]:
496 response = self.client.get(reverse("captcha-image", kwargs=dict(key=key)))
497 self.assertEqual(response.status_code, 200)
498 self.assertEqual(response["content-type"], "image/png")
500 settings.CAPTCHA_FONT_PATH = False
501 for key in [store.hashkey for store in self.stores.values()]:
502 try:
503 response = self.client.get(
504 reverse("captcha-image", kwargs=dict(key=key))
505 )
506 self.fail()
507 except ImproperlyConfigured:
508 pass
510 settings.CAPTCHA_FONT_PATH = __current_test_mode_setting
512 def test_math_challenge(self):
513 __current_test_mode_setting = settings.CAPTCHA_MATH_CHALLENGE_OPERATOR
514 settings.CAPTCHA_MATH_CHALLENGE_OPERATOR = "~"
515 helper = "captcha.helpers.math_challenge"
516 challenge, response = settings._callable_from_string(helper)()
518 while settings.CAPTCHA_MATH_CHALLENGE_OPERATOR not in challenge:
519 challenge, response = settings._callable_from_string(helper)()
521 self.assertEqual(
522 response,
523 str(
524 eval(
525 challenge.replace(settings.CAPTCHA_MATH_CHALLENGE_OPERATOR, "*")[
526 :-1
527 ]
528 )
529 ),
530 )
531 settings.CAPTCHA_MATH_CHALLENGE_OPERATOR = __current_test_mode_setting
533 def test_get_from_pool(self):
534 __current_test_get_from_pool_setting = settings.CAPTCHA_GET_FROM_POOL
535 __current_test_get_from_pool_timeout_setting = (
536 settings.CAPTCHA_GET_FROM_POOL_TIMEOUT
537 )
538 __current_test_timeout_setting = settings.CAPTCHA_TIMEOUT
539 settings.CAPTCHA_GET_FROM_POOL = True
540 settings.CAPTCHA_GET_FROM_POOL_TIMEOUT = 5
541 settings.CAPTCHA_TIMEOUT = 90
542 CaptchaStore.objects.all().delete() # Delete objects created during SetUp
543 POOL_SIZE = 10
544 CaptchaStore.create_pool(count=POOL_SIZE)
545 self.assertEqual(CaptchaStore.objects.count(), POOL_SIZE)
546 pool = CaptchaStore.objects.values_list("hashkey", flat=True)
547 random_pick = CaptchaStore.pick()
548 self.assertIn(random_pick, pool)
549 # pick() should not create any extra captcha
550 self.assertEqual(CaptchaStore.objects.count(), POOL_SIZE)
551 settings.CAPTCHA_GET_FROM_POOL = __current_test_get_from_pool_setting
552 settings.CAPTCHA_GET_FROM_POOL_TIMEOUT = (
553 __current_test_get_from_pool_timeout_setting
554 )
555 settings.CAPTCHA_TIMEOUT = __current_test_timeout_setting
557 def test_captcha_create_pool(self):
558 CaptchaStore.objects.all().delete() # Delete objects created during SetUp
559 POOL_SIZE = 10
560 management.call_command("captcha_create_pool", pool_size=POOL_SIZE, verbosity=0)
561 self.assertEqual(CaptchaStore.objects.count(), POOL_SIZE)
563 def test_empty_pool_fallback(self):
564 __current_test_get_from_pool_setting = settings.CAPTCHA_GET_FROM_POOL
565 settings.CAPTCHA_GET_FROM_POOL = True
566 CaptchaStore.objects.all().delete() # Delete objects created during SetUp
567 with LogCapture() as log:
568 CaptchaStore.pick()
569 log.check(
570 ("captcha.models", "ERROR", "Couldn't get a captcha from pool, generating")
571 )
572 self.assertEqual(CaptchaStore.objects.count(), 1)
573 settings.CAPTCHA_GET_FROM_POOL = __current_test_get_from_pool_setting
575 def test_serializer(self):
576 r = self.client.post(
577 reverse("captcha-test-serializer"),
578 dict(
579 captcha_code=self.default_store.response,
580 captcha_hashkey=self.default_store.hashkey,
581 ),
582 )
583 self.assertEqual(r.status_code, 200)
585 def test_wrong_serializer(self):
586 r = self.client.post(
587 reverse("captcha-test-serializer"),
588 dict(
589 captcha_code="xxx",
590 captcha_hashkey="wrong hash",
591 ),
592 )
593 self.assertEqual(r.status_code, 400)
594 self.assertEqual(json.loads(r.content), {"error": "Invalid CAPTCHA"})
596 def test_model_serializer(self):
597 r = self.client.post(
598 reverse("captcha-test-model-serializer"),
599 dict(
600 captcha_code=self.default_store.response,
601 captcha_hashkey=self.default_store.hashkey,
602 subject="xxx",
603 sender="asasd@asdasd.com",
604 ),
605 )
606 self.assertEqual(r.status_code, 200)
608 def test_wrong_model_serializer(self):
609 r = self.client.post(
610 reverse("captcha-test-model-serializer"),
611 dict(
612 captcha_code="xxx",
613 captcha_hashkey="wrong hash",
614 subject="xxx",
615 sender="asasd@asdasd.com",
616 ),
617 )
618 self.assertEqual(r.status_code, 400)
619 self.assertEqual(json.loads(r.content), {"error": "Invalid CAPTCHA"})
622def trivial_challenge():
623 return "trivial", "trivial"
626def random_color_challenge(index, string):
627 return "#ffffff"
630def random_char_challenge():
631 chars = "abcdefghijklmnopqrstuvwxyz"
632 ret = chars[: settings.CAPTCHA_LENGTH]
633 return ret.upper(), ret