diff options
author | Antoine A <> | 2024-01-03 01:54:21 +0000 |
---|---|---|
committer | Antoine A <> | 2024-01-03 01:54:21 +0000 |
commit | 95a2a1dfabbb5c6f86adb5fb5244886370252c9a (patch) | |
tree | aa6c2d7969e5ba6e6662203f1b6bfc666f0a88b8 | |
parent | 7f1d0c909e29c0d0a0880dffce7a572f13726b9c (diff) | |
download | libeufin-95a2a1dfabbb5c6f86adb5fb5244886370252c9a.tar.gz libeufin-95a2a1dfabbb5c6f86adb5fb5244886370252c9a.tar.bz2 libeufin-95a2a1dfabbb5c6f86adb5fb5244886370252c9a.zip |
2fa for cashout and remove obsolete cashout tan challenge logic
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt | 174 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 10 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt | 167 | ||||
-rw-r--r-- | bank/src/test/kotlin/CoreBankApiTest.kt | 259 | ||||
-rw-r--r-- | bank/src/test/kotlin/DatabaseTest.kt | 98 | ||||
-rw-r--r-- | bank/src/test/kotlin/helpers.kt | 8 | ||||
-rw-r--r-- | database-versioning/libeufin-bank-0001.sql | 2 | ||||
-rw-r--r-- | database-versioning/libeufin-bank-0002.sql | 13 | ||||
-rw-r--r-- | database-versioning/libeufin-bank-procedures.sql | 266 | ||||
-rw-r--r-- | integration/test/IntegrationTest.kt | 8 |
10 files changed, 150 insertions, 855 deletions
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt index fcbe3a36..985ba707 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt @@ -562,134 +562,53 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { } } +suspend fun ApplicationCall.cashoutHttp(db: Database, ctx: BankConfig, req: CashoutRequest, is2fa: Boolean) { + ctx.checkRegionalCurrency(req.amount_debit) + ctx.checkFiatCurrency(req.amount_credit) + + val res = db.cashout.create( + login = username, + requestUid = req.request_uid, + amountDebit = req.amount_debit, + amountCredit = req.amount_credit, + subject = req.subject ?: "", // TODO default subject + now = Instant.now(), + is2fa = is2fa + ) + when (res) { + CashoutCreationResult.AccountNotFound -> throw unknownAccount(username) + CashoutCreationResult.BadConversion -> throw conflict( + "Wrong currency conversion", + TalerErrorCode.BANK_BAD_CONVERSION + ) + CashoutCreationResult.AccountIsExchange -> throw conflict( + "Exchange account cannot perform cashout operation", + TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE + ) + CashoutCreationResult.BalanceInsufficient -> throw conflict( + "Insufficient funds to withdraw with Taler", + TalerErrorCode.BANK_UNALLOWED_DEBIT + ) + CashoutCreationResult.RequestUidReuse -> throw conflict( + "request_uid used already", + TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED + ) + CashoutCreationResult.NoCashoutPayto -> throw conflict( + "Missing cashout payto uri", + TalerErrorCode.BANK_CONFIRM_INCOMPLETE + ) + CashoutCreationResult.TanRequired -> { + respondChallenge(db, Operation.cashout, req) + } + is CashoutCreationResult.Success -> respond(CashoutResponse(res.id)) + } +} + private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = conditional(ctx.allowConversion) { auth(db, TokenScope.readwrite) { post("/accounts/{USERNAME}/cashouts") { val req = call.receive<CashoutRequest>() - - ctx.checkRegionalCurrency(req.amount_debit) - ctx.checkFiatCurrency(req.amount_credit) - - val tanChannel = req.tan_channel ?: TanChannel.sms - val tanScript = ctx.tanChannels.get(tanChannel) ?: throw libeufinError( - HttpStatusCode.NotImplemented, - "Unsupported tan channel $tanChannel", - TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED - ) - - val res = db.cashout.create( - login = username, - requestUid = req.request_uid, - amountDebit = req.amount_debit, - amountCredit = req.amount_credit, - subject = req.subject ?: "", // TODO default subject - tanChannel = tanChannel, - tanCode = Tan.genCode(), - now = Instant.now(), - retryCounter = TAN_RETRY_COUNTER, - validityPeriod = TAN_VALIDITY_PERIOD - ) - when (res) { - is CashoutCreationResult.AccountNotFound -> throw unknownAccount(username) - is CashoutCreationResult.BadConversion -> throw conflict( - "Wrong currency conversion", - TalerErrorCode.BANK_BAD_CONVERSION - ) - is CashoutCreationResult.AccountIsExchange -> throw conflict( - "Exchange account cannot perform cashout operation", - TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE - ) - is CashoutCreationResult.BalanceInsufficient -> throw conflict( - "Insufficient funds to withdraw with Taler", - TalerErrorCode.BANK_UNALLOWED_DEBIT - ) - is CashoutCreationResult.MissingTanInfo -> throw conflict( - "Account '$username' missing info for tan channel ${req.tan_channel}", - TalerErrorCode.BANK_MISSING_TAN_INFO - ) - is CashoutCreationResult.RequestUidReuse -> throw conflict( - "request_uid used already", - TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED - ) - is CashoutCreationResult.Success -> { - res.tanCode?.run { - val exitValue = withContext(Dispatchers.IO) { - val process = ProcessBuilder(tanScript, res.tanInfo).start() - try { - process.outputWriter().use { it.write(res.tanCode) } - process.onExit().await() - } catch (e: Exception) { - process.destroy() - } - process.exitValue() - } - if (exitValue != 0) { - throw libeufinError( - HttpStatusCode.BadGateway, - "Tan channel script failure with exit value $exitValue", - TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED - ) - } - db.cashout.markSent(res.id, Instant.now(), TAN_RETRANSMISSION_PERIOD, tanChannel, res.tanInfo) - } - call.respond(CashoutPending(res.id)) - } - } - } - post("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}/abort") { - val id = call.longUriComponent("CASHOUT_ID") - when (db.cashout.abort(id, username)) { - AbortResult.UnknownOperation -> throw notFound( - "Cashout operation $id not found", - TalerErrorCode.BANK_TRANSACTION_NOT_FOUND - ) - AbortResult.AlreadyConfirmed -> throw conflict( - "Cannot abort confirmed cashout", - TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT - ) - AbortResult.Success -> call.respond(HttpStatusCode.NoContent) - } - } - post("/accounts/{USERNAME}/cashouts/{CASHOUT_ID}/confirm") { - val req = call.receive<CashoutConfirm>() - val id = call.longUriComponent("CASHOUT_ID") - when (db.cashout.confirm( - id = id, - login = username, - tanCode = req.tan, - timestamp = Instant.now() - )) { - CashoutConfirmationResult.OP_NOT_FOUND -> throw notFound( - "Cashout operation $id not found", - TalerErrorCode.BANK_TRANSACTION_NOT_FOUND - ) - CashoutConfirmationResult.ABORTED -> throw conflict( - "Cannot confirm an aborted cashout", - TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT - ) - CashoutConfirmationResult.BAD_TAN_CODE -> throw conflict( - "Incorrect TAN code", - TalerErrorCode.BANK_TAN_CHALLENGE_FAILED - ) - CashoutConfirmationResult.NO_RETRY -> throw libeufinError( - HttpStatusCode.TooManyRequests, - "Too many failed confirmation attempt", - TalerErrorCode.BANK_TAN_RATE_LIMITED - ) - CashoutConfirmationResult.NO_CASHOUT_PAYTO -> throw conflict( - "Missing cashout payto uri", - TalerErrorCode.BANK_CONFIRM_INCOMPLETE - ) - CashoutConfirmationResult.BALANCE_INSUFFICIENT -> throw conflict( - "Insufficient funds", - TalerErrorCode.BANK_UNALLOWED_DEBIT - ) - CashoutConfirmationResult.BAD_CONVERSION -> throw conflict( - "Wrong currency conversion", - TalerErrorCode.BANK_BAD_CONVERSION - ) - CashoutConfirmationResult.SUCCESS -> call.respond(HttpStatusCode.NoContent) - } + call.cashoutHttp(db, ctx, req, false) } } auth(db, TokenScope.readonly) { @@ -724,7 +643,6 @@ private fun Routing.coreBankCashoutApi(db: Database, ctx: BankConfig) = conditio } } - private fun Routing.coreBankTanApi(db: Database, ctx: BankConfig) { auth(db, TokenScope.readwrite) { post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}") { @@ -774,7 +692,7 @@ private fun Routing.coreBankTanApi(db: Database, ctx: BankConfig) { } post("/accounts/{USERNAME}/challenge/{CHALLENGE_ID}/confirm") { val id = call.longUriComponent("CHALLENGE_ID") - val req = call.receive<CashoutConfirm>() + val req = call.receive<ChallengeSolve>() val res = db.tan.solve( id = id, login = username, @@ -811,6 +729,10 @@ private fun Routing.coreBankTanApi(db: Database, ctx: BankConfig) { val req = Json.decodeFromString<TransactionCreateRequest>(res.body) call.bankTransactionHttp(db, ctx, req, true) } + Operation.cashout -> { + val req = Json.decodeFromString<CashoutRequest>(res.body) + call.cashoutHttp(db, ctx, req, true) + } } } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt index e0429423..8aba10ad 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -75,7 +75,8 @@ enum class Timeframe { enum class Operation { account_reconfig, account_delete, - bank_transaction + bank_transaction, + cashout } @Serializable(with = Option.Serializer::class) @@ -478,12 +479,11 @@ data class CashoutRequest( val request_uid: ShortHashCode, val subject: String?, val amount_debit: TalerAmount, - val amount_credit: TalerAmount, - val tan_channel: TanChannel? + val amount_credit: TalerAmount ) @Serializable -data class CashoutPending( +data class CashoutResponse( val cashout_id: Long, ) @@ -524,7 +524,7 @@ data class CashoutStatusResponse( ) @Serializable -data class CashoutConfirm( +data class ChallengeSolve( val tan: String ) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt index 58d2ca03..2c70a144 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt @@ -28,14 +28,14 @@ import tech.libeufin.util.* class CashoutDAO(private val db: Database) { /** Result of cashout operation creation */ sealed class CashoutCreationResult { - /** Cashout [id] has been created or refreshed. If [tanCode] is not null, use [tanInfo] to send it via [tanChannel] then call [markSent] */ - data class Success(val id: Long, val tanInfo: String, val tanCode: String?): CashoutCreationResult() + data class Success(val id: Long): CashoutCreationResult() object BadConversion: CashoutCreationResult() object AccountNotFound: CashoutCreationResult() object AccountIsExchange: CashoutCreationResult() - object MissingTanInfo: CashoutCreationResult() object BalanceInsufficient: CashoutCreationResult() object RequestUidReuse: CashoutCreationResult() + object NoCashoutPayto: CashoutCreationResult() + object TanRequired: CashoutCreationResult() } /** Create a new cashout operation */ @@ -45,24 +45,20 @@ class CashoutDAO(private val db: Database) { amountDebit: TalerAmount, amountCredit: TalerAmount, subject: String, - tanChannel: TanChannel, - tanCode: String, now: Instant, - retryCounter: Int, - validityPeriod: Duration + is2fa: Boolean ): CashoutCreationResult = db.serializable { conn -> val stmt = conn.prepareStatement(""" SELECT out_bad_conversion, out_account_not_found, out_account_is_exchange, - out_missing_tan_info, out_balance_insufficient, out_request_uid_reuse, - out_cashout_id, - out_tan_info, - out_tan_code - FROM cashout_create(?, ?, (?,?)::taler_amount, (?,?)::taler_amount, ?, ?, ?::tan_enum, ?, ?, ?) + out_no_cashout_payto, + out_tan_required, + out_cashout_id + FROM cashout_create(?,?,(?,?)::taler_amount,(?,?)::taler_amount,?,?,?) """) stmt.setString(1, login) stmt.setBytes(2, requestUid.raw) @@ -72,10 +68,7 @@ class CashoutDAO(private val db: Database) { stmt.setInt(6, amountCredit.frac) stmt.setString(7, subject) stmt.setLong(8, now.toDbMicros() ?: throw faultyTimestampByBank()) - stmt.setString(9, tanChannel.name) - stmt.setString(10, tanCode) - stmt.setInt(11, retryCounter) - stmt.setLong(12, TimeUnit.MICROSECONDS.convert(validityPeriod)) + stmt.setBoolean(9, is2fa) stmt.executeQuery().use { when { !it.next() -> @@ -83,114 +76,11 @@ class CashoutDAO(private val db: Database) { it.getBoolean("out_bad_conversion") -> CashoutCreationResult.BadConversion it.getBoolean("out_account_not_found") -> CashoutCreationResult.AccountNotFound it.getBoolean("out_account_is_exchange") -> CashoutCreationResult.AccountIsExchange - it.getBoolean("out_missing_tan_info") -> CashoutCreationResult.MissingTanInfo it.getBoolean("out_balance_insufficient") -> CashoutCreationResult.BalanceInsufficient it.getBoolean("out_request_uid_reuse") -> CashoutCreationResult.RequestUidReuse - else -> CashoutCreationResult.Success( - id = it.getLong("out_cashout_id"), - tanInfo = it.getString("out_tan_info"), - tanCode = it.getString("out_tan_code") - ) - } - } - } - - /** Mark cashout operation [id] challenge as having being successfully sent [now] and not to be retransmit until after [retransmissionPeriod] */ - suspend fun markSent( - id: Long, - now: Instant, - retransmissionPeriod: Duration, - tanChannel: TanChannel, - tanInfo: String - ) = db.serializable { - it.transaction { conn -> - conn.prepareStatement(""" - SELECT challenge_mark_sent(challenge, ?, ?) - FROM cashout_operations - WHERE cashout_id=? - """).run { - setLong(1, now.toDbMicros() ?: throw faultyTimestampByBank()) - setLong(2, TimeUnit.MICROSECONDS.convert(retransmissionPeriod)) - setLong(3, id) - executeQueryCheck() - } - conn.prepareStatement(""" - UPDATE cashout_operations - SET tan_channel = ?, tan_info = ? - WHERE cashout_id=? - """).run { - setString(1, tanChannel.name) - setString(2, tanInfo) - setLong(3, id) - executeUpdateCheck() - } - } - } - - /** Abort cashout operation [id] owned by [login] */ - suspend fun abort(id: Long, login: String): AbortResult = db.serializable { conn -> - val stmt = conn.prepareStatement(""" - UPDATE cashout_operations - SET aborted = local_transaction IS NULL - FROM bank_accounts JOIN customers ON customer_id=owning_customer_id - WHERE cashout_id=? AND bank_account=bank_account_id AND login=? - RETURNING local_transaction IS NOT NULL - """) - stmt.setLong(1, id) - stmt.setString(2, login) - when (stmt.oneOrNull { it.getBoolean(1) }) { - null -> AbortResult.UnknownOperation - true -> AbortResult.AlreadyConfirmed - false -> AbortResult.Success - } - } - - /** Result status of cashout operation confirmation */ - enum class CashoutConfirmationResult { - SUCCESS, - BAD_CONVERSION, - OP_NOT_FOUND, - BAD_TAN_CODE, - BALANCE_INSUFFICIENT, - NO_RETRY, - NO_CASHOUT_PAYTO, - ABORTED - } - - /** Confirm cashout operation [id] owned by [login] */ - suspend fun confirm( - id: Long, - login: String, - tanCode: String, - timestamp: Instant - ): CashoutConfirmationResult = db.serializable { conn -> - val stmt = conn.prepareStatement(""" - SELECT - out_no_op, - out_bad_conversion, - out_bad_code, - out_balance_insufficient, - out_aborted, - out_no_retry, - out_no_cashout_payto - FROM cashout_confirm(?, ?, ?, ?); - """) - stmt.setLong(1, id) - stmt.setString(2, login) - stmt.setString(3, tanCode) - stmt.setLong(4, timestamp.toDbMicros() ?: throw faultyTimestampByBank()) - stmt.executeQuery().use { - when { - !it.next() -> - throw internalServerError("No result from DB procedure cashout_create") - it.getBoolean("out_no_op") -> CashoutConfirmationResult.OP_NOT_FOUND - it.getBoolean("out_bad_code") -> CashoutConfirmationResult.BAD_TAN_CODE - it.getBoolean("out_balance_insufficient") -> CashoutConfirmationResult.BALANCE_INSUFFICIENT - it.getBoolean("out_aborted") -> CashoutConfirmationResult.ABORTED - it.getBoolean("out_no_retry") -> CashoutConfirmationResult.NO_RETRY - it.getBoolean("out_no_cashout_payto") -> CashoutConfirmationResult.NO_CASHOUT_PAYTO - it.getBoolean("out_bad_conversion") -> CashoutConfirmationResult.BAD_CONVERSION - else -> CashoutConfirmationResult.SUCCESS + it.getBoolean("out_no_cashout_payto") -> CashoutCreationResult.NoCashoutPayto + it.getBoolean("out_tan_required") -> CashoutCreationResult.TanRequired + else -> CashoutCreationResult.Success(it.getLong("out_cashout_id")) } } } @@ -199,20 +89,18 @@ class CashoutDAO(private val db: Database) { suspend fun get(id: Long, login: String): CashoutStatusResponse? = db.conn { conn -> val stmt = conn.prepareStatement(""" SELECT - CASE - WHEN aborted THEN 'aborted' - WHEN local_transaction IS NOT NULL THEN 'confirmed' - ELSE 'pending' - END as status - ,(amount_debit).val as amount_debit_val + (amount_debit).val as amount_debit_val ,(amount_debit).frac as amount_debit_frac ,(amount_credit).val as amount_credit_val ,(amount_credit).frac as amount_credit_frac ,cashout_operations.subject ,creation_time ,transaction_date as confirmation_date - ,cashout_operations.tan_channel - ,tan_info + ,tan_channel + ,CASE tan_channel + WHEN 'sms' THEN phone + WHEN 'email' THEN email + END as tan_info FROM cashout_operations JOIN bank_accounts ON bank_account=bank_account_id JOIN customers ON owning_customer_id=customer_id @@ -223,7 +111,7 @@ class CashoutDAO(private val db: Database) { stmt.setString(2, login) stmt.oneOrNull { CashoutStatusResponse( - status = CashoutStatus.valueOf(it.getString("status")), + status = CashoutStatus.confirmed, amount_debit = it.getAmount("amount_debit", db.bankCurrency), amount_credit = it.getAmount("amount_credit", db.fiatCurrency!!), subject = it.getString("subject"), @@ -244,11 +132,6 @@ class CashoutDAO(private val db: Database) { SELECT cashout_id ,login - ,CASE - WHEN aborted THEN 'aborted' - WHEN local_transaction IS NOT NULL THEN 'confirmed' - ELSE 'pending' - END as status FROM cashout_operations JOIN bank_accounts ON bank_account=bank_account_id JOIN customers ON owning_customer_id=customer_id @@ -257,20 +140,14 @@ class CashoutDAO(private val db: Database) { GlobalCashoutInfo( cashout_id = it.getLong("cashout_id"), username = it.getString("login"), - status = CashoutStatus.valueOf(it.getString("status")) + status = CashoutStatus.confirmed ) } /** Get a page of all cashout operations owned by [login] */ suspend fun pageForUser(params: PageParams, login: String): List<CashoutInfo> = db.page(params, "cashout_id", """ - SELECT - cashout_id - ,CASE - WHEN aborted THEN 'aborted' - WHEN local_transaction IS NOT NULL THEN 'confirmed' - ELSE 'pending' - END as status + SELECT cashout_id FROM cashout_operations JOIN bank_accounts ON bank_account=bank_account_id JOIN customers ON owning_customer_id=customer_id @@ -283,7 +160,7 @@ class CashoutDAO(private val db: Database) { ) { CashoutInfo( cashout_id = it.getLong("cashout_id"), - status = CashoutStatus.valueOf(it.getString("status")) + status = CashoutStatus.confirmed ) } }
\ No newline at end of file diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt index 0c10f992..0a254bb4 100644 --- a/bank/src/test/kotlin/CoreBankApiTest.kt +++ b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -977,40 +977,17 @@ class CoreBankCashoutApiTest { "amount_credit" to convert("KUDOS:1") } - // Check missing TAN info + // Missing info client.postA("/accounts/customer/cashouts") { json(req) - }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO) - client.patch("/accounts/customer") { - pwAuth("admin") - json { - "contact_data" to obj { - "phone" to "+99" - "email" to "foo@example.com" - } - } - }.assertNoContent() + }.assertConflict(TalerErrorCode.BANK_CONFIRM_INCOMPLETE) - // Check email TAN error TODO use 2FA - /*client.postA("/accounts/customer/cashouts") { - json(req) { - "tan_channel" to "email" - } - }.assertStatus(HttpStatusCode.BadGateway, TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED)*/ + fillCashoutInfo("customer") // Check OK client.postA("/accounts/customer/cashouts") { json(req) - }.assertOkJson<CashoutPending> { first -> - tanCode("+99") - // Check idempotency - client.postA("/accounts/customer/cashouts") { - json(req) - }.assertOkJson<CashoutPending> { second -> - assertEquals(first.cashout_id, second.cashout_id) - assertNull(tanCode("+99")) - } - } + }.assertOkJson<CashoutResponse>() // Trigger conflict due to reused request_uid client.postA("/accounts/customer/cashouts") { @@ -1028,6 +1005,7 @@ class CoreBankCashoutApiTest { // Check insufficient fund client.postA("/accounts/customer/cashouts") { json(req) { + "request_uid" to randShortHashCode() "amount_debit" to "KUDOS:75" "amount_credit" to convert("KUDOS:75") } @@ -1051,194 +1029,19 @@ class CoreBankCashoutApiTest { "amount_credit" to "KUDOS:1" } }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) - } - - // POST /accounts/{USERNAME}/cashouts/{CASHOUT_ID}/abort - @Test - fun abort() = bankSetup { _ -> - authRoutine(HttpMethod.Post, "/accounts/merchant/cashouts/42/abort") - - fillCashoutInfo("customer") - - val req = obj { - "request_uid" to randShortHashCode() - "amount_debit" to "KUDOS:1" - "amount_credit" to convert("KUDOS:1") - } - - // Check abort created - client.postA("/accounts/customer/cashouts") { - json(req) - }.assertOkJson<CashoutPending> { - val id = it.cashout_id - - // Check OK - client.postA("/accounts/customer/cashouts/$id/abort") - .assertNoContent() - // Check idempotence - client.postA("/accounts/customer/cashouts/$id/abort") - .assertNoContent() - } - - // Check abort confirmed - client.postA("/accounts/customer/cashouts") { - json(req) { "request_uid" to randShortHashCode() } - }.assertOkJson<CashoutPending> { - val id = it.cashout_id - - client.postA("/accounts/customer/cashouts/$id/confirm") { - json { "tan" to tanCode("+99") } - }.assertNoContent() - - // Check error - client.postA("/accounts/customer/cashouts/$id/abort") - .assertConflict(TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT) - } - - // Check bad id - client.postA("/accounts/customer/cashouts/chocolate/abort") { - json { "tan" to "code" } - }.assertBadRequest() - - // Check unknown - client.postA("/accounts/customer/cashouts/42/abort") { - json { "tan" to "code" } - }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) - - // Check abort another user's operation - client.postA("/accounts/customer/cashouts") { - json(req) { "request_uid" to randShortHashCode() } - }.assertOkJson<CashoutPending> { - val id = it.cashout_id - - // Check error - client.postA("/accounts/merchant/cashouts/$id/abort") - .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) - } - } - - // POST /accounts/{USERNAME}/cashouts/{CASHOUT_ID}/confirm - @Test - fun confirm() = bankSetup { _ -> - authRoutine(HttpMethod.Post, "/accounts/merchant/cashouts/42/confirm") - - client.patch("/accounts/customer") { - pwAuth("admin") - json { - "contact_data" to obj { - "phone" to "+99" - } - } - }.assertNoContent() - - val req = obj { - "request_uid" to randShortHashCode() - "amount_debit" to "KUDOS:1" - "amount_credit" to convert("KUDOS:1") - } - // Check confirm - client.postA("/accounts/customer/cashouts") { - json(req) { "request_uid" to randShortHashCode() } - }.assertOkJson<CashoutPending> { - val id = it.cashout_id - - // Check missing cashout address - client.postA("/accounts/customer/cashouts/$id/confirm") { - json { "tan" to "code" } - }.assertConflict(TalerErrorCode.BANK_CONFIRM_INCOMPLETE) - fillCashoutInfo("customer") - - // Check bad TAN code - client.postA("/accounts/customer/cashouts/$id/confirm") { - json { "tan" to "nice-try" } - }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_FAILED) - - val code = tanCode("+99") - - // Check OK - client.postA("/accounts/customer/cashouts/$id/confirm") { - json { "tan" to code } - }.assertNoContent() - // Check idempotence - client.postA("/accounts/customer/cashouts/$id/confirm") { - json { "tan" to code } - }.assertNoContent() - } - - // Check confirm another user's operation - client.postA("/accounts/customer/cashouts") { - json(req) { - "request_uid" to randShortHashCode() - "amount_credit" to convert("KUDOS:1") - } - }.assertOkJson<CashoutPending> { - val id = it.cashout_id - - // Check error - client.postA("/accounts/merchant/cashouts/$id/confirm") { - json { "tan" to "unused" } - }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) - } - - // Check bad conversion - client.postA("/accounts/customer/cashouts") { - json(req) { "request_uid" to randShortHashCode() } - }.assertOkJson<CashoutPending> { - val id = it.cashout_id - client.post("/conversion-info/conversion-rate") { - pwAuth("admin") - json { - "cashin_ratio" to "1" - "cashin_fee" to "KUDOS:0.1" - "cashin_tiny_amount" to "KUDOS:0.0001" - "cashin_rounding_mode" to "nearest" - "cashin_min_amount" to "EUR:0.0001" - "cashout_ratio" to "1" - "cashout_fee" to "EUR:0.1" - "cashout_tiny_amount" to "EUR:0.0001" - "cashout_rounding_mode" to "nearest" - "cashout_min_amount" to "KUDOS:0.0001" - } - }.assertNoContent() - - client.postA("/accounts/customer/cashouts/$id/confirm"){ - json { "tan" to tanCode("+99") } - }.assertConflict(TalerErrorCode.BANK_BAD_CONVERSION) - - // Check can abort because not confirmed - client.postA("/accounts/customer/cashouts/$id/abort") - .assertNoContent() - } - - // Check balance insufficient + // Check 2fa + fillTanInfo("customer") + assertBalance("customer", "-KUDOS:1") client.postA("/accounts/customer/cashouts") { - json(req) { + json(req) { "request_uid" to randShortHashCode() - "amount_credit" to convert("KUDOS:1") } - }.assertOkJson<CashoutPending> { - val id = it.cashout_id - // Send too much money - tx("customer", "KUDOS:9", "merchant") - client.postA("/accounts/customer/cashouts/$id/confirm"){ - json { "tan" to tanCode("+99") } - }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) - - // Check can abort because not confirmed - client.postA("/accounts/customer/cashouts/$id/abort") - .assertNoContent() + }.assertChallenge { _,_-> + assertBalance("customer", "-KUDOS:1") + }.assertOkJson<CashoutResponse> { + assertBalance("customer", "-KUDOS:2") } - - // Check bad UUID - client.postA("/accounts/customer/cashouts/chocolate/confirm") { - json { "tan" to "code" } - }.assertBadRequest() - - // Check unknown - client.postA("/accounts/customer/cashouts/42/confirm") { - json { "tan" to "code" } - }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) } // GET /accounts/{USERNAME}/cashouts/{CASHOUT_ID} @@ -1257,41 +1060,15 @@ class CoreBankCashoutApiTest { // Check confirm client.postA("/accounts/customer/cashouts") { json(req) { "request_uid" to randShortHashCode() } - }.assertOkJson<CashoutPending> { + }.assertOkJson<CashoutResponse> { val id = it.cashout_id client.getA("/accounts/customer/cashouts/$id") .assertOkJson<CashoutStatusResponse> { - assertEquals(CashoutStatus.pending, it.status) + assertEquals(CashoutStatus.confirmed, it.status) assertEquals(amountDebit, it.amount_debit) assertEquals(amountCredit, it.amount_credit) - assertEquals(TanChannel.sms, it.tan_channel) - assertEquals("+99", it.tan_info) - } - - client.postA("/accounts/customer/cashouts/$id/confirm") { - json { "tan" to tanCode("+99") } - }.assertNoContent() - client.getA("/accounts/customer/cashouts/$id") - .assertOkJson<CashoutStatusResponse> { - assertEquals(CashoutStatus.confirmed, it.status) - } - } - - // Check abort - client.postA("/accounts/customer/cashouts") { - json(req) { "request_uid" to randShortHashCode() } - }.assertOkJson<CashoutPending> { - val id = it.cashout_id - client.getA("/accounts/customer/cashouts/$id") - .assertOkJson<CashoutStatusResponse> { - assertEquals(CashoutStatus.pending, it.status) - } - - client.postA("/accounts/customer/cashouts/$id/abort") - .assertNoContent() - client.getA("/accounts/customer/cashouts/$id") - .assertOkJson<CashoutStatusResponse> { - assertEquals(CashoutStatus.aborted, it.status) + assertNull(it.tan_channel) + assertNull(it.tan_info) } } @@ -1306,7 +1083,7 @@ class CoreBankCashoutApiTest { // Check get another user's operation client.postA("/accounts/customer/cashouts") { json(req) { "request_uid" to randShortHashCode() } - }.assertOkJson<CashoutPending> { + }.assertOkJson<CashoutResponse> { val id = it.cashout_id // Check error diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt index da7bd56f..19d552ac 100644 --- a/bank/src/test/kotlin/DatabaseTest.kt +++ b/bank/src/test/kotlin/DatabaseTest.kt @@ -69,104 +69,6 @@ class DatabaseTest { } @Test - fun challenge() = setup { db, _ -> db.conn { conn -> - val createStmt = conn.prepareStatement("SELECT challenge_create(?,?,?,?)") - val sendStmt = conn.prepareStatement("SELECT challenge_mark_sent(?,?,?)") - val tryStmt = conn.prepareStatement("SELECT ok, no_retry FROM challenge_try(?,?,?)") - val resendStmt = conn.prepareStatement("SELECT challenge_resend(?,?,?,?,?)") - - val validityPeriod = Duration.ofHours(1) - val retransmissionPeriod: Duration = Duration.ofMinutes(1) - val retryCounter = 3 - - fun create(code: String, now: Instant): Long { - createStmt.setString(1, code) - createStmt.setLong(2, ChronoUnit.MICROS.between(Instant.EPOCH, now)) - createStmt.setLong(3, TimeUnit.MICROSECONDS.convert(validityPeriod)) - createStmt.setInt(4, retryCounter) - return createStmt.oneOrNull { it.getLong(1) }!! - } - - fun send(id: Long, now: Instant) { - sendStmt.setLong(1, id) - sendStmt.setLong(2, ChronoUnit.MICROS.between(Instant.EPOCH, now)) - sendStmt.setLong(3, TimeUnit.MICROSECONDS.convert(retransmissionPeriod)) - return sendStmt.oneOrNull { }!! - } - - fun cTry(id: Long, code: String, now: Instant): Pair<Boolean, Boolean> { - tryStmt.setLong(1, id) - tryStmt.setString(2, code) - tryStmt.setLong(3, ChronoUnit.MICROS.between(Instant.EPOCH, now)) - return tryStmt.oneOrNull { - Pair(it.getBoolean(1), it.getBoolean(2)) - }!! - } - - fun resend(id: Long, code: String, now: Instant): String? { - resendStmt.setLong(1, id) - resendStmt.setString(2, code) - resendStmt.setLong(3, ChronoUnit.MICROS.between(Instant.EPOCH, now)) - resendStmt.setLong(4, TimeUnit.MICROSECONDS.convert(validityPeriod)) - resendStmt.setInt(5, retryCounter) - return resendStmt.oneOrNull { it.getString(1) } - } - - val now = Instant.now() - val expired = now + validityPeriod - val retransmit = now + retransmissionPeriod - - // Check basic - create("good-code", now).run { - // Bad code - assertEquals(Pair(false, false), cTry(this, "bad-code", now)) - // Good code - assertEquals(Pair(true, false), cTry(this, "good-code", now)) - // Never resend a confirmed challenge - assertNull(resend(this, "new-code", expired)) - // Confirmed challenge always ok - assertEquals(Pair(true, false), cTry(this, "good-code", now)) - } - - // Check retry - create("good-code", now).run { - send(this, now) - // Bad code - repeat(retryCounter) { - assertEquals(Pair(false, false), cTry(this, "bad-code", now)) - } - // Good code fail - assertEquals(Pair(false, true), cTry(this, "good-code", now)) - // New code - assertEquals("new-code", resend(this, "new-code", now)) - // Good code - assertEquals(Pair(true, false), cTry(this, "new-code", now)) - } - - // Check retransmission and expiration - create("good-code", now).run { - // Failed to send retransmit - assertEquals("good-code", resend(this, "new-code", now)) - // Code successfully sent and still valid - send(this, now) - assertNull(resend(this, "new-code", now)) - // Code is still valid but shoud be resent - assertEquals("good-code", resend(this, "new-code", retransmit)) - // Good code fail because expired - assertEquals(Pair(false, false), cTry(this, "good-code", expired)) - // New code because expired - assertEquals("new-code", resend(this, "new-code", expired)) - // Code successfully sent and still valid - send(this, expired) - assertNull(resend(this, "another-code", expired)) - // Old code no longer workds - assertEquals(Pair(false, false), cTry(this, "good-code", expired)) - // New code works - assertEquals(Pair(true, false), cTry(this, "new-code", expired)) - } - }} - - @Test fun tanChallenge() = bankSetup { db -> db.conn { conn -> val createStmt = conn.prepareStatement("SELECT tan_challenge_create('','account_reconfig'::op_enum,?,?,?,?,'customer',NULL,NULL)") val markSentStmt = conn.prepareStatement("SELECT tan_challenge_mark_sent(?,?,?)") diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt index 09eb62f3..2b8ff89a 100644 --- a/bank/src/test/kotlin/helpers.kt +++ b/bank/src/test/kotlin/helpers.kt @@ -236,11 +236,7 @@ suspend fun ApplicationTestBuilder.cashout(amount: String) { } } else { res - }.assertOkJson<CashoutPending> { - client.postA("/accounts/customer/cashouts/${it.cashout_id}/confirm") { - json { "tan" to tanCode("+99") } - }.assertNoContent() - } + }.assertOk() } /** Perform a whithrawal operation of [amount] from customer */ @@ -308,7 +304,7 @@ suspend fun tanCode(info: String): String? { /* ----- Assert ----- */ suspend fun HttpResponse.assertStatus(status: HttpStatusCode, err: TalerErrorCode?): HttpResponse { - assertEquals(status, this.status); + assertEquals(status, this.status, "$err") if (err != null) assertErr(err) return this } diff --git a/database-versioning/libeufin-bank-0001.sql b/database-versioning/libeufin-bank-0001.sql index 6bf7420a..63b39df2 100644 --- a/database-versioning/libeufin-bank-0001.sql +++ b/database-versioning/libeufin-bank-0001.sql @@ -198,7 +198,7 @@ CREATE TABLE IF NOT EXISTS cashout_operations REFERENCES challenges(challenge_id) ON DELETE CASCADE ON UPDATE RESTRICT - ,tan_channel TEXT NULL DEFAULT NULL -- TODO should be tan_enum but might be removed in the future + ,tan_channel TEXT NULL DEFAULT NULL ,tan_info TEXT NULL DEFAULT NULL ,aborted BOOLEAN NOT NULL DEFAULT FALSE ,local_transaction BIGINT UNIQUE DEFAULT NULL-- FIXME: Comment that the transaction only gets created after the TAN confirmation diff --git a/database-versioning/libeufin-bank-0002.sql b/database-versioning/libeufin-bank-0002.sql index 81f91672..ca20f040 100644 --- a/database-versioning/libeufin-bank-0002.sql +++ b/database-versioning/libeufin-bank-0002.sql @@ -18,14 +18,21 @@ BEGIN; SELECT _v.register_patch('libeufin-bank-0002', NULL, NULL); SET search_path TO libeufin_bank; --- TODO remove challenges table --- TODO remove challenge and status columns from cashout_operations +-- TODO drop pending cashout operations +ALTER TABLE cashout_operations + DROP COLUMN challenge, + DROP COLUMN tan_channel, + DROP COLUMN tan_info, + DROP COLUMN aborted, + ALTER COLUMN local_transaction SET NOT NULL; + +DROP TABLE challenges; ALTER TABLE customers ADD tan_channel tan_enum NULL; CREATE TYPE op_enum - AS ENUM ('account_reconfig', 'account_delete', 'bank_transaction'); + AS ENUM ('account_reconfig', 'account_delete', 'bank_transaction', 'cashout'); CREATE TABLE tan_challenges (challenge_id INT8 GENERATED BY DEFAULT AS IDENTITY UNIQUE diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql index 1a2ba2c8..b84ef912 100644 --- a/database-versioning/libeufin-bank-procedures.sql +++ b/database-versioning/libeufin-bank-procedures.sql @@ -153,7 +153,7 @@ my_customer_id BIGINT; my_balance_val INT8; my_balance_frac INT4; BEGIN --- check if login exists +-- check if login exists and if 2FA is required SELECT customer_id, (NOT in_is_tan AND tan_channel IS NOT NULL) INTO my_customer_id, out_tan_required FROM customers @@ -1034,26 +1034,23 @@ CREATE FUNCTION cashout_create( IN in_amount_credit taler_amount, IN in_subject TEXT, IN in_now_date INT8, - IN in_tan_channel tan_enum, - IN in_tan_code TEXT, - IN in_retry_counter INT4, - IN in_validity_period INT8, + IN in_is_tan BOOLEAN, -- Error status OUT out_bad_conversion BOOLEAN, OUT out_account_not_found BOOLEAN, OUT out_account_is_exchange BOOLEAN, - OUT out_missing_tan_info BOOLEAN, OUT out_balance_insufficient BOOLEAN, OUT out_request_uid_reuse BOOLEAN, + OUT out_no_cashout_payto BOOLEAN, + OUT out_tan_required BOOLEAN, -- Success return - OUT out_cashout_id BIGINT, - OUT out_tan_info TEXT, - OUT out_tan_code TEXT + OUT out_cashout_id BIGINT ) LANGUAGE plpgsql AS $$ DECLARE account_id BIGINT; -challenge_id BIGINT; +admin_account_id BIGINT; +tx_id BIGINT; BEGIN -- check conversion SELECT too_small OR no_config OR in_amount_credit!=converted INTO out_bad_conversion FROM conversion_to(in_amount_debit, 'cashout'::text); @@ -1061,152 +1058,48 @@ IF out_bad_conversion THEN RETURN; END IF; --- check account exists and has appropriate tan info +-- check account exists, if has all info and if 2FA is required SELECT - bank_account_id, is_taler_exchange, - CASE in_tan_channel - WHEN 'sms' THEN phone - WHEN 'email' THEN email - END - INTO account_id, out_account_is_exchange, out_tan_info + bank_account_id, is_taler_exchange, cashout_payto IS NULL, (NOT in_is_tan AND tan_channel IS NOT NULL) + INTO account_id, out_account_is_exchange, out_no_cashout_payto, out_tan_required FROM bank_accounts JOIN customers ON bank_accounts.owning_customer_id = customers.customer_id WHERE login=in_account_username; IF NOT FOUND THEN out_account_not_found=TRUE; RETURN; -ELSIF out_account_is_exchange THEN - RETURN; -ELSIF out_tan_info IS NULL THEN - out_missing_tan_info=TRUE; +ELSIF out_account_is_exchange OR out_no_cashout_payto THEN RETURN; END IF; --- check enough funds -SELECT account_balance_is_sufficient(account_id, in_amount_debit) INTO out_balance_insufficient; -IF out_balance_insufficient THEN - RETURN; -END IF; +-- Retrieve admin account id +SELECT bank_account_id + INTO admin_account_id + FROM bank_accounts + JOIN customers + ON customer_id=owning_customer_id + WHERE login = 'admin'; -- Check for idempotence and conflict SELECT (amount_debit != in_amount_debit OR subject != in_subject OR bank_account != account_id) - , challenge, cashout_id - INTO out_request_uid_reuse, challenge_id, out_cashout_id + , cashout_id + INTO out_request_uid_reuse, out_cashout_id FROM cashout_operations WHERE request_uid = in_request_uid; - -IF NOT found THEN - -- New cashout - out_tan_code = in_tan_code; - - -- Create challenge - SELECT challenge_create(in_tan_code, in_now_date, in_validity_period, in_retry_counter) INTO challenge_id; - - -- Create cashout operation - INSERT INTO cashout_operations ( - request_uid - ,amount_debit - ,amount_credit - ,subject - ,creation_time - ,bank_account - ,challenge - ) VALUES ( - in_request_uid - ,in_amount_debit - ,in_amount_credit - ,in_subject - ,in_now_date - ,account_id - ,challenge_id - ) RETURNING cashout_id INTO out_cashout_id; -ELSE -- Already exist, check challenge retransmission - SELECT challenge_resend(challenge_id, in_tan_code, in_now_date, in_validity_period, in_retry_counter) INTO out_tan_code; -END IF; -END $$; - -CREATE FUNCTION cashout_confirm( - IN in_cashout_id BIGINT, - IN in_login TEXT, - IN in_tan_code TEXT, - IN in_now_date BIGINT, - OUT out_no_op BOOLEAN, - OUT out_bad_conversion BOOLEAN, - OUT out_bad_code BOOLEAN, - OUT out_balance_insufficient BOOLEAN, - OUT out_aborted BOOLEAN, - OUT out_no_retry BOOLEAN, - OUT out_no_cashout_payto BOOLEAN -) -LANGUAGE plpgsql as $$ -DECLARE - wallet_account_id BIGINT; - admin_account_id BIGINT; - already_confirmed BOOLEAN; - subject_local TEXT; - amount_debit_local taler_amount; - amount_credit_local taler_amount; - challenge_id BIGINT; - tx_id BIGINT; -BEGIN --- Retrieve cashout operation info -SELECT - local_transaction IS NOT NULL, - aborted, subject, - bank_account, challenge, - (amount_debit).val, (amount_debit).frac, - (amount_credit).val, (amount_credit).frac, - cashout_payto IS NULL - INTO - already_confirmed, - out_aborted, subject_local, - wallet_account_id, challenge_id, - amount_debit_local.val, amount_debit_local.frac, - amount_credit_local.val, amount_credit_local.frac, - out_no_cashout_payto - FROM cashout_operations - JOIN bank_accounts ON bank_account_id=bank_account - JOIN customers ON customer_id=owning_customer_id - WHERE cashout_id=in_cashout_id AND login=in_login; -IF NOT FOUND THEN - out_no_op=TRUE; - RETURN; -ELSIF already_confirmed OR out_aborted OR out_no_cashout_payto THEN +IF found OR out_request_uid_reuse OR out_tan_required THEN RETURN; END IF; --- check conversion -SELECT too_small OR no_config OR amount_credit_local!=converted INTO out_bad_conversion FROM conversion_to(amount_debit_local, 'cashout'::text); -IF out_bad_conversion THEN - RETURN; -END IF; - --- check challenge -SELECT NOT ok, no_retry - INTO out_bad_code, out_no_retry - FROM challenge_try(challenge_id, in_tan_code, in_now_date); -IF out_bad_code OR out_no_retry THEN - RETURN; -END IF; - --- Retrieve admin account id -SELECT bank_account_id - INTO admin_account_id - FROM bank_accounts - JOIN customers - ON customer_id=owning_customer_id - WHERE login = 'admin'; - -- Perform bank wire transfer SELECT transfer.out_balance_insufficient, out_debit_row_id INTO out_balance_insufficient, tx_id FROM bank_wire_transfer( admin_account_id, - wallet_account_id, - subject_local, - amount_debit_local, + account_id, + in_subject, + in_amount_debit, in_now_date, NULL, NULL, @@ -1216,102 +1109,29 @@ IF out_balance_insufficient THEN RETURN; END IF; --- Confirm operation -UPDATE cashout_operations - SET local_transaction = tx_id - WHERE cashout_id=in_cashout_id; +-- Create cashout operation +INSERT INTO cashout_operations ( + request_uid + ,amount_debit + ,amount_credit + ,creation_time + ,bank_account + ,subject + ,local_transaction +) VALUES ( + in_request_uid + ,in_amount_debit + ,in_amount_credit + ,in_now_date + ,account_id + ,in_subject + ,tx_id +) RETURNING cashout_id INTO out_cashout_id; -- update stats -CALL stats_register_payment('cashout', now()::TIMESTAMP, amount_debit_local, amount_credit_local); +CALL stats_register_payment('cashout', now()::TIMESTAMP, in_amount_debit, in_amount_credit); END $$; -CREATE FUNCTION challenge_create ( - IN in_code TEXT, - IN in_now_date INT8, - IN in_validity_period INT8, - IN in_retry_counter INT4, - OUT out_challenge_id BIGINT -) -LANGUAGE sql AS $$ - INSERT INTO challenges ( - code, - creation_date, - expiration_date, - retry_counter - ) VALUES ( - in_code, - in_now_date, - in_now_date + in_validity_period, - in_retry_counter - ) RETURNING challenge_id -$$; -COMMENT ON FUNCTION challenge_create IS 'Create a new challenge, return the generated id'; - -CREATE FUNCTION challenge_mark_sent ( - IN in_challenge_id BIGINT, - IN in_now_date INT8, - IN in_retransmission_period INT8 -) RETURNS void -LANGUAGE sql AS $$ - UPDATE challenges SET - retransmission_date = in_now_date + in_retransmission_period - WHERE challenge_id = in_challenge_id; -$$; -COMMENT ON FUNCTION challenge_create IS 'Register a challenge as successfully sent'; - -CREATE FUNCTION challenge_resend ( - IN in_challenge_id BIGINT, - IN in_code TEXT, -- New code to use if the old code expired - IN in_now_date INT8, - IN in_validity_period INT8, - IN in_retry_counter INT4, - OUT out_tan_code TEXT -- Code to send, NULL if nothing should be sent -) -LANGUAGE plpgsql as $$ -DECLARE -expired BOOLEAN; -retransmit BOOLEAN; -BEGIN --- Recover expiration date -SELECT - (in_now_date >= expiration_date OR retry_counter <= 0) AND confirmation_date IS NULL - ,in_now_date >= retransmission_date AND confirmation_date IS NULL - ,code -INTO expired, retransmit, out_tan_code -FROM challenges WHERE challenge_id = in_challenge_id; - -IF expired THEN - UPDATE challenges SET - code = in_code - ,expiration_date = in_now_date + in_validity_period - ,retry_counter = in_retry_counter - WHERE challenge_id = in_challenge_id; - out_tan_code = in_code; -ELSIF NOT retransmit THEN - out_tan_code = NULL; -END IF; -END $$; -COMMENT ON FUNCTION challenge_resend IS 'Get the challenge code to send, return NULL if nothing should be sent'; - -CREATE FUNCTION challenge_try ( - IN in_challenge_id BIGINT, - IN in_code TEXT, - IN in_now_date INT8, - OUT ok BOOLEAN, - OUT no_retry BOOLEAN -) -LANGUAGE sql as $$ - UPDATE challenges SET - confirmation_date = CASE - WHEN (retry_counter > 0 AND in_now_date < expiration_date AND code = in_code) THEN in_now_date - ELSE confirmation_date - END, - retry_counter = retry_counter - 1 - WHERE challenge_id = in_challenge_id - RETURNING confirmation_date IS NOT NULL, retry_counter < 0 AND confirmation_date IS NULL; -$$; -COMMENT ON FUNCTION challenge_try IS 'Try to confirm a challenge, return true if the challenge have been confirmed'; - CREATE FUNCTION tan_challenge_create ( IN in_body TEXT, IN in_op op_enum, diff --git a/integration/test/IntegrationTest.kt b/integration/test/IntegrationTest.kt index ae148d85..ec2ea642 100644 --- a/integration/test/IntegrationTest.kt +++ b/integration/test/IntegrationTest.kt @@ -174,13 +174,7 @@ class IntegrationTest { "amount_debit" to amount "amount_credit" to convert } - }.assertOkJson<CashoutPending> { - val code = File("/tmp/tan-+99.txt").readText() - client.post("http://0.0.0.0:8090/accounts/customer/cashouts/${it.cashout_id}/confirm") { - basicAuth("customer", "password") - json { "tan" to code } - }.assertNoContent() - } + }.assertOkJson<CashoutResponse>() } } } |