Coverage for functions \ flipdare \ service \ external_account_service.py: 86%

120 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-05-08 12:22 +1000

1#!/usr/bin/env python 

2# Copyright (c) 2026 Flipdare Pty Ltd. All rights reserved. 

3# 

4# This file is part of Flipdare's proprietary software and contains 

5# confidential and copyrighted material. Unauthorised copying, 

6# modification, distribution, or use of this file is strictly 

7# prohibited without prior written permission from Flipdare Pty Ltd. 

8# 

9# This software includes third-party components licensed under MIT, 

10# BSD, and Apache 2.0 licences. See THIRD_PARTY_NOTICES for details. 

11# 

12 

13from __future__ import annotations 

14from typing import TYPE_CHECKING, Any 

15import flask 

16from flipdare.app_log import LOG 

17from flipdare.mailer.user.delete_account_email import DeleteAccountEmail 

18from flipdare.error.app_error import AppError 

19from flipdare.generated.schema.error.error_email_schema import ErrorEmailSchema 

20from flipdare.service._error_mixin import ErrorMixin 

21from flipdare.generated.shared.app_error_code import AppErrorCode 

22from flipdare.message.error_message import ErrorMessage 

23from flipdare.message.user_message import UserMessage 

24from flipdare.request.app_request import AppRequest 

25from flipdare.request.data.delete_request_adapter import ( 

26 DeleteConfirmRequestAdapter, 

27 DeleteRequestAdapter, 

28) 

29from flipdare.request.data.unsubscribe_request_adapter import UnsubscribeRequestAdapter 

30from flipdare.request.request_types import AppHttpRequestType 

31from flipdare.core.app_response import AppErrorResponse, AppOkResponse 

32from flipdare.service._service_provider import ServiceProvider 

33from flipdare.service._user_mixin import UserMixin 

34from flipdare.util.code_generator import CodeGenerator 

35from flipdare.wrapper.user_wrapper import UserWrapper 

36 

37if TYPE_CHECKING: 

38 from flipdare.manager.db_manager import DbManager 

39 from flipdare.manager.backend_manager import BackendManager 

40 from flipdare.manager.service_manager import ServiceManager 

41 

42 

43class ExternalAccountService(UserMixin, ErrorMixin, ServiceProvider): 

44 def __init__( 

45 self, 

46 db_manager: DbManager | None = None, 

47 backend_manager: BackendManager | None = None, 

48 service_manager: ServiceManager | None = None, 

49 ) -> None: 

50 super().__init__( 

51 backend_manager=backend_manager, 

52 db_manager=db_manager, 

53 service_manager=service_manager, 

54 ) 

55 

56 def unsubscribe(self, request: AppRequest[Any]) -> flask.Response: 

57 # this is remote so we need to check the honeypot_field 

58 # If honeypot_field is NOT empty: Reject the submission immediately 

59 # (do not process the unsubscribe request). 

60 # If honeypot_field IS empty: Proceed with normal processing. 

61 endpoint = request.endpoint 

62 

63 unsubscribe_data = UnsubscribeRequestAdapter.from_http( 

64 request.raw_request, 

65 req_type=AppHttpRequestType.UNSUBSCRIBE, 

66 ) 

67 

68 try: 

69 unsubscribe_data.validate() 

70 except AppError as error: 

71 LOG().error(f"Error validating unsubscribe request: {error}") 

72 return self.http_validation_error( 

73 endpoint=endpoint, 

74 error=error, 

75 user_error_code=unsubscribe_data.user_error_code, 

76 notify_admin=False, 

77 ) 

78 

79 email = unsubscribe_data.email 

80 endpoint = request.endpoint 

81 try: 

82 user = self.get_user_by_email(endpoint, email) 

83 user.email_notifs_enabled = False 

84 self.update_user( 

85 endpoint=endpoint, 

86 user=user, 

87 on_error_msg=ErrorMessage.UNSUBSCRIBE_ERROR, 

88 ) 

89 return AppOkResponse.message( 

90 message=UserMessage.UNSUBSCRIBE_OK, 

91 ).raw_response() 

92 except Exception as error: 

93 msg = f"Unexpected error in unsubscribe for email {email}: {error}" 

94 LOG().error(msg) 

95 return self.http_request_error( 

96 endpoint=endpoint, 

97 notify_admin=False, 

98 error_code=AppErrorCode.SERVER, 

99 message=msg, 

100 error=error, 

101 ) 

102 

103 def delete(self, request: AppRequest[Any]) -> flask.Response: 

104 # this is remote so we need to check the honeypot_field 

105 # If honeypot_field is NOT empty: Reject the submission immediately 

106 # (do not process the unsubscribe request). 

107 # If honeypot_field IS empty: Proceed with normal processing. 

108 req = request.raw_request 

109 endpoint = request.endpoint 

110 

111 delete_data = DeleteRequestAdapter.from_http(req, req_type=AppHttpRequestType.DELETE) 

112 

113 try: 

114 delete_data.validate() 

115 except AppError as error: 

116 msg = f"Error validating delete request for endpoint {endpoint}: {error}" 

117 return self.http_validation_error( 

118 endpoint=endpoint, 

119 error=error, 

120 user_error_code=delete_data.user_error_code, 

121 ) 

122 

123 url = req.url 

124 email = delete_data.email 

125 user: UserWrapper | None = None 

126 

127 try: 

128 user = self.get_user_by_email(url, email) 

129 delete_code = CodeGenerator.instance().delete_code() 

130 user.delete_code = delete_code 

131 self.update_user( 

132 endpoint=endpoint, 

133 user=user, 

134 on_error_msg=ErrorMessage.DELETE_ACCOUNT_ERROR, 

135 ) 

136 except AppError as error: 

137 return AppErrorResponse.from_context(error.context).raw_response() 

138 except Exception as error: 

139 msg = f"Unexpected error in delete for email {email}: {error}" 

140 LOG().error(msg) 

141 return self.http_request_error( 

142 endpoint=endpoint, 

143 notify_admin=False, 

144 error_code=AppErrorCode.SERVER, 

145 message=msg, 

146 error=error, 

147 ) 

148 

149 assert user is not None # type narrowing, if we got here user should be set 

150 

151 LOG().info(f"Sending delete request for {email}") 

152 try: 

153 email_template = DeleteAccountEmail.delete_confirm(to_user=user) 

154 # first we send the email, and update if the email is sent ok 

155 self.user_mailer.send(user=user, email_template=email_template, notif_check=False) 

156 LOG().info(f"Delete request email sent for {email}") 

157 return AppOkResponse.message(UserMessage.DELETE_OK).raw_response() 

158 except Exception as error: 

159 msg = f"Error creating delete request for email {email}: {error}" 

160 LOG().error(msg) 

161 return self.http_request_error( 

162 endpoint=endpoint, 

163 notify_admin=False, 

164 error_code=AppErrorCode.SERVER, 

165 message=msg, 

166 error=error, 

167 ) 

168 

169 def delete_confirm(self, request: AppRequest[Any]) -> flask.Response: 

170 # this is remote so we need to check the honeypot_field 

171 # If honeypot_field is NOT empty: Reject the submission immediately 

172 # (do not process the unsubscribe request). 

173 # If honeypot_field IS empty: Proceed with normal processing. 

174 req = request.raw_request 

175 endpoint = request.endpoint 

176 

177 confirm_data = DeleteConfirmRequestAdapter.from_http( 

178 req, 

179 req_type=AppHttpRequestType.DELETE_CONFIRM, 

180 ) 

181 

182 try: 

183 confirm_data.validate() 

184 except AppError as error: 

185 msg = f"Error validating delete confirm request for endpoint {endpoint}: {error}" 

186 return self.http_validation_error( 

187 endpoint=endpoint, 

188 error=error, 

189 user_error_code=confirm_data.user_error_code, 

190 ) 

191 

192 endpoint = req.url 

193 email = confirm_data.email 

194 code = confirm_data.delete_code 

195 

196 if code == "": 

197 LOG().error(f"Missing delete code for user {email}") 

198 msg = ErrorMessage.MISSING_REQUEST_PIN_CODE 

199 return self.http_request_error( 

200 endpoint=endpoint, 

201 notify_admin=False, 

202 error_code=AppErrorCode.INVALID_DATA, 

203 message=msg, 

204 ) 

205 

206 user: UserWrapper | None = None 

207 try: 

208 user = self.get_user_by_email(endpoint, email) 

209 except AppError as error: 

210 LOG().error(f"Error validating delete confirm request for email {email}: {error}") 

211 return AppErrorResponse.from_context(error.context).raw_response() 

212 except Exception as error: 

213 LOG().error(f"Unexpected error in delete confirm for email {email}: {error}") 

214 msg = ErrorMessage.INVALID_EMAIL.formatted(ErrorEmailSchema(email=email)) 

215 return self.http_request_error( 

216 endpoint=endpoint, 

217 notify_admin=False, 

218 error_code=AppErrorCode.SERVER, 

219 message=msg, 

220 error=error, 

221 ) 

222 

223 assert user is not None # type narrowing, if we got here user should be set 

224 

225 if user.delete_code is None: 

226 LOG().error(f"Missing server delete code for user {email}") 

227 msg = ErrorMessage.MISSING_SERVER_PIN_CODE 

228 return self.http_request_error( 

229 endpoint=endpoint, 

230 notify_admin=False, 

231 error_code=AppErrorCode.INVALID_DATA, 

232 message=msg, 

233 ) 

234 

235 actual_code = user.delete_code 

236 if str(actual_code).strip() != str(code).strip(): 

237 # NOTE: its safer to compare as strings 

238 LOG().error(f"Invalid delete code for user {email}: exp {actual_code} got {code}") 

239 msg = ErrorMessage.PIN_CODE_MISMATCH 

240 return self.http_request_error( 

241 endpoint=endpoint, 

242 notify_admin=False, 

243 error_code=AppErrorCode.PIN_CODE_MISMATCH, 

244 message=msg, 

245 ) 

246 

247 # NOTE: because of data retention policies for fraud and criminal investigations, 

248 # NOTE; we need to keep this data 

249 # NOTE: so we set a random password for the user and append 

250 # NOTE: an _underscore delete to the email 

251 try: 

252 updates = {"email": f"{email}_deleted"} 

253 self.update_user( 

254 endpoint=endpoint, 

255 user=user, 

256 on_error_msg=ErrorMessage.DELETE_PARTIAL_FAILED, 

257 manual_updates=updates, 

258 ) 

259 LOG().info(f"User {email} deleted successfully") 

260 return AppOkResponse.message(UserMessage.DELETE_CONFIRM_OK).raw_response() 

261 except Exception as error: 

262 LOG().error(f"Error deleting user {email}: {error}") 

263 return self.http_request_error( 

264 endpoint=endpoint, 

265 notify_admin=False, 

266 error_code=AppErrorCode.SERVER, 

267 message=ErrorMessage.DELETE_ACCOUNT_ERROR, 

268 error=error, 

269 )