libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

DatabaseTest.kt (7461B)


      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.http.*
     21 import kotlinx.coroutines.*
     22 import org.junit.Test
     23 import org.slf4j.LoggerFactory
     24 import tech.libeufin.bank.*
     25 import tech.libeufin.bank.db.AccountDAO.AccountCreationResult
     26 import tech.libeufin.bank.db.TanDAO.*
     27 import tech.libeufin.common.*
     28 import tech.libeufin.common.assertOk
     29 import tech.libeufin.common.db.*
     30 import tech.libeufin.common.json
     31 import tech.libeufin.common.test.*
     32 import java.time.Duration
     33 import java.time.Instant
     34 import java.time.temporal.ChronoUnit
     35 import java.util.UUID
     36 import java.util.concurrent.TimeUnit
     37 import kotlin.test.assertEquals
     38 import kotlin.test.assertIs
     39 import kotlin.test.assertNull
     40 import kotlin.test.assertNotEquals
     41 
     42 class DatabaseTest {
     43 
     44     @Test
     45     fun notificationConnectionsClose() = setup { db, cfg ->
     46         val source = pgDataSource(cfg.dbCfg.dbConnStr)
     47         val applicationName = "notification-test-${UUID.randomUUID()}"
     48         source.applicationName = applicationName
     49         val firstNotification = CompletableDeferred<Unit>()
     50         val secondNotification = CompletableDeferred<Unit>()
     51         var reconnect = true
     52 
     53         db.conn { observer ->
     54             fun watcherPids(): List<Int> = observer.prepareStatement(
     55                 "SELECT pid FROM pg_stat_activity WHERE application_name = ? AND query = 'LISTEN watcher_test' AND state = 'idle'"
     56             ).use { stmt ->
     57                 stmt.setString(1, applicationName)
     58                 stmt.executeQuery().use { rows ->
     59                     buildList { while (rows.next()) add(rows.getInt(1)) }
     60                 }
     61             }
     62 
     63             val watcher = watchNotifications(
     64                 source, "libeufin_bank", LoggerFactory.getLogger("notification-test"),
     65                 mapOf("watcher_test" to {
     66                     if (reconnect) {
     67                         reconnect = false
     68                         firstNotification.complete(Unit)
     69                         // Exercise cleanup on the retry path as well as on close().
     70                         throw IllegalStateException("test notification reconnect")
     71                     }
     72                     secondNotification.complete(Unit)
     73                 })
     74             )
     75             watcher.use {
     76                 val firstPid = withTimeout(5000) {
     77                     while (watcherPids().isEmpty()) delay(10)
     78                     watcherPids().single()
     79                 }
     80                 observer.execSQLUpdate("NOTIFY watcher_test, 'first'")
     81                 withTimeout(5000) { firstNotification.await() }
     82                 val secondPid = withTimeout(5000) {
     83                     while (true) {
     84                         val pids = watcherPids()
     85                         if (pids.size == 1 && pids.single() != firstPid) break
     86                         delay(10)
     87                     }
     88                     watcherPids().single()
     89                 }
     90                 assertNotEquals(firstPid, secondPid)
     91                 observer.execSQLUpdate("NOTIFY watcher_test, 'second'")
     92                 withTimeout(5000) { secondNotification.await() }
     93             }
     94             withTimeout(5000) {
     95                 while (watcherPids().isNotEmpty()) delay(10)
     96             }
     97         }
     98     }
     99     
    100     // Testing the helper that creates the admin account.
    101     @Test
    102     fun createAdmin() = setup { db, ctx ->
    103         // Create admin account
    104         assertIs<AccountCreationResult.Success>(createAdminAccount(db, ctx))
    105         // Checking idempotency
    106         assertEquals(AccountCreationResult.UsernameReuse, createAdminAccount(db, ctx))
    107     }
    108 
    109     @Test
    110     fun tanChallenge() = bankSetup { db -> db.conn { conn ->
    111         val validityPeriod = Duration.ofHours(1)
    112         val retransmissionPeriod: Duration = Duration.ofMinutes(1)
    113         val retryCounter = 3
    114 
    115         suspend fun create(code: String, timestamp: Instant): UUID {
    116             return db.tan.new(
    117                 hbody = Base32Crockford64B.rand(),
    118                 salt = Base32Crockford16B.rand(),
    119                 username = "customer", 
    120                 op = Operation.withdrawal,
    121                 code = code,
    122                 timestamp = timestamp,
    123                 retryCounter = retryCounter,
    124                 validityPeriod = validityPeriod,
    125                 tanChannel = TanChannel.sms,
    126                 tanInfo = "+88"
    127             )
    128         }
    129 
    130         suspend fun markSent(id: UUID, timestamp: Instant) {
    131             db.tan.markSent(id, timestamp + retransmissionPeriod)
    132         }
    133 
    134         suspend fun send(id: UUID, code: String, timestamp: Instant): String? {
    135             return (db.tan.send(
    136                 id,
    137                 timestamp,
    138                 10
    139             ) as? TanSendResult.Send)?.tanCode
    140         }
    141         
    142         val now = Instant.now()
    143         val expired = now + validityPeriod
    144         val retransmit = now + retransmissionPeriod
    145 
    146         // Check basic
    147         create("good-code", now).run {
    148             // Bad code
    149             assertEquals(TanSolveResult.BadCode, db.tan.solve(this, "bad-code", now))
    150             // Good code
    151             assertIs<TanSolveResult.Success>(db.tan.solve(this, "good-code", now))
    152             // Never resend a confirmed challenge
    153             assertEquals(TanSendResult.Solved, db.tan.send(this, now, 10))
    154             // Confirmed challenge always ok
    155             assertIs<TanSolveResult.Success>(db.tan.solve(this, "good-code", now))
    156         }
    157 
    158         // Check retry
    159         create("good-code", now).run {
    160             markSent(this, now)
    161             // Bad code
    162             repeat(retryCounter-1) {
    163                 assertEquals(TanSolveResult.BadCode, db.tan.solve(this, "bad-code", now))
    164             }
    165             assertEquals(TanSolveResult.NoRetry, db.tan.solve(this, "bad-code", now))
    166             // Good code fail
    167             assertEquals(TanSolveResult.NoRetry, db.tan.solve(this, "good-code", now))
    168             // Exhausted challenge IDs are terminal and cannot be resent.
    169             assertEquals(TanSendResult.Expired, db.tan.send(this, now, 10))
    170         }
    171 
    172         // Check retransmission
    173         create("good-code", now).run {
    174             // Failed to send retransmit
    175             assertIs<TanSendResult.Send>(db.tan.send(this, now, 10))
    176             // Code successfully sent and still valid
    177             markSent(this, now)
    178             assertIs<TanSendResult.Success>(db.tan.send(this, now, 10))
    179             // Code is still valid but should be resent
    180             assertIs<TanSendResult.Send>(db.tan.send(this, retransmit, 10))
    181             // Good code fail because expired
    182             assertEquals(TanSolveResult.Expired, db.tan.solve(this, "good-code", expired))
    183             // Expired challenge IDs are terminal and cannot be resent.
    184             assertEquals(TanSendResult.Expired, db.tan.send(this, expired, 10))
    185         }
    186     }}
    187 }