Coverage for src/couchers/servicers/account.py: 95%
245 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-10-21 08:09 +0000
« prev ^ index » next coverage.py v7.5.0, created at 2024-10-21 08:09 +0000
1import json
2import logging
3from datetime import timedelta
5import grpc
6import requests
7from google.protobuf import empty_pb2
8from sqlalchemy.sql import func, update
9from user_agents import parse as user_agents_parse
11from couchers import errors
12from couchers.config import config
13from couchers.constants import PHONE_REVERIFICATION_INTERVAL, SMS_CODE_ATTEMPTS, SMS_CODE_LIFETIME
14from couchers.crypto import (
15 b64decode,
16 b64encode,
17 hash_password,
18 simple_decrypt,
19 simple_encrypt,
20 urlsafe_secure_token,
21 verify_password,
22 verify_token,
23)
24from couchers.helpers.geoip import geoip_approximate_location
25from couchers.jobs.enqueue import queue_job
26from couchers.metrics import (
27 account_deletion_initiations_counter,
28 strong_verification_completions_counter,
29 strong_verification_data_deletions_counter,
30 strong_verification_initiations_counter,
31)
32from couchers.models import (
33 AccountDeletionReason,
34 AccountDeletionToken,
35 ContributeOption,
36 ContributorForm,
37 ModNote,
38 StrongVerificationAttempt,
39 StrongVerificationAttemptStatus,
40 StrongVerificationCallbackEvent,
41 User,
42 UserSession,
43)
44from couchers.notifications.notify import notify
45from couchers.phone import sms
46from couchers.phone.check import is_e164_format, is_known_operator
47from couchers.sql import couchers_select as select
48from couchers.tasks import (
49 maybe_send_contributor_form_email,
50 send_account_deletion_report_email,
51 send_email_changed_confirmation_to_new_email,
52)
53from couchers.utils import (
54 Timestamp_from_datetime,
55 dt_from_page_token,
56 dt_to_page_token,
57 is_valid_email,
58 now,
59 to_aware_datetime,
60)
61from proto import account_pb2, account_pb2_grpc, api_pb2, auth_pb2, iris_pb2_grpc, notification_data_pb2
62from proto.google.api import httpbody_pb2
63from proto.internal import jobs_pb2, verification_pb2
65logger = logging.getLogger(__name__)
66logger.setLevel(logging.DEBUG)
68contributeoption2sql = {
69 auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED: None,
70 auth_pb2.CONTRIBUTE_OPTION_YES: ContributeOption.yes,
71 auth_pb2.CONTRIBUTE_OPTION_MAYBE: ContributeOption.maybe,
72 auth_pb2.CONTRIBUTE_OPTION_NO: ContributeOption.no,
73}
75contributeoption2api = {
76 None: auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED,
77 ContributeOption.yes: auth_pb2.CONTRIBUTE_OPTION_YES,
78 ContributeOption.maybe: auth_pb2.CONTRIBUTE_OPTION_MAYBE,
79 ContributeOption.no: auth_pb2.CONTRIBUTE_OPTION_NO,
80}
82MAX_PAGINATION_LENGTH = 50
85def has_strong_verification(session, user):
86 attempt = session.execute(
87 select(StrongVerificationAttempt)
88 .where(StrongVerificationAttempt.user_id == user.id)
89 .where(StrongVerificationAttempt.is_valid)
90 .order_by(StrongVerificationAttempt.passport_expiry_datetime.desc())
91 .limit(1)
92 ).scalar_one_or_none()
93 if attempt:
94 assert attempt.is_valid
95 return attempt.has_strong_verification(user)
96 return False
99def mod_note_to_pb(note: ModNote):
100 return account_pb2.ModNote(
101 note_id=note.id,
102 note_content=note.note_content,
103 created=Timestamp_from_datetime(note.created),
104 acknowledged=Timestamp_from_datetime(note.acknowledged) if note.acknowledged else None,
105 )
108def get_strong_verification_fields(session, db_user):
109 out = dict(
110 birthdate_verification_status=api_pb2.BIRTHDATE_VERIFICATION_STATUS_UNVERIFIED,
111 gender_verification_status=api_pb2.GENDER_VERIFICATION_STATUS_UNVERIFIED,
112 has_strong_verification=False,
113 )
114 attempt = session.execute(
115 select(StrongVerificationAttempt)
116 .where(StrongVerificationAttempt.user_id == db_user.id)
117 .where(StrongVerificationAttempt.is_valid)
118 .order_by(StrongVerificationAttempt.passport_expiry_datetime.desc())
119 .limit(1)
120 ).scalar_one_or_none()
121 if attempt:
122 assert attempt.is_valid
123 if attempt.matches_birthdate(db_user):
124 out["birthdate_verification_status"] = api_pb2.BIRTHDATE_VERIFICATION_STATUS_VERIFIED
125 else:
126 out["birthdate_verification_status"] = api_pb2.BIRTHDATE_VERIFICATION_STATUS_MISMATCH
128 if attempt.matches_gender(db_user):
129 out["gender_verification_status"] = api_pb2.GENDER_VERIFICATION_STATUS_VERIFIED
130 else:
131 out["gender_verification_status"] = api_pb2.GENDER_VERIFICATION_STATUS_MISMATCH
133 out["has_strong_verification"] = attempt.has_strong_verification(db_user)
135 assert out["has_strong_verification"] == (
136 out["birthdate_verification_status"] == api_pb2.BIRTHDATE_VERIFICATION_STATUS_VERIFIED
137 and out["gender_verification_status"] == api_pb2.GENDER_VERIFICATION_STATUS_VERIFIED
138 )
139 return out
142def abort_on_invalid_password(password, context):
143 """
144 Internal utility function: given a password, aborts if password is unforgivably insecure
145 """
146 if len(password) < 8:
147 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.PASSWORD_TOO_SHORT)
149 if len(password) > 256:
150 # Hey, what are you trying to do? Give us a DDOS attack?
151 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.PASSWORD_TOO_LONG)
153 # check for most common weak passwords (not meant to be an exhaustive check!)
154 if password.lower() in ("password", "12345678", "couchers", "couchers1"):
155 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INSECURE_PASSWORD)
158class Account(account_pb2_grpc.AccountServicer):
159 def GetAccountInfo(self, request, context, session):
160 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
162 return account_pb2.GetAccountInfoRes(
163 username=user.username,
164 email=user.email,
165 phone=user.phone if (user.phone_is_verified or not user.phone_code_expired) else None,
166 has_donated=user.has_donated,
167 phone_verified=user.phone_is_verified,
168 profile_complete=user.has_completed_profile,
169 timezone=user.timezone,
170 **get_strong_verification_fields(session, user),
171 )
173 def ChangePasswordV2(self, request, context, session):
174 """
175 Changes the user's password. They have to confirm their old password just in case.
177 If they didn't have an old password previously, then we don't check that.
178 """
179 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
181 if not verify_password(user.hashed_password, request.old_password):
182 # wrong password
183 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_PASSWORD)
185 abort_on_invalid_password(request.new_password, context)
186 user.hashed_password = hash_password(request.new_password)
188 session.commit()
190 notify(
191 session,
192 user_id=user.id,
193 topic_action="password:change",
194 )
196 return empty_pb2.Empty()
198 def ChangeEmailV2(self, request, context, session):
199 """
200 Change the user's email address.
202 If the user has a password, a notification is sent to the old email, and a confirmation is sent to the new one.
204 Otherwise they need to confirm twice, via an email sent to each of their old and new emails.
206 In all confirmation emails, the user must click on the confirmation link.
207 """
208 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
210 # check password first
211 if not verify_password(user.hashed_password, request.password):
212 # wrong password
213 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_PASSWORD)
215 # not a valid email
216 if not is_valid_email(request.new_email):
217 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_EMAIL)
219 # email already in use (possibly by this user)
220 if session.execute(select(User).where(User.email == request.new_email)).scalar_one_or_none():
221 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_EMAIL)
223 user.new_email = request.new_email
224 user.new_email_token = urlsafe_secure_token()
225 user.new_email_token_created = now()
226 user.new_email_token_expiry = now() + timedelta(hours=2)
228 send_email_changed_confirmation_to_new_email(session, user)
230 # will still go into old email
231 notify(
232 session,
233 user_id=user.id,
234 topic_action="email_address:change",
235 data=notification_data_pb2.EmailAddressChange(
236 new_email=request.new_email,
237 ),
238 )
240 # session autocommit
241 return empty_pb2.Empty()
243 def FillContributorForm(self, request, context, session):
244 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
246 form = request.contributor_form
248 form = ContributorForm(
249 user=user,
250 ideas=form.ideas or None,
251 features=form.features or None,
252 experience=form.experience or None,
253 contribute=contributeoption2sql[form.contribute],
254 contribute_ways=form.contribute_ways,
255 expertise=form.expertise or None,
256 )
258 session.add(form)
259 session.flush()
260 maybe_send_contributor_form_email(session, form)
262 user.filled_contributor_form = True
264 return empty_pb2.Empty()
266 def GetContributorFormInfo(self, request, context, session):
267 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
269 return account_pb2.GetContributorFormInfoRes(
270 filled_contributor_form=user.filled_contributor_form,
271 )
273 def ChangePhone(self, request, context, session):
274 phone = request.phone
275 # early quick validation
276 if phone and not is_e164_format(phone):
277 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_PHONE)
279 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
280 if not user.has_donated:
281 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.NOT_DONATED)
283 if not phone:
284 user.phone = None
285 user.phone_verification_verified = None
286 user.phone_verification_token = None
287 user.phone_verification_attempts = 0
288 return empty_pb2.Empty()
290 if not is_known_operator(phone):
291 context.abort(grpc.StatusCode.UNIMPLEMENTED, errors.UNRECOGNIZED_PHONE_NUMBER)
293 if now() - user.phone_verification_sent < PHONE_REVERIFICATION_INTERVAL:
294 context.abort(grpc.StatusCode.RESOURCE_EXHAUSTED, errors.REVERIFICATION_TOO_EARLY)
296 token = sms.generate_random_code()
297 result = sms.send_sms(phone, sms.format_message(token))
299 if result == "success":
300 user.phone = phone
301 user.phone_verification_verified = None
302 user.phone_verification_token = token
303 user.phone_verification_sent = now()
304 user.phone_verification_attempts = 0
306 notify(
307 session,
308 user_id=user.id,
309 topic_action="phone_number:change",
310 data=notification_data_pb2.PhoneNumberChange(
311 phone=phone,
312 ),
313 )
315 return empty_pb2.Empty()
317 context.abort(grpc.StatusCode.UNIMPLEMENTED, result)
319 def VerifyPhone(self, request, context, session):
320 if not sms.looks_like_a_code(request.token):
321 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.WRONG_SMS_CODE)
323 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
324 if user.phone_verification_token is None:
325 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.NO_PENDING_VERIFICATION)
327 if now() - user.phone_verification_sent > SMS_CODE_LIFETIME:
328 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.NO_PENDING_VERIFICATION)
330 if user.phone_verification_attempts > SMS_CODE_ATTEMPTS:
331 context.abort(grpc.StatusCode.RESOURCE_EXHAUSTED, errors.TOO_MANY_SMS_CODE_ATTEMPTS)
333 if not verify_token(request.token, user.phone_verification_token):
334 user.phone_verification_attempts += 1
335 session.commit()
336 context.abort(grpc.StatusCode.NOT_FOUND, errors.WRONG_SMS_CODE)
338 # Delete verifications from everyone else that has this number
339 session.execute(
340 update(User)
341 .where(User.phone == user.phone)
342 .where(User.id != context.user_id)
343 .values(
344 {
345 "phone_verification_verified": None,
346 "phone_verification_attempts": 0,
347 "phone_verification_token": None,
348 "phone": None,
349 }
350 )
351 .execution_options(synchronize_session=False)
352 )
354 user.phone_verification_token = None
355 user.phone_verification_verified = now()
356 user.phone_verification_attempts = 0
358 notify(
359 session,
360 user_id=user.id,
361 topic_action="phone_number:verify",
362 data=notification_data_pb2.PhoneNumberVerify(
363 phone=user.phone,
364 ),
365 )
367 return empty_pb2.Empty()
369 def InitiateStrongVerification(self, request, context, session):
370 if not config["ENABLE_STRONG_VERIFICATION"]:
371 context.abort(grpc.StatusCode.UNAVAILABLE, errors.STRONG_VERIFICATION_DISABLED)
373 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
374 existing_verification = session.execute(
375 select(StrongVerificationAttempt)
376 .where(StrongVerificationAttempt.user_id == user.id)
377 .where(StrongVerificationAttempt.is_valid)
378 ).scalar_one_or_none()
379 if existing_verification:
380 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.STRONG_VERIFICATION_ALREADY_VERIFIED)
382 strong_verification_initiations_counter.labels(user.gender).inc()
384 verification_attempt_token = urlsafe_secure_token()
385 # this is the iris reference data, they will return this on every callback, it also doubles as webhook auth given lack of it otherwise
386 reference = b64encode(
387 simple_encrypt(
388 "iris_callback",
389 verification_pb2.VerificationReferencePayload(
390 verification_attempt_token=verification_attempt_token,
391 user_id=user.id,
392 ).SerializeToString(),
393 )
394 )
395 response = requests.post(
396 "https://passportreader.app/api/v1/session.create",
397 auth=(config["IRIS_ID_PUBKEY"], config["IRIS_ID_SECRET"]),
398 json={
399 "callback_url": f"{config['BACKEND_BASE_URL']}/iris/webhook",
400 "face_verification": False,
401 "reference": reference,
402 },
403 timeout=10,
404 )
405 if response.status_code != 200:
406 raise Exception(f"Iris didn't return 200: {response.text}")
407 iris_session_id = response.json()["id"]
408 token = response.json()["token"]
409 url = f"iris:///?token={token}"
410 verification_attempt = StrongVerificationAttempt(
411 user_id=user.id,
412 verification_attempt_token=verification_attempt_token,
413 iris_session_id=iris_session_id,
414 iris_token=token,
415 )
416 session.add(verification_attempt)
418 return account_pb2.InitiateStrongVerificationRes(
419 verification_attempt_token=verification_attempt_token,
420 iris_url=url,
421 )
423 def GetStrongVerificationAttemptStatus(self, request, context, session):
424 verification_attempt = session.execute(
425 select(StrongVerificationAttempt)
426 .where(StrongVerificationAttempt.user_id == context.user_id)
427 .where(StrongVerificationAttempt.is_visible)
428 .where(StrongVerificationAttempt.verification_attempt_token == request.verification_attempt_token)
429 ).scalar_one_or_none()
430 if not verification_attempt:
431 context.abort(grpc.StatusCode.NOT_FOUND, errors.STRONG_VERIFICATION_ATTEMPT_NOT_FOUND)
432 status_to_pb = {
433 StrongVerificationAttemptStatus.succeeded: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_SUCCEEDED,
434 StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_TO_OPEN_APP,
435 StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_IN_APP,
436 StrongVerificationAttemptStatus.in_progress_waiting_on_backend: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_BACKEND,
437 StrongVerificationAttemptStatus.failed: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_FAILED,
438 }
439 return account_pb2.GetStrongVerificationAttemptStatusRes(
440 status=status_to_pb.get(
441 verification_attempt.status, account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_UNKNOWN
442 ),
443 )
445 def DeleteStrongVerificationData(self, request, context, session):
446 verification_attempts = (
447 session.execute(
448 select(StrongVerificationAttempt)
449 .where(StrongVerificationAttempt.user_id == context.user_id)
450 .where(StrongVerificationAttempt.has_full_data)
451 )
452 .scalars()
453 .all()
454 )
455 for verification_attempt in verification_attempts:
456 verification_attempt.status = StrongVerificationAttemptStatus.deleted
457 verification_attempt.has_full_data = False
458 verification_attempt.passport_encrypted_data = None
459 verification_attempt.passport_date_of_birth = None
460 verification_attempt.passport_sex = None
461 session.flush()
462 # double check:
463 verification_attempts = (
464 session.execute(
465 select(StrongVerificationAttempt)
466 .where(StrongVerificationAttempt.user_id == context.user_id)
467 .where(StrongVerificationAttempt.has_full_data)
468 )
469 .scalars()
470 .all()
471 )
472 assert len(verification_attempts) == 0
474 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
475 strong_verification_data_deletions_counter.labels(user.gender).inc()
477 return empty_pb2.Empty()
479 def DeleteAccount(self, request, context, session):
480 """
481 Triggers email with token to confirm deletion
483 Frontend should confirm via unique string (i.e. username) before this is called
484 """
485 if not request.confirm:
486 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.MUST_CONFIRM_ACCOUNT_DELETE)
488 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
490 reason = request.reason.strip()
491 if reason:
492 reason = AccountDeletionReason(user_id=user.id, reason=reason)
493 session.add(reason)
494 session.flush()
495 send_account_deletion_report_email(session, reason)
497 token = AccountDeletionToken(token=urlsafe_secure_token(), user=user, expiry=now() + timedelta(hours=2))
499 notify(
500 session,
501 user_id=user.id,
502 topic_action="account_deletion:start",
503 data=notification_data_pb2.AccountDeletionStart(
504 deletion_token=token.token,
505 ),
506 )
507 session.add(token)
509 account_deletion_initiations_counter.labels(user.gender).inc()
511 return empty_pb2.Empty()
513 def ListModNotes(self, request, context, session):
514 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
516 notes = (
517 session.execute(select(ModNote).where(ModNote.user_id == user.id).order_by(ModNote.created.asc()))
518 .scalars()
519 .all()
520 )
522 return account_pb2.ListModNotesRes(mod_notes=[mod_note_to_pb(note) for note in notes])
524 def ListActiveSessions(self, request, context, session):
525 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
526 page_token = dt_from_page_token(request.page_token) if request.page_token else now()
528 user_sessions = (
529 session.execute(
530 select(UserSession)
531 .where(UserSession.user_id == context.user_id)
532 .where(UserSession.is_valid)
533 .where(UserSession.is_api_key == False)
534 .where(UserSession.last_seen <= page_token)
535 .order_by(UserSession.last_seen.desc())
536 .limit(page_size + 1)
537 )
538 .scalars()
539 .all()
540 )
542 (token, token_expiry) = context.token
544 def _active_session_to_pb(user_session):
545 user_agent = user_agents_parse(user_session.user_agent or "")
546 return account_pb2.ActiveSession(
547 created=Timestamp_from_datetime(user_session.created),
548 expiry=Timestamp_from_datetime(user_session.expiry),
549 last_seen=Timestamp_from_datetime(user_session.last_seen),
550 operating_system=user_agent.os.family,
551 browser=user_agent.browser.family,
552 device=user_agent.device.family,
553 approximate_location=geoip_approximate_location(user_session.ip_address) or "Unknown",
554 is_current_session=user_session.token == token,
555 )
557 return account_pb2.ListActiveSessionsRes(
558 active_sessions=list(map(_active_session_to_pb, user_sessions[:page_size])),
559 next_page_token=dt_to_page_token(user_sessions[-1].last_seen) if len(user_sessions) > page_size else None,
560 )
562 def LogOutSession(self, request, context, session):
563 (token, token_expiry) = context.token
565 session.execute(
566 update(UserSession)
567 .where(UserSession.token != token)
568 .where(UserSession.user_id == context.user_id)
569 .where(UserSession.is_valid)
570 .where(UserSession.is_api_key == False)
571 .where(UserSession.created == to_aware_datetime(request.created))
572 .values(expiry=func.now())
573 .execution_options(synchronize_session=False)
574 )
575 return empty_pb2.Empty()
577 def LogOutOtherSessions(self, request, context, session):
578 if not request.confirm:
579 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.MUST_CONFIRM_LOGOUT_OTHER_SESSIONS)
581 (token, token_expiry) = context.token
583 session.execute(
584 update(UserSession)
585 .where(UserSession.token != token)
586 .where(UserSession.user_id == context.user_id)
587 .where(UserSession.is_valid)
588 .where(UserSession.is_api_key == False)
589 .values(expiry=func.now())
590 .execution_options(synchronize_session=False)
591 )
592 return empty_pb2.Empty()
595class Iris(iris_pb2_grpc.IrisServicer):
596 def Webhook(self, request, context, session):
597 json_data = json.loads(request.data)
598 reference_payload = verification_pb2.VerificationReferencePayload.FromString(
599 simple_decrypt("iris_callback", b64decode(json_data["session_referenace"]))
600 )
601 # if we make it past the decrypt, we consider this webhook authenticated
602 verification_attempt_token = reference_payload.verification_attempt_token
603 user_id = reference_payload.user_id
605 verification_attempt = session.execute(
606 select(StrongVerificationAttempt)
607 .where(StrongVerificationAttempt.user_id == reference_payload.user_id)
608 .where(StrongVerificationAttempt.verification_attempt_token == reference_payload.verification_attempt_token)
609 .where(StrongVerificationAttempt.iris_session_id == json_data["session_id"])
610 ).scalar_one()
611 iris_status = json_data["session_state"]
612 session.add(
613 StrongVerificationCallbackEvent(
614 verification_attempt_id=verification_attempt.id,
615 iris_status=iris_status,
616 )
617 )
618 if iris_status == "INITIATED":
619 # the user opened the session in the app
620 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app
621 elif iris_status == "COMPLETED":
622 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend
623 elif iris_status == "APPROVED":
624 strong_verification_completions_counter.inc()
625 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend
626 session.commit()
627 # background worker will go and sort this one out
628 queue_job(
629 session,
630 job_type="finalize_strong_verification",
631 payload=jobs_pb2.FinalizeStrongVerificationPayload(verification_attempt_id=verification_attempt.id),
632 )
633 elif iris_status in ["FAILED", "ABORTED", "REJECTED"]:
634 verification_attempt.status = StrongVerificationAttemptStatus.failed
636 return httpbody_pb2.HttpBody(
637 content_type="application/json",
638 # json.dumps escapes non-ascii characters
639 data=json.dumps({"success": True}).encode("ascii"),
640 )