diff options
author | Antoine A <> | 2023-10-19 13:18:59 +0000 |
---|---|---|
committer | Antoine A <> | 2023-10-19 13:18:59 +0000 |
commit | 79c1992f05cc6bcc7ab49f479c9a46c684c36a09 (patch) | |
tree | 36ef6f0e63a71cf714a76367a7a456eebbdef74b | |
parent | 525a1b1e2ea9e17c6cf8c1fd927c66eb871b2e47 (diff) | |
download | libeufin-79c1992f05cc6bcc7ab49f479c9a46c684c36a09.tar.gz libeufin-79c1992f05cc6bcc7ab49f479c9a46c684c36a09.tar.bz2 libeufin-79c1992f05cc6bcc7ab49f479c9a46c684c36a09.zip |
Improve test
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt | 247 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/Main.kt | 4 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt | 12 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 7 | ||||
-rw-r--r-- | bank/src/test/kotlin/BankIntegrationApiTest.kt | 78 | ||||
-rw-r--r-- | bank/src/test/kotlin/CoreBankApiTest.kt | 459 | ||||
-rw-r--r-- | bank/src/test/kotlin/DatabaseTest.kt | 26 | ||||
-rw-r--r-- | bank/src/test/kotlin/helpers.kt | 36 |
8 files changed, 432 insertions, 437 deletions
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt index 59a713e0..a610c7cc 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt @@ -18,30 +18,7 @@ import kotlin.random.Random private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.accountsMgmtHandlers") -/** - * This function collects all the /accounts handlers that - * create, update, delete, show bank accounts. No histories - * and wire transfers should belong here. - */ -fun Routing.accountsMgmtApi(db: Database, ctx: BankApplicationContext) { - // TOKEN ENDPOINTS - delete("/accounts/{USERNAME}/token") { - call.authCheck(db, TokenScope.readonly) - val token = call.getAuthToken() ?: throw badRequest("Basic auth not supported here.") - - /** - * Not sanity-checking the token, as it was used by the authentication already. - * If harder errors happen, then they'll get Ktor respond with 500. - */ - db.bearerTokenDelete(Base32Crockford.decode(token)) - /** - * Responding 204 regardless of it being actually deleted or not. - * If it wasn't found, then it must have been deleted before we - * reached here, but the token was valid as it served the authentication - * => no reason to fail the request. - */ - call.respond(HttpStatusCode.NoContent) - } +fun Routing.coreBankTokenApi(db: Database) { post("/accounts/{USERNAME}/token") { val (login, _) = call.authCheck(db, TokenScope.refreshable) val maybeAuthToken = call.getAuthToken() @@ -97,111 +74,27 @@ fun Routing.accountsMgmtApi(db: Database, ctx: BankApplicationContext) { ) ) ) - return@post } - // WITHDRAWAL ENDPOINTS - post("/accounts/{USERNAME}/withdrawals") { - val (login, _) = call.authCheck(db, TokenScope.readwrite) - val req = call.receive<BankAccountCreateWithdrawalRequest>() // Checking that the user has enough funds. - - if (req.amount.currency != ctx.currency) - throw badRequest("Wrong currency: ${req.amount.currency}") + delete("/accounts/{USERNAME}/token") { + call.authCheck(db, TokenScope.readonly) + val token = call.getAuthToken() ?: throw badRequest("Basic auth not supported here.") - val opId = UUID.randomUUID() - when (db.talerWithdrawalCreate(login, opId, req.amount)) { - WithdrawalCreationResult.ACCOUNT_NOT_FOUND -> throw notFound( - "Customer $login not found", - TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT - ) - WithdrawalCreationResult.ACCOUNT_IS_EXCHANGE -> throw conflict( - "Exchange account cannot perform withdrawal operation", - TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT - ) - WithdrawalCreationResult.BALANCE_INSUFFICIENT -> throw conflict( - "Insufficient funds to withdraw with Taler", - TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT - ) - WithdrawalCreationResult.SUCCESS -> { - val bankBaseUrl = call.request.getBaseUrl() ?: throw internalServerError("Bank could not find its own base URL") - call.respond( - BankAccountCreateWithdrawalResponse( - withdrawal_id = opId.toString(), taler_withdraw_uri = getTalerWithdrawUri(bankBaseUrl, opId.toString()) - ) - ) - } - } - } - get("/withdrawals/{withdrawal_id}") { - val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) - call.respond( - BankAccountGetWithdrawalResponse( - amount = op.amount, - aborted = op.aborted, - confirmation_done = op.confirmationDone, - selection_done = op.selectionDone, - selected_exchange_account = op.selectedExchangePayto, - selected_reserve_pub = op.reservePub - ) - ) - } - post("/withdrawals/{withdrawal_id}/abort") { - val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) // Idempotency: - if (op.aborted) { - call.respondText("{}", ContentType.Application.Json) - return@post - } // Op is found, it'll now fail only if previously confirmed (DB checks). - if (!db.talerWithdrawalAbort(op.withdrawalUuid)) throw conflict( - hint = "Cannot abort confirmed withdrawal", talerEc = TalerErrorCode.TALER_EC_END - ) - call.respondText("{}", ContentType.Application.Json) - } - post("/withdrawals/{withdrawal_id}/confirm") { - val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) // Checking idempotency: - if (op.confirmationDone) { - call.respondText("{}", ContentType.Application.Json) - return@post - } - if (op.aborted) throw conflict( - hint = "Cannot confirm an aborted withdrawal", talerEc = TalerErrorCode.TALER_EC_BANK_CONFIRM_ABORT_CONFLICT - ) // Checking that reserve GOT indeed selected. - if (!op.selectionDone) throw LibeufinBankException( - httpStatus = HttpStatusCode.UnprocessableEntity, talerError = TalerError( - hint = "Cannot confirm an unselected withdrawal", code = TalerErrorCode.TALER_EC_END.code - ) - ) // Confirmation conditions are all met, now put the operation - // to the selected state _and_ wire the funds to the exchange. - // Note: 'when' helps not to omit more result codes, should more - // be added. - when (db.talerWithdrawalConfirm(op.withdrawalUuid, Instant.now())) { - WithdrawalConfirmationResult.BALANCE_INSUFFICIENT -> - throw conflict( - "Insufficient funds", - TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT - ) - WithdrawalConfirmationResult.OP_NOT_FOUND -> - /** - * Despite previous checks, the database _still_ did not - * find the withdrawal operation, that's on the bank. - */ - throw internalServerError("Withdrawal operation (${op.withdrawalUuid}) not found") - WithdrawalConfirmationResult.EXCHANGE_NOT_FOUND -> - /** - * That can happen because the bank did not check the exchange - * exists when POST /withdrawals happened, or because the exchange - * bank account got removed before this confirmation. - */ - throw conflict( - hint = "Exchange to withdraw from not found", - talerEc = TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT - ) - WithdrawalConfirmationResult.CONFLICT -> throw internalServerError("Bank didn't check for idempotency") - WithdrawalConfirmationResult.SUCCESS -> call.respondText( - "{}", ContentType.Application.Json - ) - } + /** + * Not sanity-checking the token, as it was used by the authentication already. + * If harder errors happen, then they'll get Ktor respond with 500. + */ + db.bearerTokenDelete(Base32Crockford.decode(token)) + /** + * Responding 204 regardless of it being actually deleted or not. + * If it wasn't found, then it must have been deleted before we + * reached here, but the token was valid as it served the authentication + * => no reason to fail the request. + */ + call.respond(HttpStatusCode.NoContent) } } + fun Routing.coreBankAccountsMgmtApi(db: Database, ctx: BankApplicationContext) { post("/accounts") { // check if only admin is allowed to create new accounts @@ -477,10 +370,7 @@ fun Routing.coreBankTransactionsApi(db: Database, ctx: BankApplicationContext) { val subject = tx.payto_uri.message ?: throw badRequest("Wire transfer lacks subject") val amount = tx.payto_uri.amount ?: tx.amount ?: throw badRequest("Wire transfer lacks amount") - if (amount.currency != ctx.currency) throw badRequest( - "Wrong currency: ${amount.currency}", - talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH - ) + checkInternalCurrency(ctx, amount) val result = db.bankTransaction( creditAccountPayto = tx.payto_uri, debitAccountUsername = login, @@ -508,4 +398,105 @@ fun Routing.coreBankTransactionsApi(db: Database, ctx: BankApplicationContext) { BankTransactionResult.SUCCESS -> call.respond(HttpStatusCode.OK) } } +} + +fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankApplicationContext) { + post("/accounts/{USERNAME}/withdrawals") { + val (login, _) = call.authCheck(db, TokenScope.readwrite) + val req = call.receive<BankAccountCreateWithdrawalRequest>() // Checking that the user has enough funds. + + checkInternalCurrency(ctx, req.amount) + + val opId = UUID.randomUUID() + when (db.talerWithdrawalCreate(login, opId, req.amount)) { + WithdrawalCreationResult.ACCOUNT_NOT_FOUND -> throw notFound( + "Customer $login not found", + TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT + ) + WithdrawalCreationResult.ACCOUNT_IS_EXCHANGE -> throw conflict( + "Exchange account cannot perform withdrawal operation", + TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT + ) + WithdrawalCreationResult.BALANCE_INSUFFICIENT -> throw conflict( + "Insufficient funds to withdraw with Taler", + TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT + ) + WithdrawalCreationResult.SUCCESS -> { + val bankBaseUrl = call.request.getBaseUrl() ?: throw internalServerError("Bank could not find its own base URL") + call.respond( + BankAccountCreateWithdrawalResponse( + withdrawal_id = opId.toString(), taler_withdraw_uri = getTalerWithdrawUri(bankBaseUrl, opId.toString()) + ) + ) + } + } + } + get("/withdrawals/{withdrawal_id}") { + val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) + call.respond( + BankAccountGetWithdrawalResponse( + amount = op.amount, + aborted = op.aborted, + confirmation_done = op.confirmationDone, + selection_done = op.selectionDone, + selected_exchange_account = op.selectedExchangePayto, + selected_reserve_pub = op.reservePub + ) + ) + } + post("/withdrawals/{withdrawal_id}/abort") { + val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) // Idempotency: + if (op.aborted) { + call.respondText("{}", ContentType.Application.Json) + return@post + } // Op is found, it'll now fail only if previously confirmed (DB checks). + if (!db.talerWithdrawalAbort(op.withdrawalUuid)) throw conflict( + hint = "Cannot abort confirmed withdrawal", talerEc = TalerErrorCode.TALER_EC_END + ) + call.respondText("{}", ContentType.Application.Json) + } + post("/withdrawals/{withdrawal_id}/confirm") { + val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) // Checking idempotency: + if (op.confirmationDone) { + call.respondText("{}", ContentType.Application.Json) + return@post + } + if (op.aborted) throw conflict( + hint = "Cannot confirm an aborted withdrawal", talerEc = TalerErrorCode.TALER_EC_BANK_CONFIRM_ABORT_CONFLICT + ) // Checking that reserve GOT indeed selected. + if (!op.selectionDone) throw LibeufinBankException( + httpStatus = HttpStatusCode.UnprocessableEntity, talerError = TalerError( + hint = "Cannot confirm an unselected withdrawal", code = TalerErrorCode.TALER_EC_END.code + ) + ) // Confirmation conditions are all met, now put the operation + // to the selected state _and_ wire the funds to the exchange. + // Note: 'when' helps not to omit more result codes, should more + // be added. + when (db.talerWithdrawalConfirm(op.withdrawalUuid, Instant.now())) { + WithdrawalConfirmationResult.BALANCE_INSUFFICIENT -> throw conflict( + "Insufficient funds", + TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT + ) + WithdrawalConfirmationResult.OP_NOT_FOUND -> + /** + * Despite previous checks, the database _still_ did not + * find the withdrawal operation, that's on the bank. + */ + throw internalServerError("Withdrawal operation (${op.withdrawalUuid}) not found") + WithdrawalConfirmationResult.EXCHANGE_NOT_FOUND -> + /** + * That can happen because the bank did not check the exchange + * exists when POST /withdrawals happened, or because the exchange + * bank account got removed before this confirmation. + */ + throw conflict( + hint = "Exchange to withdraw from not found", + talerEc = TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT + ) + WithdrawalConfirmationResult.CONFLICT -> throw internalServerError("Bank didn't check for idempotency") + WithdrawalConfirmationResult.SUCCESS -> call.respondText( + "{}", ContentType.Application.Json + ) + } + } }
\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt index 159d27fd..0afad9a5 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -259,10 +259,10 @@ fun Application.corebankWebApp(db: Database, ctx: BankApplicationContext) { call.respond(Config(ctx.currencySpecification)) return@get } - this.accountsMgmtApi(db, ctx) + this.coreBankTokenApi(db) this.coreBankAccountsMgmtApi(db, ctx) this.coreBankTransactionsApi(db, ctx) - this.accountsMgmtApi(db, ctx) + this.coreBankWithdrawalApi(db, ctx) this.bankIntegrationApi(db, ctx) this.wireGatewayApi(db, ctx) } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt index e1cfd897..dcf1f785 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt @@ -45,11 +45,7 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankApplicationContext) { post("/accounts/{USERNAME}/taler-wire-gateway/transfer") { val (login, _) = call.authCheck(db, TokenScope.readwrite) val req = call.receive<TransferRequest>() - if (req.amount.currency != ctx.currency) - throw badRequest( - "Currency mismatch", - TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH - ) + checkInternalCurrency(ctx, req.amount) val dbRes = db.talerTransferCreate( req = req, username = login, @@ -128,11 +124,7 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankApplicationContext) { post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming") { val (login, _) = call.authCheck(db, TokenScope.readwrite) // TODO authAdmin ? val req = call.receive<AddIncomingRequest>() - if (req.amount.currency != ctx.currency) - throw badRequest( - "Currency mismatch", - TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH - ) + checkInternalCurrency(ctx, req.amount) val timestamp = Instant.now() val dbRes = db.talerAddIncomingCreate( req = req, diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt index 8ab6ba28..d8123ed2 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -114,6 +114,13 @@ fun badRequest( ) ) +fun checkInternalCurrency(ctx: BankApplicationContext, amount: TalerAmount) { + if (amount.currency != ctx.currency) throw badRequest( + "Wrong currency: expected internal currency ${ctx.currency} got ${amount.currency}", + talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH + ) +} + // Generates a new Payto-URI with IBAN scheme. fun genIbanPaytoUri(): String = "payto://iban/SANDBOXX/${getIban()}" diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt index b5dab990..79c0b7bf 100644 --- a/bank/src/test/kotlin/BankIntegrationApiTest.kt +++ b/bank/src/test/kotlin/BankIntegrationApiTest.kt @@ -34,7 +34,7 @@ class BankIntegrationApiTest { }.assertOk().run { val resp = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(bodyAsText()) val uuid = resp.taler_withdraw_uri.split("/").last() - client.get("/taler-integration/withdrawal-operation/${uuid}") + client.get("/taler-integration/withdrawal-operation/$uuid") .assertOk() } @@ -74,15 +74,15 @@ class BankIntegrationApiTest { val uuid = resp.taler_withdraw_uri.split("/").last() // Check OK - client.post("/taler-integration/withdrawal-operation/${uuid}") { + client.post("/taler-integration/withdrawal-operation/$uuid") { jsonBody(req) }.assertOk() // Check idempotence - client.post("/taler-integration/withdrawal-operation/${uuid}") { + client.post("/taler-integration/withdrawal-operation/$uuid") { jsonBody(req) }.assertOk() // Check already selected - client.post("/taler-integration/withdrawal-operation/${uuid}") { + client.post("/taler-integration/withdrawal-operation/$uuid") { jsonBody(json(req) { "reserve_pub" to randEddsaPublicKey() }) @@ -97,18 +97,18 @@ class BankIntegrationApiTest { val uuid = resp.taler_withdraw_uri.split("/").last() // Check reserve_pub_reuse - client.post("/taler-integration/withdrawal-operation/${uuid}") { + client.post("/taler-integration/withdrawal-operation/$uuid") { jsonBody(req) }.assertConflict().assertErr(TalerErrorCode.TALER_EC_BANK_DUPLICATE_RESERVE_PUB_SUBJECT) // Check unknown account - client.post("/taler-integration/withdrawal-operation/${uuid}") { + client.post("/taler-integration/withdrawal-operation/$uuid") { jsonBody(json { "reserve_pub" to randEddsaPublicKey() "selected_exchange" to IbanPayTo("payto://iban/UNKNOWN-IBAN-XYZ") }) }.assertConflict().assertErr(TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT) // Check account not exchange - client.post("/taler-integration/withdrawal-operation/${uuid}") { + client.post("/taler-integration/withdrawal-operation/$uuid") { jsonBody(json { "reserve_pub" to randEddsaPublicKey() "selected_exchange" to IbanPayTo("payto://iban/MERCHANT-IBAN-XYZ") @@ -117,70 +117,6 @@ class BankIntegrationApiTest { } } - // Testing withdrawal abort - @Test - fun withdrawalAbort() = bankSetup { db -> - val uuid = UUID.randomUUID() - // insert new. - assertEquals(WithdrawalCreationResult.SUCCESS, db.talerWithdrawalCreate( - opUUID = uuid, - walletAccountUsername = "merchant", - amount = TalerAmount(1, 0, "KUDOS") - )) - val op = db.talerWithdrawalGet(uuid) - assert(op?.aborted == false) - assertEquals(WithdrawalSelectionResult.SUCCESS, - db.talerWithdrawalSetDetails(uuid, IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ"), randEddsaPublicKey()).first - ) - - client.post("/withdrawals/${uuid}/abort") { - basicAuth("merchant", "merchant-password") - }.assertOk() - - val opAbo = db.talerWithdrawalGet(uuid) - assert(opAbo?.aborted == true && opAbo.selectionDone == true) - } - - // Testing withdrawal creation - @Test - fun withdrawalCreation() = bankSetup { _ -> - // Creating the withdrawal as if the SPA did it. - val r = client.post("/accounts/merchant/withdrawals") { - basicAuth("merchant", "merchant-password") - jsonBody(BankAccountCreateWithdrawalRequest(TalerAmount(value = 9, frac = 0, currency = "KUDOS"))) - }.assertOk() - val opId = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(r.bodyAsText()) - // Getting the withdrawal from the bank. Throws (failing the test) if not found. - client.get("/withdrawals/${opId.withdrawal_id}") { - basicAuth("merchant", "merchant-password") - }.assertOk() - } - - // Testing withdrawal confirmation - @Test - fun withdrawalConfirmation() = bankSetup { db -> - // Artificially making a withdrawal operation for merchant. - val uuid = UUID.randomUUID() - assertEquals(WithdrawalCreationResult.SUCCESS, db.talerWithdrawalCreate( - opUUID = uuid, - walletAccountUsername = "merchant", - amount = TalerAmount(1, 0, "KUDOS") - )) - // Specifying the exchange via its Payto URI. - assertEquals(WithdrawalSelectionResult.SUCCESS, - db.talerWithdrawalSetDetails( - opUuid = uuid, - exchangePayto = IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ"), - reservePub = randEddsaPublicKey() - ).first - ) - - // Starting the bank and POSTing as Foo to /confirm the operation. - client.post("/withdrawals/${uuid}/confirm") { - basicAuth("merchant", "merchant-password") - }.assertOk() - } - // Testing the generation of taler://withdraw-URIs. @Test fun testWithdrawUri() { diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt index 811c3011..2498d8f6 100644 --- a/bank/src/test/kotlin/CoreBankApiTest.kt +++ b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -18,10 +18,135 @@ import java.sql.DriverManager import java.time.Duration import java.time.Instant import java.time.temporal.ChronoUnit +import java.util.* import kotlin.random.Random import kotlin.test.* import kotlinx.coroutines.* +class CoreBankConfigTest { + @Test + fun getConfig() = bankSetup { _ -> + client.get("/config").assertOk() + } +} + +class CoreBankTokenApiTest { + // POST /accounts/USERNAME/token + @Test + fun post() = bankSetup { db -> + // Wrong user + client.post("/accounts/merchant/token") { + basicAuth("exchange", "exchange-password") + }.assertUnauthorized() + + // New default token + client.post("/accounts/merchant/token") { + basicAuth("merchant", "merchant-password") + jsonBody(json { "scope" to "readonly"}) + }.assertOk().run { + // Checking that the token lifetime defaulted to 24 hours. + val resp = Json.decodeFromString<TokenSuccessResponse>(bodyAsText()) + val token = db.bearerTokenGet(Base32Crockford.decode(resp.access_token)) + val lifeTime = Duration.between(token!!.creationTime, token.expirationTime) + assertEquals(Duration.ofDays(1), lifeTime) + } + + // Check default duration + client.post("/accounts/merchant/token") { + basicAuth("merchant", "merchant-password") + jsonBody(json { "scope" to "readonly" }) + }.assertOk().run { + // Checking that the token lifetime defaulted to 24 hours. + val resp = Json.decodeFromString<TokenSuccessResponse>(bodyAsText()) + val token = db.bearerTokenGet(Base32Crockford.decode(resp.access_token)) + val lifeTime = Duration.between(token!!.creationTime, token.expirationTime) + assertEquals(Duration.ofDays(1), lifeTime) + } + + // Check refresh + client.post("/accounts/merchant/token") { + basicAuth("merchant", "merchant-password") + jsonBody(json { + "scope" to "readonly" + "refreshable" to true + }) + }.assertOk().run { + val token = Json.decodeFromString<TokenSuccessResponse>(bodyAsText()).access_token + client.post("/accounts/merchant/token") { + headers["Authorization"] = "Bearer secret-token:$token" + jsonBody(json { "scope" to "readonly" }) + }.assertOk() + } + + // Check'forever' case. + client.post("/accounts/merchant/token") { + basicAuth("merchant", "merchant-password") + jsonBody(json { + "scope" to "readonly" + "duration" to json { + "d_us" to "forever" + } + }) + }.run { + val never: TokenSuccessResponse = Json.decodeFromString(bodyAsText()) + assertEquals(Instant.MAX, never.expiration.t_s) + } + + // Check too big or invalid durations + client.post("/accounts/merchant/token") { + basicAuth("merchant", "merchant-password") + jsonBody(json { + "scope" to "readonly" + "duration" to json { + "d_us" to "invalid" + } + }) + }.assertBadRequest() + client.post("/accounts/merchant/token") { + basicAuth("merchant", "merchant-password") + jsonBody(json { + "scope" to "readonly" + "duration" to json { + "d_us" to Long.MAX_VALUE + } + }) + }.assertBadRequest() + client.post("/accounts/merchant/token") { + basicAuth("merchant", "merchant-password") + jsonBody(json { + "scope" to "readonly" + "duration" to json { + "d_us" to -1 + } + }) + }.assertBadRequest() + } + + // DELETE /accounts/USERNAME/token + @Test + fun delete() = bankSetup { _ -> + val token = client.post("/accounts/merchant/token") { + basicAuth("merchant", "merchant-password") + jsonBody(json { "scope" to "readonly" }) + }.assertOk().run { + Json.decodeFromString<TokenSuccessResponse>(bodyAsText()).access_token + } + // Check OK + client.delete("/accounts/merchant/token") { + headers["Authorization"] = "Bearer secret-token:$token" + }.assertNoContent() + // Check token no longer work + client.delete("/accounts/merchant/token") { + headers["Authorization"] = "Bearer secret-token:$token" + }.assertUnauthorized() + + // Checking merchant can still be served by basic auth, after token deletion. + client.get("/accounts/merchant") { + basicAuth("merchant", "merchant-password") + }.assertOk() + } +} + class CoreBankAccountsMgmtApiTest { // Testing the account creation and its idempotency @Test @@ -551,213 +676,159 @@ class CoreBankTransactionsApiTest { } } -class LibeuFinApiTest { - private val customerFoo = Customer( - login = "foo", - passwordHash = CryptoUtil.hashpw("pw"), - name = "Foo", - phone = "+00", - email = "foo@b.ar", - cashoutPayto = "payto://external-IBAN", - cashoutCurrency = "KUDOS" - ) - private val customerBar = Customer( - login = "bar", - passwordHash = CryptoUtil.hashpw("pw"), - name = "Bar", - phone = "+99", - email = "bar@example.com", - cashoutPayto = "payto://external-IBAN", - cashoutCurrency = "KUDOS" - ) - - private fun genBankAccount(rowId: Long) = BankAccount( - hasDebt = false, - internalPaytoUri = IbanPayTo("payto://iban/ac${rowId}"), - maxDebt = TalerAmount(100, 0, "KUDOS"), - owningCustomerId = rowId - ) - +class CoreBankWithdrawalApiTest { + // POST /accounts/USERNAME/withdrawals @Test - fun getConfig() = bankSetup { _ -> - val r = client.get("/config") { - expectSuccess = true + fun create() = bankSetup { _ -> + client.post("/accounts/merchant/withdrawals") { + basicAuth("merchant", "merchant-password") + jsonBody(json { "amount" to "KUDOS:9.0" }) }.assertOk() - println(r.bodyAsText()) } - + // GET /withdrawals/withdrawal_id @Test - fun tokenDeletionTest() = setup { db, ctx -> - assert(db.customerCreate(customerFoo) != null) - val token = ByteArray(32) - Random.nextBytes(token) - assert(db.bearerTokenCreate( - BearerToken( - bankCustomer = 1L, - content = token, - creationTime = Instant.now(), - expirationTime = Instant.now().plusSeconds(10), - scope = TokenScope.readwrite - ) - )) - testApplication { - application { - corebankWebApp(db, ctx) - } - // Legitimate first attempt, should succeed - client.delete("/accounts/foo/token") { - expectSuccess = true - headers["Authorization"] = "Bearer secret-token:${Base32Crockford.encode(token)}" - }.apply { - assert(this.status == HttpStatusCode.NoContent) - } - // Trying after deletion should hit 404. - client.delete("/accounts/foo/token") { - expectSuccess = false - headers["Authorization"] = "Bearer secret-token:${Base32Crockford.encode(token)}" - }.apply { - assert(this.status == HttpStatusCode.Unauthorized) - } - // Checking foo can still be served by basic auth, after token deletion. - assert(db.bankAccountCreate( - BankAccount( - hasDebt = false, - internalPaytoUri = IbanPayTo("payto://iban/DE1234"), - maxDebt = TalerAmount(100, 0, "KUDOS"), - owningCustomerId = 1 - ) - ) != null) - client.get("/accounts/foo") { - expectSuccess = true - basicAuth("foo", "pw") - } + fun get() = bankSetup { _ -> + // Check OK + client.post("/accounts/merchant/withdrawals") { + basicAuth("merchant", "merchant-password") + jsonBody(json { "amount" to "KUDOS:9.0" } ) + }.assertOk().run { + val opId = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(bodyAsText()) + client.get("/withdrawals/${opId.withdrawal_id}") { + basicAuth("merchant", "merchant-password") + }.assertOk() } + + // Check bad UUID + client.get("/withdrawals/chocolate").assertBadRequest() + + // Check unknown + client.get("/withdrawals/${UUID.randomUUID()}").assertNotFound() } - // Creating token with "forever" duration. + // POST /withdrawals/withdrawal_id/abort @Test - fun tokenForeverTest() = setup { db, ctx -> - assert(db.customerCreate(customerFoo) != null) - testApplication { - application { - corebankWebApp(db, ctx) - } - val newTok = client.post("/accounts/foo/token") { - expectSuccess = true - contentType(ContentType.Application.Json) - basicAuth("foo", "pw") - setBody( - """ - {"duration": {"d_us": "forever"}, "scope": "readonly"} - """.trimIndent() - ) - } - val newTokObj = Json.decodeFromString<TokenSuccessResponse>(newTok.bodyAsText()) - assert(newTokObj.expiration.t_s == Instant.MAX) + fun abort() = bankSetup { _ -> + // Check abort created + client.post("/accounts/merchant/withdrawals") { + basicAuth("merchant", "merchant-password") + jsonBody(json { "amount" to "KUDOS:1" }) + }.assertOk().run { + val resp = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(bodyAsText()) + val uuid = resp.taler_withdraw_uri.split("/").last() + + // Check OK + client.post("/withdrawals/$uuid/abort").assertOk() + // Check idempotence + client.post("/withdrawals/$uuid/abort").assertOk() } - } - // Testing that too big or invalid durations fail the request. - @Test - fun tokenInvalidDurationTest() = setup { db, ctx -> - assert(db.customerCreate(customerFoo) != null) - testApplication { - application { - corebankWebApp(db, ctx) - } - var r = client.post("/accounts/foo/token") { - expectSuccess = false - contentType(ContentType.Application.Json) - basicAuth("foo", "pw") - setBody("""{ - "duration": {"d_us": "invalid"}, - "scope": "readonly"}""".trimIndent()) - } - assert(r.status == HttpStatusCode.BadRequest) - r = client.post("/accounts/foo/token") { - expectSuccess = false - contentType(ContentType.Application.Json) - basicAuth("foo", "pw") - setBody("""{ - "duration": {"d_us": ${Long.MAX_VALUE}}, - "scope": "readonly"}""".trimIndent()) - } - assert(r.status == HttpStatusCode.BadRequest) - r = client.post("/accounts/foo/token") { - expectSuccess = false - contentType(ContentType.Application.Json) - basicAuth("foo", "pw") - setBody("""{ - "duration": {"d_us": -1}, - "scope": "readonly"}""".trimIndent()) - } - assert(r.status == HttpStatusCode.BadRequest) + // Check abort selected + client.post("/accounts/merchant/withdrawals") { + basicAuth("merchant", "merchant-password") + jsonBody(json { "amount" to "KUDOS:1" }) + }.assertOk().run { + val resp = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(bodyAsText()) + val uuid = resp.taler_withdraw_uri.split("/").last() + client.post("/taler-integration/withdrawal-operation/$uuid") { + jsonBody(json { + "reserve_pub" to randEddsaPublicKey() + "selected_exchange" to IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ") + }) + }.assertOk() + + // Check OK + client.post("/withdrawals/$uuid/abort").assertOk() + // Check idempotence + client.post("/withdrawals/$uuid/abort").assertOk() + } + + // Check abort confirmed + client.post("/accounts/merchant/withdrawals") { + basicAuth("merchant", "merchant-password") + jsonBody(json { "amount" to "KUDOS:1" }) + }.assertOk().run { + val resp = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(bodyAsText()) + val uuid = resp.taler_withdraw_uri.split("/").last() + client.post("/taler-integration/withdrawal-operation/$uuid") { + jsonBody(json { + "reserve_pub" to randEddsaPublicKey() + "selected_exchange" to IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ") + }) + }.assertOk() + client.post("/withdrawals/$uuid/confirm").assertOk() + + // Check error + client.post("/withdrawals/$uuid/abort").assertConflict() } + + // Check bad UUID + client.post("/withdrawals/chocolate/abort").assertBadRequest() + + // Check unknown + client.post("/withdrawals/${UUID.randomUUID()}/abort").assertNotFound() } - // Checking the POST /token handling. + + // POST /withdrawals/withdrawal_id/confirm @Test - fun tokenTest() = setup { db, ctx -> - assert(db.customerCreate(customerFoo) != null) - testApplication { - application { - corebankWebApp(db, ctx) - } - val newTok = client.post("/accounts/foo/token") { - expectSuccess = true - contentType(ContentType.Application.Json) - basicAuth("foo", "pw") - setBody( - """ - {"scope": "readonly"} - """.trimIndent() - ) - } - // Checking that the token lifetime defaulted to 24 hours. - val newTokObj = Json.decodeFromString<TokenSuccessResponse>(newTok.bodyAsText()) - val newTokDb = db.bearerTokenGet(Base32Crockford.decode(newTokObj.access_token)) - val lifeTime = Duration.between(newTokDb!!.creationTime, newTokDb.expirationTime) - assert(lifeTime == Duration.ofDays(1)) - - // foo tries to create a token on behalf of bar, expect 403. - val r = client.post("/accounts/bar/token") { - expectSuccess = false - basicAuth("foo", "pw") - } - assert(r.status == HttpStatusCode.Unauthorized) - // Make ad-hoc token for foo. - val fooTok = ByteArray(32).apply { Random.nextBytes(this) } - assert( - db.bearerTokenCreate( - BearerToken( - content = fooTok, - bankCustomer = 1L, // only foo exists. - scope = TokenScope.readonly, - creationTime = Instant.now(), - isRefreshable = true, - expirationTime = Instant.now().plus(1, ChronoUnit.DAYS) - ) - ) - ) - // Testing the secret-token:-scheme. - client.post("/accounts/foo/token") { - headers.set("Authorization", "Bearer secret-token:${Base32Crockford.encode(fooTok)}") - contentType(ContentType.Application.Json) - setBody("{\"scope\": \"readonly\"}") - expectSuccess = true - } - // Testing the 'forever' case. - val forever = client.post("/accounts/foo/token") { - expectSuccess = true - contentType(ContentType.Application.Json) - basicAuth("foo", "pw") - setBody("""{ - "scope": "readonly", - "duration": {"d_us": "forever"} - }""".trimIndent()) - } - val never: TokenSuccessResponse = Json.decodeFromString(forever.bodyAsText()) - assert(never.expiration.t_s == Instant.MAX) + fun confirm() = bankSetup { db -> + // Check confirm created + client.post("/accounts/merchant/withdrawals") { + basicAuth("merchant", "merchant-password") + jsonBody(json { "amount" to "KUDOS:1" }) + }.assertOk().run { + val resp = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(bodyAsText()) + val uuid = resp.taler_withdraw_uri.split("/").last() + + // Check err + client.post("/withdrawals/$uuid/confirm").assertStatus(HttpStatusCode.UnprocessableEntity) + } + + // Check confirm selected + client.post("/accounts/merchant/withdrawals") { + basicAuth("merchant", "merchant-password") + jsonBody(json { "amount" to "KUDOS:1" }) + }.assertOk().run { + val resp = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(bodyAsText()) + val uuid = resp.taler_withdraw_uri.split("/").last() + client.post("/taler-integration/withdrawal-operation/$uuid") { + jsonBody(json { + "reserve_pub" to randEddsaPublicKey() + "selected_exchange" to IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ") + }) + }.assertOk() + + // Check OK + client.post("/withdrawals/$uuid/confirm").assertOk() + // Check idempotence + client.post("/withdrawals/$uuid/confirm").assertOk() + } + + // Check confirm aborted + client.post("/accounts/merchant/withdrawals") { + basicAuth("merchant", "merchant-password") + jsonBody(json { "amount" to "KUDOS:1" }) + }.assertOk().run { + val resp = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(bodyAsText()) + val uuid = resp.taler_withdraw_uri.split("/").last() + client.post("/taler-integration/withdrawal-operation/$uuid") { + jsonBody(json { + "reserve_pub" to randEddsaPublicKey() + "selected_exchange" to IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ") + }) + }.assertOk() + client.post("/withdrawals/$uuid/abort").assertOk() + + // Check error + client.post("/withdrawals/$uuid/confirm").assertConflict() + .assertErr(TalerErrorCode.TALER_EC_BANK_CONFIRM_ABORT_CONFLICT) } + + // Check bad UUID + client.post("/withdrawals/chocolate/confirm").assertBadRequest() + + // Check unknown + client.post("/withdrawals/${UUID.randomUUID()}/confirm").assertNotFound() } -} +}
\ No newline at end of file diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt index 549a489b..74ad25ff 100644 --- a/bank/src/test/kotlin/DatabaseTest.kt +++ b/bank/src/test/kotlin/DatabaseTest.kt @@ -108,7 +108,7 @@ class DatabaseTest { * given by the exchange to pay one merchant. */ @Test - fun talerTransferTest() = setupDb { db -> + fun talerTransferTest() = dbSetup { db -> val exchangeReq = TransferRequest( amount = TalerAmount(9, 0, "KUDOS"), credit_account = IbanPayTo("payto://iban/BAR-IBAN-ABC"), @@ -131,7 +131,7 @@ class DatabaseTest { } @Test - fun bearerTokenTest() = setupDb { db -> + fun bearerTokenTest() = dbSetup { db -> val tokenBytes = ByteArray(32) Random().nextBytes(tokenBytes) val token = BearerToken( @@ -148,7 +148,7 @@ class DatabaseTest { } @Test - fun tokenDeletionTest() = setupDb { db -> + fun tokenDeletionTest() = dbSetup { db -> val token = ByteArray(32) // Token not there, must fail. assert(!db.bearerTokenDelete(token)) @@ -172,7 +172,7 @@ class DatabaseTest { } @Test - fun bankTransactionsTest() = setupDb { db -> + fun bankTransactionsTest() = dbSetup { db -> val fooId = db.customerCreate(customerFoo) assert(fooId != null) val barId = db.customerCreate(customerBar) @@ -253,7 +253,7 @@ class DatabaseTest { } @Test - fun customerCreationTest() = setupDb { db -> + fun customerCreationTest() = dbSetup { db -> assert(db.customerGetFromLogin("foo") == null) db.customerCreate(customerFoo) assert(db.customerGetFromLogin("foo")?.name == "Foo") @@ -262,7 +262,7 @@ class DatabaseTest { } @Test - fun bankAccountTest() = setupDb { db -> + fun bankAccountTest() = dbSetup { db -> val currency = "KUDOS" assert(db.bankAccountGetFromOwnerId(1L) == null) assert(db.customerCreate(customerFoo) != null) @@ -272,7 +272,7 @@ class DatabaseTest { } @Test - fun withdrawalTest() = setupDb { db -> + fun withdrawalTest() = dbSetup { db -> val uuid = UUID.randomUUID() val currency = "KUDOS" assert(db.customerCreate(customerFoo) != null) @@ -302,7 +302,7 @@ class DatabaseTest { } // Only testing the interaction between Kotlin and the DBMS. No actual logic tested. @Test - fun historyTest() = setupDb { db -> + fun historyTest() = dbSetup { db -> val currency = "KUDOS" db.customerCreate(customerFoo); db.bankAccountCreate(bankAccountFoo) db.customerCreate(customerBar); db.bankAccountCreate(bankAccountBar) @@ -330,7 +330,7 @@ class DatabaseTest { assert(backward[0].row_id <= 50 && backward.size == 2 && backward[0].row_id > backward[1].row_id) } @Test - fun cashoutTest() = setupDb { db -> + fun cashoutTest() = dbSetup { db -> val currency = "KUDOS" val op = Cashout( cashoutUuid = UUID.randomUUID(), @@ -385,7 +385,7 @@ class DatabaseTest { // Tests the retrieval of many accounts, used along GET /accounts @Test - fun accountsForAdminTest() = setupDb { db -> + fun accountsForAdminTest() = dbSetup { db -> assert(db.accountsGetForAdmin().isEmpty()) // No data exists yet. assert(db.customerCreate(customerFoo) != null) assert(db.bankAccountCreate(bankAccountFoo) != null) @@ -397,7 +397,7 @@ class DatabaseTest { } @Test - fun passwordChangeTest() = setupDb { db -> + fun passwordChangeTest() = dbSetup { db -> // foo not found, this fails. assert(!db.customerChangePassword("foo", "won't make it")) // creating foo. @@ -407,7 +407,7 @@ class DatabaseTest { } @Test - fun getPublicAccountsTest() = setupDb { db -> + fun getPublicAccountsTest() = dbSetup { db -> // Expecting empty, no accounts exist yet. assert(db.accountsGetPublic("KUDOS").isEmpty()) // Make a NON-public account, so expecting still an empty result. @@ -442,7 +442,7 @@ class DatabaseTest { * PATCH /accounts/foo endpoint. */ @Test - fun accountReconfigTest() = setupDb { db -> + fun accountReconfigTest() = dbSetup { db -> // asserting for the customer not being found. db.accountReconfig( "foo", diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt index d756a4b6..503dfdea 100644 --- a/bank/src/test/kotlin/helpers.kt +++ b/bank/src/test/kotlin/helpers.kt @@ -49,6 +49,22 @@ val bankAccountExchange = BankAccount( isTalerExchange = true ) +fun setup( + conf: String = "test.conf", + lambda: suspend (Database, BankApplicationContext) -> Unit +) { + val config = talerConfig("conf/$conf") + val dbCfg = config.loadDbConfig() + resetDatabaseTables(dbCfg, "libeufin-bank") + initializeDatabaseTables(dbCfg, "libeufin-bank") + val ctx = config.loadBankApplicationContext() + Database(dbCfg.dbConnStr, ctx.currency).use { + runBlocking { + lambda(it, ctx) + } + } +} + fun bankSetup( conf: String = "test.conf", lambda: suspend ApplicationTestBuilder.(Database) -> Unit @@ -77,23 +93,7 @@ fun bankSetup( } } -fun setup( - conf: String = "test.conf", - lambda: suspend (Database, BankApplicationContext) -> Unit -){ - val config = talerConfig("conf/$conf") - val dbCfg = config.loadDbConfig() - resetDatabaseTables(dbCfg, "libeufin-bank") - initializeDatabaseTables(dbCfg, "libeufin-bank") - val ctx = config.loadBankApplicationContext() - Database(dbCfg.dbConnStr, ctx.currency).use { - runBlocking { - lambda(it, ctx) - } - } -} - -fun setupDb(lambda: suspend (Database) -> Unit) { +fun dbSetup(lambda: suspend (Database) -> Unit) { setup() { db, _ -> lambda(db) } } @@ -119,8 +119,6 @@ suspend fun HttpResponse.assertErr(code: TalerErrorCode): HttpResponse { return this } - - fun BankTransactionResult.assertSuccess() { assertEquals(BankTransactionResult.SUCCESS, this) } |