Coverage for app/backend/src/couchers/models/verification.py: 100%

73 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-19 15:47 +0000

1import enum 

2from datetime import date, datetime 

3from typing import TYPE_CHECKING, Any 

4 

5from sqlalchemy import ( 

6 BigInteger, 

7 Boolean, 

8 CheckConstraint, 

9 Date, 

10 DateTime, 

11 Enum, 

12 ForeignKey, 

13 Index, 

14 String, 

15 func, 

16) 

17from sqlalchemy import LargeBinary as Binary 

18from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property 

19from sqlalchemy.orm import Mapped, column_property, mapped_column, relationship 

20from sqlalchemy.sql.elements import ColumnElement 

21 

22from couchers.models.base import Base 

23from couchers.utils import date_in_timezone, now 

24 

25if TYPE_CHECKING: 

26 from couchers.models.users import User 

27 

28 

29class StrongVerificationAttemptStatus(enum.Enum): 

30 ## full data states 

31 # completed, this now provides verification for a user 

32 succeeded = enum.auto() 

33 

34 ## no data states 

35 # in progress: waiting for the user to scan the Iris code or open the app 

36 in_progress_waiting_on_user_to_open_app = enum.auto() 

37 # in progress: waiting for the user to scan MRZ or NFC/chip 

38 in_progress_waiting_on_user_in_app = enum.auto() 

39 # in progress, waiting for backend to pull verification data 

40 in_progress_waiting_on_backend = enum.auto() 

41 # failed, no data 

42 failed = enum.auto() 

43 

44 # duplicate, at our end, has data 

45 duplicate = enum.auto() 

46 

47 ## minimal data states 

48 # the data, except minimal deduplication data, was deleted 

49 deleted = enum.auto() 

50 

51 

52class PassportSex(enum.Enum): 

53 """ 

54 We don't care about sex, we use gender on the platform. But passports apparently do. 

55 """ 

56 

57 male = enum.auto() 

58 female = enum.auto() 

59 unspecified = enum.auto() 

60 

61 

62class StrongVerificationAttempt(Base, kw_only=True): 

63 """ 

64 An attempt to perform strong verification 

65 """ 

66 

67 __tablename__ = "strong_verification_attempts" 

68 

69 # our verification id 

70 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

71 

72 # this is returned in the callback, and we look up the attempt via this 

73 verification_attempt_token: Mapped[str] = mapped_column(String, unique=True) 

74 

75 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 

76 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

77 

78 status: Mapped[StrongVerificationAttemptStatus] = mapped_column( 

79 Enum(StrongVerificationAttemptStatus), 

80 default=StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app, 

81 ) 

82 

83 ## full data 

84 has_full_data: Mapped[bool] = mapped_column(Boolean, default=False) 

85 # the data returned from iris, encrypted with a public key whose private key is kept offline 

86 passport_encrypted_data: Mapped[bytes | None] = mapped_column(Binary, default=None) 

87 passport_date_of_birth: Mapped[date | None] = mapped_column(Date, default=None) 

88 passport_sex: Mapped[PassportSex | None] = mapped_column(Enum(PassportSex), default=None) 

89 

90 ## minimal data: this will not be deleted 

91 has_minimal_data: Mapped[bool] = mapped_column(Boolean, default=False) 

92 passport_expiry_date: Mapped[date | None] = mapped_column(Date, default=None) 

93 passport_nationality: Mapped[str | None] = mapped_column(String, default=None) 

94 # last three characters of the passport number 

95 passport_last_three_document_chars: Mapped[str | None] = mapped_column(String, default=None) 

96 

97 iris_token: Mapped[str] = mapped_column(String, unique=True) 

98 iris_session_id: Mapped[int] = mapped_column(BigInteger, unique=True) 

99 

100 passport_expiry_datetime = column_property(date_in_timezone(passport_expiry_date, "Etc/UTC")) 

101 

102 user: Mapped[User] = relationship(init=False) 

103 

104 @hybrid_property 

105 def is_valid(self) -> bool: 

106 """ 

107 This only checks whether the attempt is a success and the passport is not expired, use `has_strong_verification` for full check 

108 """ 

109 return (self.status == StrongVerificationAttemptStatus.succeeded) and (self.passport_expiry_datetime >= now()) 

110 

111 @is_valid.inplace.expression 

112 @classmethod 

113 def _is_valid_expression(cls) -> ColumnElement[bool]: 

114 return (cls.status == StrongVerificationAttemptStatus.succeeded) & ( 

115 func.coalesce(cls.passport_expiry_datetime >= func.now(), False) 

116 ) 

117 

118 @hybrid_property 

119 def is_visible(self) -> bool: 

120 return self.status != StrongVerificationAttemptStatus.deleted 

121 

122 @hybrid_method 

123 def _raw_birthdate_match(self, user: User | type[User]) -> Any: 

124 """Does not check whether the SV attempt itself is not expired""" 

125 return self.passport_date_of_birth == user.birthdate 

126 

127 @hybrid_method 

128 def matches_birthdate(self, user: User | type[User]) -> Any: 

129 return self.is_valid & self._raw_birthdate_match(user) 

130 

131 @hybrid_method 

132 def _raw_gender_match(self, user: User | type[User]) -> Any: 

133 """Does not check whether the SV attempt itself is not expired""" 

134 return ( 

135 ((user.gender == "Woman") & (self.passport_sex == PassportSex.female)) # type: ignore[operator] 

136 | ((user.gender == "Man") & (self.passport_sex == PassportSex.male)) 

137 | (self.passport_sex == PassportSex.unspecified) 

138 | (user.has_passport_sex_gender_exception == True) 

139 ) 

140 

141 @hybrid_method 

142 def matches_gender(self, user: User | type[User]) -> Any: 

143 return self.is_valid & self._raw_gender_match(user) 

144 

145 @hybrid_method 

146 def has_strong_verification(self, user: User | type[User]) -> Any: 

147 # an attempt only verifies its own user: as a SQL expression there's no control flow to carry that, so the 

148 # identity check has to be part of the predicate or a caller can pair any attempt with any user 

149 return ( 

150 (self.user_id == user.id) & self.is_valid & self._raw_birthdate_match(user) & self._raw_gender_match(user) 

151 ) 

152 

153 __table_args__ = ( 

154 # used to look up verification status for a user 

155 Index( 

156 "ix_strong_verification_attempts_current", 

157 user_id, 

158 passport_expiry_date, 

159 postgresql_where=status == StrongVerificationAttemptStatus.succeeded, 

160 ), 

161 # each passport can be verified only once 

162 Index( 

163 "ix_strong_verification_attempts_unique_succeeded", 

164 passport_expiry_date, 

165 passport_nationality, 

166 passport_last_three_document_chars, 

167 unique=True, 

168 postgresql_where=( 

169 (status == StrongVerificationAttemptStatus.succeeded) 

170 | (status == StrongVerificationAttemptStatus.deleted) 

171 ), 

172 ), 

173 # full data check 

174 CheckConstraint( 

175 "(has_full_data IS TRUE AND passport_encrypted_data IS NOT NULL AND passport_date_of_birth IS NOT NULL) OR \ 

176 (has_full_data IS FALSE AND passport_encrypted_data IS NULL AND passport_date_of_birth IS NULL)", 

177 name="full_data_status", 

178 ), 

179 # minimal data check 

180 CheckConstraint( 

181 "(has_minimal_data IS TRUE AND passport_expiry_date IS NOT NULL AND passport_nationality IS NOT NULL AND passport_last_three_document_chars IS NOT NULL) OR \ 

182 (has_minimal_data IS FALSE AND passport_expiry_date IS NULL AND passport_nationality IS NULL AND passport_last_three_document_chars IS NULL)", 

183 name="minimal_data_status", 

184 ), 

185 # note on implications: p => q iff ~p OR q 

186 # full data implies minimal data, has_minimal_data => has_full_data 

187 CheckConstraint( 

188 "(has_full_data IS FALSE) OR (has_minimal_data IS TRUE)", 

189 name="full_data_implies_minimal_data", 

190 ), 

191 # succeeded implies full data 

192 CheckConstraint( 

193 "(NOT (status = 'succeeded')) OR (has_full_data IS TRUE)", 

194 name="succeeded_implies_full_data", 

195 ), 

196 # in_progress/failed implies no_data 

197 CheckConstraint( 

198 "(NOT ((status = 'in_progress_waiting_on_user_to_open_app') OR (status = 'in_progress_waiting_on_user_in_app') OR (status = 'in_progress_waiting_on_backend') OR (status = 'failed'))) OR (has_minimal_data IS FALSE)", 

199 name="in_progress_failed_iris_implies_no_data", 

200 ), 

201 # deleted or duplicate implies minimal data 

202 CheckConstraint( 

203 "(NOT ((status = 'deleted') OR (status = 'duplicate'))) OR (has_minimal_data IS TRUE)", 

204 name="deleted_duplicate_implies_minimal_data", 

205 ), 

206 ) 

207 

208 

209class StrongVerificationCallbackEvent(Base, kw_only=True): 

210 __tablename__ = "strong_verification_callback_events" 

211 

212 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False) 

213 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False) 

214 

215 verification_attempt_id: Mapped[int] = mapped_column(ForeignKey("strong_verification_attempts.id"), index=True) 

216 

217 iris_status: Mapped[str] = mapped_column(String)