helpers.kt (13849B)
1 /* 2 * This file is part of LibEuFin. 3 * Copyright (C) 2023-2025 Taler Systems S.A. 4 5 * LibEuFin is free software; you can redistribute it and/or modify 6 * it under the terms of the GNU Affero General Public License as 7 * published by the Free Software Foundation; either version 3, or 8 * (at your option) any later version. 9 10 * LibEuFin is distributed in the hope that it will be useful, but 11 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 12 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General 13 * Public License for more details. 14 15 * You should have received a copy of the GNU Affero General Public 16 * License along with LibEuFin; see the file COPYING. If not, see 17 * <http://www.gnu.org/licenses/> 18 */ 19 20 import io.ktor.client.* 21 import io.ktor.client.request.* 22 import io.ktor.client.statement.* 23 import io.ktor.http.* 24 import io.ktor.server.testing.* 25 import kotlinx.coroutines.runBlocking 26 import tech.libeufin.bank.* 27 import tech.libeufin.bank.db.AccountDAO.AccountCreationResult 28 import tech.libeufin.bank.db.Database 29 import tech.libeufin.common.* 30 import tech.libeufin.common.test.* 31 import tech.libeufin.common.db.dbInit 32 import tech.libeufin.common.db.pgDataSource 33 import java.nio.file.NoSuchFileException 34 import kotlin.io.path.Path 35 import kotlin.io.path.deleteExisting 36 import kotlin.io.path.readText 37 import kotlin.random.Random 38 import kotlin.test.assertEquals 39 import kotlin.test.assertIs 40 import kotlin.test.assertNotNull 41 42 /* ----- Setup ----- */ 43 44 val merchantPayto = IbanPayto.rand() 45 val exchangePayto = IbanPayto.rand() 46 val customerPayto = IbanPayto.rand() 47 val unknownPayto = IbanPayto.rand() 48 var tmpPayTo = IbanPayto.rand() 49 lateinit var adminPayto: String 50 val paytos = mapOf( 51 "merchant" to merchantPayto, 52 "exchange" to exchangePayto, 53 "customer" to customerPayto 54 ) 55 56 fun genTmpPayTo(): IbanPayto { 57 tmpPayTo = IbanPayto.rand() 58 return tmpPayTo 59 } 60 61 fun setup( 62 conf: String = "test.conf", 63 lambda: suspend (Database, BankConfig) -> Unit 64 ) = runBlocking { 65 globalTestTokens.clear() 66 val cfg = bankConfig(Path("conf/$conf")) 67 pgDataSource(cfg.dbCfg.dbConnStr).run { 68 dbInit(cfg.dbCfg, "libeufin-nexus", true) 69 dbInit(cfg.dbCfg, "libeufin-bank", true) 70 } 71 cfg.withDb { db, cfg -> 72 db.conn { conn -> 73 val sqlProcedures = Path("${cfg.dbCfg.sqlDir}/libeufin-conversion-setup.sql") 74 conn.execSQLUpdate(sqlProcedures.readText()) 75 } 76 lambda(db, cfg) 77 } 78 } 79 80 fun bankSetup( 81 conf: String = "test.conf", 82 lambda: suspend ApplicationTestBuilder.(Database) -> Unit 83 ) = setup(conf) { db, cfg -> 84 // Creating the exchange and merchant accounts first. 85 val bonus = TalerAmount.zero("KUDOS") 86 assertIs<AccountCreationResult.Success>(db.account.create( 87 username = "merchant", 88 password = "merchant-password", 89 name = "Merchant", 90 internalPayto = merchantPayto, 91 maxDebt = TalerAmount("KUDOS:10"), 92 isTalerExchange = false, 93 isPublic = false, 94 bonus = bonus, 95 checkPaytoIdempotent = false, 96 email = null, 97 phone = null, 98 cashoutPayto = null, 99 tanChannels = emptySet(), 100 conversionRateClassId = null, 101 pwCrypto = cfg.pwCrypto 102 )) 103 assertIs<AccountCreationResult.Success>(db.account.create( 104 username = "exchange", 105 password = "exchange-password", 106 name = "Exchange", 107 internalPayto = exchangePayto, 108 maxDebt = TalerAmount("KUDOS:10"), 109 isTalerExchange = true, 110 isPublic = false, 111 bonus = bonus, 112 checkPaytoIdempotent = false, 113 email = null, 114 phone = null, 115 cashoutPayto = null, 116 tanChannels = emptySet(), 117 conversionRateClassId = null, 118 pwCrypto = cfg.pwCrypto 119 )) 120 assertIs<AccountCreationResult.Success>(db.account.create( 121 username = "customer", 122 password = "customer-password", 123 name = "Customer", 124 internalPayto = customerPayto, 125 maxDebt = TalerAmount("KUDOS:10"), 126 isTalerExchange = false, 127 isPublic = false, 128 bonus = bonus, 129 checkPaytoIdempotent = false, 130 email = null, 131 phone = null, 132 cashoutPayto = null, 133 tanChannels = emptySet(), 134 conversionRateClassId = null, 135 pwCrypto = cfg.pwCrypto 136 )) 137 // Create admin account 138 val result = assertIs<AccountCreationResult.Success>(createAdminAccount(db, cfg, "admin-password")) 139 adminPayto = result.payto 140 testApplication { 141 application { 142 corebankWebApp(db, cfg) 143 } 144 if (cfg.allowConversion) { 145 // Set conversion rates 146 client.postAdmin("/conversion-info/conversion-rate") { 147 json { 148 "cashin_ratio" to "0.8" 149 "cashin_fee" to "KUDOS:0.02" 150 "cashin_tiny_amount" to "KUDOS:0.01" 151 "cashin_rounding_mode" to "nearest" 152 "cashin_min_amount" to "EUR:0" 153 "cashout_ratio" to "1.26" 154 "cashout_fee" to "EUR:0.003" 155 "cashout_tiny_amount" to "EUR:0.01" 156 "cashout_rounding_mode" to "zero" 157 "cashout_min_amount" to "KUDOS:0.1" 158 } 159 }.assertNoContent() 160 } 161 lambda(db) 162 } 163 } 164 165 fun dbSetup(lambda: suspend (Database) -> Unit) = 166 setup { db, _ -> lambda(db) } 167 168 /* ----- Common actions ----- */ 169 170 /** Set [account] debit threshold to [maxDebt] amount */ 171 suspend fun ApplicationTestBuilder.setMaxDebt(account: String, maxDebt: String) { 172 client.patchAdmin("/accounts/$account") { 173 json { "debit_threshold" to maxDebt } 174 }.assertNoContent() 175 } 176 177 /** Check [account] balance is [amount], [amount] is prefixed with + for credit and - for debit */ 178 suspend fun ApplicationTestBuilder.assertBalance(account: String, amount: String) { 179 client.getAdmin("/accounts/$account").assertOkJson<AccountData> { 180 val balance = it.balance 181 val fmt = "${if (balance.credit_debit_indicator == CreditDebitInfo.debit) '-' else '+'}${balance.amount}" 182 assertEquals(amount, fmt, "For $account") 183 } 184 } 185 186 /** Check [account] tan channel and info */ 187 suspend fun ApplicationTestBuilder.tanInfo(account: String): Pair<TanChannel?, String?> { 188 val res = client.getA("/accounts/$account").assertOkJson<AccountData>() 189 val channel: TanChannel? = res.tan_channel 190 return Pair(channel, when (channel) { 191 TanChannel.sms -> res.contact_data!!.phone.get() 192 TanChannel.email -> res.contact_data!!.email.get() 193 null -> null 194 }) 195 } 196 197 /** Perform a bank transaction of [amount] [from] account [to] account with [subject} */ 198 suspend fun ApplicationTestBuilder.tx(from: String, amount: String, to: String, subject: String = "payout"): Long { 199 return client.postA("/accounts/$from/transactions") { 200 json { 201 "payto_uri" to "${paytos[to] ?: tmpPayTo}?message=${subject.encodeURLParameter()}&amount=$amount" 202 } 203 }.maybeChallenge().assertOkJson<TransactionCreateResponse>().row_id 204 } 205 206 /** Perform a taler outgoing transaction of [amount] from exchange to merchant */ 207 suspend fun ApplicationTestBuilder.transfer(amount: String, payto: IbanPayto = merchantPayto) { 208 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 209 json { 210 "request_uid" to HashCode.rand() 211 "amount" to TalerAmount(amount) 212 "exchange_base_url" to "http://exchange.example.com/" 213 "wtid" to ShortHashCode.rand() 214 "credit_account" to payto 215 } 216 }.assertOk() 217 } 218 219 /** Perform a taler incoming transaction of [amount] from merchant to exchange */ 220 suspend fun ApplicationTestBuilder.addIncoming(amount: String) { 221 client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") { 222 json { 223 "amount" to TalerAmount(amount) 224 "reserve_pub" to EddsaPublicKey.rand() 225 "debit_account" to merchantPayto 226 } 227 }.assertOk() 228 } 229 230 /** Perform a taler kyc transaction of [amount] from merchant to exchange */ 231 suspend fun ApplicationTestBuilder.addKyc(amount: String) { 232 client.postA("/accounts/exchange/taler-wire-gateway/admin/add-kycauth") { 233 json { 234 "amount" to TalerAmount(amount) 235 "account_pub" to EddsaPublicKey.rand() 236 "debit_account" to merchantPayto 237 } 238 }.assertOk() 239 } 240 241 /** Perform a cashout operation of [amount] from customer */ 242 suspend fun ApplicationTestBuilder.cashout(amount: String) { 243 val res = client.postA("/accounts/customer/cashouts") { 244 json { 245 "request_uid" to ShortHashCode.rand() 246 "amount_debit" to amount 247 "amount_credit" to convert(amount) 248 } 249 } 250 if (res.status == HttpStatusCode.Conflict) { 251 // Retry with cashout info 252 fillCashoutInfo("customer") 253 client.postA("/accounts/customer/cashouts") { 254 json { 255 "request_uid" to ShortHashCode.rand() 256 "amount_debit" to amount 257 "amount_credit" to convert(amount) 258 } 259 } 260 } else { 261 res 262 }.assertOk() 263 } 264 265 /** Perform a whithrawal operation of [amount] from customer */ 266 suspend fun ApplicationTestBuilder.withdrawal(amount: String) { 267 client.postA("/accounts/merchant/withdrawals") { 268 json { "amount" to amount } 269 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 270 val uuid = it.taler_withdraw_uri.split("/").last() 271 withdrawalSelect(uuid) 272 client.postA("/accounts/merchant/withdrawals/${uuid}/confirm") 273 .assertNoContent() 274 } 275 } 276 277 suspend fun ApplicationTestBuilder.fillCashoutInfo(account: String) { 278 client.patchAdmin("/accounts/$account") { 279 json { 280 "cashout_payto_uri" to unknownPayto 281 "contact_data" to obj { 282 "phone" to "+99" 283 } 284 } 285 }.assertNoContent() 286 } 287 288 suspend fun ApplicationTestBuilder.fillTanInfo(username: String) { 289 // Create a token before we require 2fa for it 290 client.cachedToken(username) 291 client.patchAdmin("/accounts/$username") { 292 json { 293 "contact_data" to obj { 294 "phone" to "+${Random.nextInt(0, 10000)}" 295 } 296 "tan_channel" to "sms" 297 } 298 }.assertNoContent() 299 } 300 301 suspend fun ApplicationTestBuilder.withdrawalSelect(uuid: String) { 302 client.post("/taler-integration/withdrawal-operation/$uuid") { 303 json { 304 "reserve_pub" to EddsaPublicKey.rand() 305 "selected_exchange" to exchangePayto 306 } 307 }.assertOk() 308 } 309 310 private var nbClass = 0; 311 312 suspend fun ApplicationTestBuilder.createConversionRateClass( 313 cashout_min_amount: TalerAmount? = null 314 ): Long { 315 nbClass += 1 316 return client.postAdmin("/conversion-rate-classes") { 317 json { 318 "name" to "Gen class $nbClass" 319 "cashout_min_amount" to cashout_min_amount 320 } 321 }.assertOkJson<ConversionRateClassResponse>().conversion_rate_class_id 322 } 323 324 325 suspend fun ApplicationTestBuilder.convert(amount: String): TalerAmount { 326 return client.get("/conversion-info/cashout-rate?amount_debit=$amount") 327 .assertOkJson<ConversionResponse>().amount_credit 328 } 329 330 fun tanCode(info: String): String? { 331 try { 332 val file = Path("/tmp/tan-$info.txt") 333 val code = file.readText().split(" ", limit=2).first() 334 file.deleteExisting() 335 return code 336 } catch (e: Exception) { 337 if (e is NoSuchFileException) return null 338 throw e 339 } 340 } 341 342 343 /* ----- Assert ----- */ 344 345 suspend fun HttpResponse.maybeChallenge(): HttpResponse { 346 return if (this.status == HttpStatusCode.Accepted) { 347 this.assertChallenge() 348 } else { 349 this 350 } 351 } 352 353 suspend fun HttpResponse.assertChallenge( 354 check: suspend (ChallengeResponse) -> Unit = {} 355 ): HttpResponse { 356 val res = assertAcceptedJson<ChallengeResponse>() 357 val username = call.request.url.segments[1] 358 359 val challenge = res.challenges.random() 360 361 if (res.combi_and) { 362 for (challenge in res.challenges) { 363 call.client.postA("/accounts/$username/challenge/${challenge.challenge_id}").assertOk() 364 } 365 } else { 366 call.client.postA("/accounts/$username/challenge/${challenge.challenge_id}").assertOk() 367 } 368 369 check(res) 370 371 if (res.combi_and) { 372 for (challenge in res.challenges) { 373 val code = assertNotNull(tanCode(challenge.tan_info)) 374 call.client.postA("/accounts/$username/challenge/${challenge.challenge_id}/confirm") { 375 json { "tan" to code } 376 }.assertNoContent() 377 } 378 } else { 379 val code = assertNotNull(tanCode(challenge.tan_info)) 380 call.client.postA("/accounts/$username/challenge/${challenge.challenge_id}/confirm") { 381 json { "tan" to code } 382 }.assertNoContent() 383 } 384 385 // Recover body from request 386 val requestBody = this.request.content 387 val ids = res.challenges.map { it.challenge_id }.joinToString(", ") 388 return call.client.request(this.call.request.url) { 389 tokenAuth(call.client, username) 390 method = call.request.method 391 headers[TALER_CHALLENGE_IDS] = ids 392 setBody(requestBody) 393 } 394 } 395 396 fun assertException(msg: String, lambda: () -> Unit) { 397 try { 398 lambda() 399 throw Exception("Expected failure") 400 } catch (e: Exception) { 401 assert(e.message!!.startsWith(msg)) { "${e.message}" } 402 } 403 } 404 405 /* ----- Random data generation ----- */ 406 407 fun randBase32Crockford(length: Int) = Base32Crockford.encode(ByteArray(length).rand()) 408 fun randIncomingSubject(reservePub: EddsaPublicKey): String = "$reservePub" 409 fun randOutgoingSubject(wtid: ShortHashCode, url: BaseURL): String = "$wtid $url"