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