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 }