frindlymap/server/test_verify.py

158 lines
6.0 KiB
Python

import json
import re
import threading
import unittest
from urllib.parse import quote
import urllib.error
import urllib.request
from mailbox_server import make_server
from mailbox_store import SqliteMailboxStore
from phone_verify import SmsSender, Verifier
PHONE = "+821022223333"
TOKEN_A = "a" * 32
TOKEN_B = "b" * 32
class Capture(SmsSender):
def __init__(self):
self.sent, self.ok = [], True
def send(self, phone, text):
if self.ok:
self.sent.append((phone, text))
return self.ok
def last_code(self):
return re.search(r"\b(\d{6})\b", self.sent[-1][1]).group(1)
class Clock:
def __init__(self):
self.t = 1000.0
def __call__(self):
return self.t
class VerifierTest(unittest.TestCase):
def setUp(self):
self.sms, self.clock = Capture(), Clock()
self.v = Verifier(self.sms, self.clock)
def test_correct_code_confirms_once(self):
self.assertEqual(self.v.start(PHONE), "ok")
code = self.sms.last_code()
self.assertTrue(self.v.confirm(PHONE, code))
self.assertFalse(self.v.confirm(PHONE, code)) # 한 번 쓴 번호는 못 쓴다
def test_wrong_code_and_tries_limit(self):
self.v.start(PHONE)
code = self.sms.last_code()
for _ in range(5):
self.assertFalse(self.v.confirm(PHONE, "000000" if code != "000000" else "111111"))
self.assertFalse(self.v.confirm(PHONE, code)) # 5번 틀리면 맞는 번호도 거절
def test_expires_after_five_minutes(self):
self.v.start(PHONE)
code = self.sms.last_code()
self.clock.t += 301
self.assertFalse(self.v.confirm(PHONE, code))
def test_send_rate_limit_per_hour(self):
for _ in range(3):
self.assertEqual(self.v.start(PHONE), "ok")
self.assertEqual(self.v.start(PHONE), "rate")
self.clock.t += 3601
self.assertEqual(self.v.start(PHONE), "ok")
def test_sender_failure_does_not_count(self):
self.sms.ok = False
self.assertEqual(self.v.start(PHONE), "fail")
self.sms.ok = True
for _ in range(3):
self.assertEqual(self.v.start(PHONE), "ok")
def test_new_request_replaces_old_code(self):
self.v.start(PHONE)
old = self.sms.last_code()
self.v.start(PHONE)
new = self.sms.last_code()
if old != new:
self.assertFalse(self.v.confirm(PHONE, old))
self.assertTrue(self.v.confirm(PHONE, new) or old == new)
class ServerVerifyTest(unittest.TestCase):
def setUp(self):
self.sms = Capture()
self.store = SqliteMailboxStore()
self.srv = make_server(self.store, verifier=Verifier(self.sms), strict=True)
self.base = f"http://127.0.0.1:{self.srv.server_address[1]}"
threading.Thread(target=self.srv.serve_forever, daemon=True).start()
def tearDown(self):
self.srv.shutdown()
def call(self, method, path, body=None, token=None):
req = urllib.request.Request(self.base + path, method=method,
data=json.dumps(body).encode() if body is not None else None)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("X-Fm-Token", token)
try:
with urllib.request.urlopen(req) as r:
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read())
def verify(self, token):
self.assertEqual(self.call("POST", "/v1/verify/start", {"phone": PHONE})[0], 200)
return self.call("POST", "/v1/verify/confirm", {"phone": PHONE, "code": self.sms.last_code(), "token": token})
def test_strict_mode_blocks_unverified_squatter(self):
self.assertEqual(self.call("GET", f"/v1/fetch?to={quote(PHONE)}", token=TOKEN_A)[0], 403) # 선점 시도 막힘
def test_verified_device_opens_mailbox(self):
self.assertEqual(self.verify(TOKEN_A)[0], 200)
self.assertEqual(self.call("GET", f"/v1/fetch?to={quote(PHONE)}", token=TOKEN_A)[0], 200)
self.assertEqual(self.call("GET", f"/v1/fetch?to={quote(PHONE)}", token=TOKEN_B)[0], 403)
def test_real_owner_takes_over_after_new_verification(self):
self.verify(TOKEN_A)
self.assertEqual(self.verify(TOKEN_B)[0], 200) # 폰을 바꾼 진짜 주인
self.assertEqual(self.call("GET", f"/v1/fetch?to={quote(PHONE)}", token=TOKEN_B)[0], 200)
self.assertEqual(self.call("GET", f"/v1/fetch?to={quote(PHONE)}", token=TOKEN_A)[0], 403)
def test_wrong_code_rejected_and_short_token_rejected(self):
self.call("POST", "/v1/verify/start", {"phone": PHONE})
self.assertEqual(self.call("POST", "/v1/verify/confirm", {"phone": PHONE, "code": "123", "token": TOKEN_A})[0], 403)
self.assertEqual(self.call("POST", "/v1/verify/confirm", {"phone": PHONE, "code": self.sms.last_code(), "token": "짧음"})[0], 400)
def test_rate_limit_returns_429(self):
for _ in range(3):
self.assertEqual(self.call("POST", "/v1/verify/start", {"phone": PHONE})[0], 200)
self.assertEqual(self.call("POST", "/v1/verify/start", {"phone": PHONE})[0], 429)
def test_bad_phone_rejected(self):
self.assertEqual(self.call("POST", "/v1/verify/start", {"phone": "010-1234"})[0], 400)
class NoVerifierTest(unittest.TestCase):
def test_endpoints_unavailable_and_tofu_still_works(self):
store = SqliteMailboxStore()
srv = make_server(store)
threading.Thread(target=srv.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{srv.server_address[1]}"
req = urllib.request.Request(base + "/v1/verify/start", method="POST", data=json.dumps({"phone": PHONE}).encode())
with self.assertRaises(urllib.error.HTTPError) as cm:
urllib.request.urlopen(req)
self.assertEqual(cm.exception.code, 400)
self.assertTrue(store.check_token(PHONE, TOKEN_A)) # 문자 확인을 안 쓰면 예전처럼 처음 쓴 기기에 묶인다
srv.shutdown()
if __name__ == "__main__":
unittest.main()