libeufin

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

commit 16fa54c96f3d9600748ea8b0530fd8ad4b146319
parent 9b44ad96e9073ad9e43ef03d803bedbc01b34a41
Author: MS <ms@taler.net>
Date:   Mon, 11 Sep 2023 15:08:46 +0200

Bank refactoring.

Deleting entirely the previous Sandbox tree and
creating a new one with only one libeufin-bank.

Diffstat:
D.idea/$PRODUCT_WORKSPACE_FILE$ | 20--------------------
D.idea/.gitignore | 4----
D.idea/codeStyles/Project.xml | 12------------
D.idea/codeStyles/codeStyleConfig.xml | 6------
D.idea/dictionaries/dold.xml | 26--------------------------
M.idea/gradle.xml | 6+-----
D.idea/inspectionProfiles/Project_Default.xml | 10----------
M.idea/kotlinc.xml | 3---
D.idea/libraries-with-intellij-classes.xml | 66------------------------------------------------------------------
M.idea/misc.xml | 10+---------
D.idea/runConfigurations/SchedulingTest.xml | 22----------------------
D.idea/runConfigurations/run_sandbox.xml | 22----------------------
D.idea/runConfigurations/test_nexus.xml | 22----------------------
D.idea/runConfigurations/test_sandbox.xml | 24------------------------
D.idea/uiDesigner.xml | 125-------------------------------------------------------------------------------
A.idea/workspace.xml | 232+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbank/build.gradle | 3+++
Dbank/src/main/kotlin/tech/libeufin/bank/CircuitApi.kt | 842-------------------------------------------------------------------------------
Dbank/src/main/kotlin/tech/libeufin/bank/ConversionService.kt | 433-------------------------------------------------------------------------------
Dbank/src/main/kotlin/tech/libeufin/bank/DB.kt | 747-------------------------------------------------------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 266++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Dbank/src/main/kotlin/tech/libeufin/bank/EbicsProtocolBackend.kt | 1436-------------------------------------------------------------------------------
Dbank/src/main/kotlin/tech/libeufin/bank/Helpers.kt | 472-------------------------------------------------------------------------------
Dbank/src/main/kotlin/tech/libeufin/bank/JSON.kt | 155-------------------------------------------------------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 1802+++++++------------------------------------------------------------------------
Dbank/src/main/kotlin/tech/libeufin/bank/XMLEbicsConverter.kt | 71-----------------------------------------------------------------------
Dbank/src/main/kotlin/tech/libeufin/bank/bankAccount.kt | 277-------------------------------------------------------------------------------
Mbank/src/main/resources/logback.xml | 2+-
Dbank/src/test/kotlin/BalanceTest.kt | 115-------------------------------------------------------------------------------
Dbank/src/test/kotlin/DBTest.kt | 153-------------------------------------------------------------------------------
Mbank/src/test/kotlin/DatabaseTest.kt | 90+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Dbank/src/test/kotlin/EbicsErrorTest.kt | 25-------------------------
Abank/src/test/kotlin/LibeuFinApiTest.kt | 27+++++++++++++++++++++++++++
Dbank/src/test/kotlin/StringsTest.kt | 38--------------------------------------
Mdatabase-versioning/new/libeufin-bank-0001.sql | 47++++++++++++++++++++++++++++-------------------
Mdatabase-versioning/new/procedures.sql | 38++++++++++++++------------------------
Mnexus/build.gradle | 6++----
Dnexus/src/test/kotlin/ConversionServiceTest.kt | 396-------------------------------------------------------------------------------
Dnexus/src/test/kotlin/DbEventTest.kt | 72------------------------------------------------------------------------
Dnexus/src/test/kotlin/EbicsTest.kt | 384-------------------------------------------------------------------------------
Dnexus/src/test/kotlin/Iso20022Test.kt | 204-------------------------------------------------------------------------------
Dnexus/src/test/kotlin/JsonTest.kt | 110-------------------------------------------------------------------------------
Dnexus/src/test/kotlin/LetterFormatTest.kt | 26--------------------------
Dnexus/src/test/kotlin/MakeEnv.kt | 773-------------------------------------------------------------------------------
Dnexus/src/test/kotlin/NexusApiTest.kt | 273-------------------------------------------------------------------------------
Dnexus/src/test/kotlin/PainTest.kt | 34----------------------------------
Dnexus/src/test/kotlin/PostFinance.kt | 159-------------------------------------------------------------------------------
Dnexus/src/test/kotlin/SandboxAccessApiTest.kt | 492-------------------------------------------------------------------------------
Dnexus/src/test/kotlin/SandboxBankAccountTest.kt | 74--------------------------------------------------------------------------
Dnexus/src/test/kotlin/SandboxCircuitApiTest.kt | 663-------------------------------------------------------------------------------
Dnexus/src/test/kotlin/SandboxLegacyApiTest.kt | 193-------------------------------------------------------------------------------
Dnexus/src/test/kotlin/SchedulingTest.kt | 180-------------------------------------------------------------------------------
Dnexus/src/test/kotlin/SplitString.kt | 15---------------
Dnexus/src/test/kotlin/SubjectNormalization.kt | 37-------------------------------------
Dnexus/src/test/kotlin/TalerTest.kt | 261-------------------------------------------------------------------------------
Dnexus/src/test/kotlin/XLibeufinBankTest.kt | 160-------------------------------------------------------------------------------
Dnexus/src/test/kotlin/XPathTest.kt | 42------------------------------------------
Dnexus/src/test/resources/iso20022-samples/camt.053/de.camt.053.001.02.xml | 488-------------------------------------------------------------------------------
Dnexus/src/test/resources/logback-test.xml | 28----------------------------
Mutil/build.gradle | 3---
Mutil/src/main/kotlin/Config.kt | 5+----
Mutil/src/main/kotlin/DB.kt | 6+++++-
Mutil/src/main/kotlin/HTTP.kt | 34+++++++++++++++++++++++-----------
Mutil/src/main/kotlin/iban.kt | 4++--
Mutil/src/main/kotlin/startServer.kt | 2++
Mutil/src/main/kotlin/time.kt | 2++
Dutil/src/test/kotlin/StartServerTest.kt | 33---------------------------------
67 files changed, 753 insertions(+), 12055 deletions(-)

diff --git a/.idea/$PRODUCT_WORKSPACE_FILE$ b/.idea/$PRODUCT_WORKSPACE_FILE$ @@ -1,19 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<project version="4"> - <component name="masterDetails"> - <states> - <state key="ProjectJDKs.UI"> - <settings> - <last-edited>12</last-edited> - <splitter-proportions> - <option name="proportions"> - <list> - <option value="0.2" /> - </list> - </option> - </splitter-proportions> - </settings> - </state> - </states> - </component> -</project> -\ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore @@ -1,3 +0,0 @@ - -# Default ignored files -/workspace.xml -\ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml @@ -1,11 +0,0 @@ -<component name="ProjectCodeStyleConfiguration"> - <code_scheme name="Project" version="173"> - <JetCodeStyleSettings> - <option name="SPACE_AROUND_RANGE" value="true" /> - <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" /> - </JetCodeStyleSettings> - <codeStyleSettings language="kotlin"> - <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" /> - </codeStyleSettings> - </code_scheme> -</component> -\ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml @@ -1,5 +0,0 @@ -<component name="ProjectCodeStyleConfiguration"> - <state> - <option name="USE_PER_PROJECT_SETTINGS" value="true" /> - </state> -</component> -\ No newline at end of file diff --git a/.idea/dictionaries/dold.xml b/.idea/dictionaries/dold.xml @@ -1,25 +0,0 @@ -<component name="ProjectDictionaryState"> - <dictionary name="dold"> - <words> - <w>affero</w> - <w>camt</w> - <w>combinators</w> - <w>crdt</w> - <w>cronspec</w> - <w>dbit</w> - <w>ebics</w> - <w>gnunet</w> - <w>iban</w> - <w>infos</w> - <w>keyletter</w> - <w>libeufin</w> - <w>payto</w> - <w>pdng</w> - <w>servicer</w> - <w>sqlite</w> - <w>taler</w> - <w>talerwiregateway</w> - <w>wtid</w> - </words> - </dictionary> -</component> -\ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml @@ -4,16 +4,12 @@ <component name="GradleSettings"> <option name="linkedExternalProjectsSettings"> <GradleProjectSettings> - <option name="delegatedBuild" value="true" /> - <option name="testRunner" value="GRADLE" /> <option name="distributionType" value="DEFAULT_WRAPPED" /> <option name="externalProjectPath" value="$PROJECT_DIR$" /> - <option name="gradleJvm" value="16" /> <option name="modules"> <set> <option value="$PROJECT_DIR$" /> - <option value="$PROJECT_DIR$/nexus" /> - <option value="$PROJECT_DIR$/sandbox" /> + <option value="$PROJECT_DIR$/bank" /> <option value="$PROJECT_DIR$/util" /> </set> </option> diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml @@ -1,9 +0,0 @@ -<component name="InspectionProjectProfileManager"> - <profile version="1.0"> - <option name="myName" value="Project Default" /> - <inspection_tool class="FoldInitializerAndIfToElvis" enabled="false" level="INFO" enabled_by_default="false" /> - <inspection_tool class="JsonStandardCompliance" enabled="true" level="ERROR" enabled_by_default="true"> - <option name="myWarnAboutComments" value="false" /> - </inspection_tool> - </profile> -</component> -\ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml @@ -1,8 +1,5 @@ <?xml version="1.0" encoding="UTF-8"?> <project version="4"> - <component name="Kotlin2JvmCompilerArguments"> - <option name="jvmTarget" value="1.8" /> - </component> <component name="KotlinJpsPluginSettings"> <option name="version" value="1.7.22" /> </component> diff --git a/.idea/libraries-with-intellij-classes.xml b/.idea/libraries-with-intellij-classes.xml @@ -1,65 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<project version="4"> - <component name="libraries-with-intellij-classes"> - <option name="intellijApiContainingLibraries"> - <list> - <LibraryCoordinatesState> - <option name="artifactId" value="ideaIU" /> - <option name="groupId" value="com.jetbrains.intellij.idea" /> - </LibraryCoordinatesState> - <LibraryCoordinatesState> - <option name="artifactId" value="ideaIU" /> - <option name="groupId" value="com.jetbrains" /> - </LibraryCoordinatesState> - <LibraryCoordinatesState> - <option name="artifactId" value="ideaIC" /> - <option name="groupId" value="com.jetbrains.intellij.idea" /> - </LibraryCoordinatesState> - <LibraryCoordinatesState> - <option name="artifactId" value="ideaIC" /> - <option name="groupId" value="com.jetbrains" /> - </LibraryCoordinatesState> - <LibraryCoordinatesState> - <option name="artifactId" value="pycharmPY" /> - <option name="groupId" value="com.jetbrains.intellij.pycharm" /> - </LibraryCoordinatesState> - <LibraryCoordinatesState> - <option name="artifactId" value="pycharmPY" /> - <option name="groupId" value="com.jetbrains" /> - </LibraryCoordinatesState> - <LibraryCoordinatesState> - <option name="artifactId" value="pycharmPC" /> - <option name="groupId" value="com.jetbrains.intellij.pycharm" /> - </LibraryCoordinatesState> - <LibraryCoordinatesState> - <option name="artifactId" value="pycharmPC" /> - <option name="groupId" value="com.jetbrains" /> - </LibraryCoordinatesState> - <LibraryCoordinatesState> - <option name="artifactId" value="clion" /> - <option name="groupId" value="com.jetbrains.intellij.clion" /> - </LibraryCoordinatesState> - <LibraryCoordinatesState> - <option name="artifactId" value="clion" /> - <option name="groupId" value="com.jetbrains" /> - </LibraryCoordinatesState> - <LibraryCoordinatesState> - <option name="artifactId" value="riderRD" /> - <option name="groupId" value="com.jetbrains.intellij.rider" /> - </LibraryCoordinatesState> - <LibraryCoordinatesState> - <option name="artifactId" value="riderRD" /> - <option name="groupId" value="com.jetbrains" /> - </LibraryCoordinatesState> - <LibraryCoordinatesState> - <option name="artifactId" value="goland" /> - <option name="groupId" value="com.jetbrains.intellij.goland" /> - </LibraryCoordinatesState> - <LibraryCoordinatesState> - <option name="artifactId" value="goland" /> - <option name="groupId" value="com.jetbrains" /> - </LibraryCoordinatesState> - </list> - </option> - </component> -</project> -\ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml @@ -1,13 +1,5 @@ <?xml version="1.0" encoding="UTF-8"?> <project version="4"> - <component name="EntryPointsManager"> - <list size="1"> - <item index="0" class="java.lang.String" itemvalue="com.fasterxml.jackson.annotation.JsonValue" /> - </list> - </component> <component name="ExternalStorageConfigurationManager" enabled="true" /> - <component name="FrameworkDetectionExcludesConfiguration"> - <file type="web" url="file://$PROJECT_DIR$" /> - </component> - <component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="11" project-jdk-type="JavaSDK" /> + <component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="16" project-jdk-type="JavaSDK" /> </project> \ No newline at end of file diff --git a/.idea/runConfigurations/SchedulingTest.xml b/.idea/runConfigurations/SchedulingTest.xml @@ -1,21 +0,0 @@ -<component name="ProjectRunConfigurationManager"> - <configuration default="false" name="SchedulingTest" type="GradleRunConfiguration" factoryName="Gradle"> - <ExternalSystemSettings> - <option name="executionName" /> - <option name="externalProjectPath" value="$PROJECT_DIR$" /> - <option name="externalSystemIdString" value="GRADLE" /> - <option name="scriptParameters" value=":nexus:test --tests --quiet &quot;SchedulingTest&quot;" /> - <option name="taskDescriptions"> - <list /> - </option> - <option name="taskNames"> - <list /> - </option> - <option name="vmOptions" /> - </ExternalSystemSettings> - <ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess> - <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess> - <DebugAllEnabled>false</DebugAllEnabled> - <method v="2" /> - </configuration> -</component> -\ No newline at end of file diff --git a/.idea/runConfigurations/run_sandbox.xml b/.idea/runConfigurations/run_sandbox.xml @@ -1,21 +0,0 @@ -<component name="ProjectRunConfigurationManager"> - <configuration default="false" name="run-sandbox" type="GradleRunConfiguration" factoryName="Gradle"> - <ExternalSystemSettings> - <option name="executionName" /> - <option name="externalProjectPath" value="$PROJECT_DIR$/sandbox" /> - <option name="externalSystemIdString" value="GRADLE" /> - <option name="scriptParameters" value="" /> - <option name="taskDescriptions"> - <list /> - </option> - <option name="taskNames"> - <list> - <option value="run" /> - </list> - </option> - <option name="vmOptions" value="" /> - </ExternalSystemSettings> - <GradleScriptDebugEnabled>true</GradleScriptDebugEnabled> - <method v="2" /> - </configuration> -</component> -\ No newline at end of file diff --git a/.idea/runConfigurations/test_nexus.xml b/.idea/runConfigurations/test_nexus.xml @@ -1,21 +0,0 @@ -<component name="ProjectRunConfigurationManager"> - <configuration default="false" name="test-nexus" type="GradleRunConfiguration" factoryName="Gradle"> - <ExternalSystemSettings> - <option name="executionName" /> - <option name="externalProjectPath" value="$PROJECT_DIR$/nexus" /> - <option name="externalSystemIdString" value="GRADLE" /> - <option name="scriptParameters" value="" /> - <option name="taskDescriptions"> - <list /> - </option> - <option name="taskNames"> - <list> - <option value="test" /> - </list> - </option> - <option name="vmOptions" value="" /> - </ExternalSystemSettings> - <GradleScriptDebugEnabled>true</GradleScriptDebugEnabled> - <method v="2" /> - </configuration> -</component> -\ No newline at end of file diff --git a/.idea/runConfigurations/test_sandbox.xml b/.idea/runConfigurations/test_sandbox.xml @@ -1,23 +0,0 @@ -<component name="ProjectRunConfigurationManager"> - <configuration default="false" name="test-sandbox" type="GradleRunConfiguration" factoryName="Gradle" show_console_on_std_out="true"> - <ExternalSystemSettings> - <option name="executionName" /> - <option name="externalProjectPath" value="$PROJECT_DIR$/sandbox" /> - <option name="externalSystemIdString" value="GRADLE" /> - <option name="scriptParameters" value="--info" /> - <option name="taskDescriptions"> - <list /> - </option> - <option name="taskNames"> - <list> - <option value="test" /> - </list> - </option> - <option name="vmOptions" value="" /> - </ExternalSystemSettings> - <ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess> - <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess> - <DebugAllEnabled>false</DebugAllEnabled> - <method v="2" /> - </configuration> -</component> -\ No newline at end of file diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml @@ -1,124 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<project version="4"> - <component name="Palette2"> - <group name="Swing"> - <item class="com.intellij.uiDesigner.HSpacer" tooltip-text="Horizontal Spacer" icon="/com/intellij/uiDesigner/icons/hspacer.png" removable="false" auto-create-binding="false" can-attach-label="false"> - <default-constraints vsize-policy="1" hsize-policy="6" anchor="0" fill="1" /> - </item> - <item class="com.intellij.uiDesigner.VSpacer" tooltip-text="Vertical Spacer" icon="/com/intellij/uiDesigner/icons/vspacer.png" removable="false" auto-create-binding="false" can-attach-label="false"> - <default-constraints vsize-policy="6" hsize-policy="1" anchor="0" fill="2" /> - </item> - <item class="javax.swing.JPanel" icon="/com/intellij/uiDesigner/icons/panel.png" removable="false" auto-create-binding="false" can-attach-label="false"> - <default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3" /> - </item> - <item class="javax.swing.JScrollPane" icon="/com/intellij/uiDesigner/icons/scrollPane.png" removable="false" auto-create-binding="false" can-attach-label="true"> - <default-constraints vsize-policy="7" hsize-policy="7" anchor="0" fill="3" /> - </item> - <item class="javax.swing.JButton" icon="/com/intellij/uiDesigner/icons/button.png" removable="false" auto-create-binding="true" can-attach-label="false"> - <default-constraints vsize-policy="0" hsize-policy="3" anchor="0" fill="1" /> - <initial-values> - <property name="text" value="Button" /> - </initial-values> - </item> - <item class="javax.swing.JRadioButton" icon="/com/intellij/uiDesigner/icons/radioButton.png" removable="false" auto-create-binding="true" can-attach-label="false"> - <default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" /> - <initial-values> - <property name="text" value="RadioButton" /> - </initial-values> - </item> - <item class="javax.swing.JCheckBox" icon="/com/intellij/uiDesigner/icons/checkBox.png" removable="false" auto-create-binding="true" can-attach-label="false"> - <default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" /> - <initial-values> - <property name="text" value="CheckBox" /> - </initial-values> - </item> - <item class="javax.swing.JLabel" icon="/com/intellij/uiDesigner/icons/label.png" removable="false" auto-create-binding="false" can-attach-label="false"> - <default-constraints vsize-policy="0" hsize-policy="0" anchor="8" fill="0" /> - <initial-values> - <property name="text" value="Label" /> - </initial-values> - </item> - <item class="javax.swing.JTextField" icon="/com/intellij/uiDesigner/icons/textField.png" removable="false" auto-create-binding="true" can-attach-label="true"> - <default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1"> - <preferred-size width="150" height="-1" /> - </default-constraints> - </item> - <item class="javax.swing.JPasswordField" icon="/com/intellij/uiDesigner/icons/passwordField.png" removable="false" auto-create-binding="true" can-attach-label="true"> - <default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1"> - <preferred-size width="150" height="-1" /> - </default-constraints> - </item> - <item class="javax.swing.JFormattedTextField" icon="/com/intellij/uiDesigner/icons/formattedTextField.png" removable="false" auto-create-binding="true" can-attach-label="true"> - <default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1"> - <preferred-size width="150" height="-1" /> - </default-constraints> - </item> - <item class="javax.swing.JTextArea" icon="/com/intellij/uiDesigner/icons/textArea.png" removable="false" auto-create-binding="true" can-attach-label="true"> - <default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3"> - <preferred-size width="150" height="50" /> - </default-constraints> - </item> - <item class="javax.swing.JTextPane" icon="/com/intellij/uiDesigner/icons/textPane.png" removable="false" auto-create-binding="true" can-attach-label="true"> - <default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3"> - <preferred-size width="150" height="50" /> - </default-constraints> - </item> - <item class="javax.swing.JEditorPane" icon="/com/intellij/uiDesigner/icons/editorPane.png" removable="false" auto-create-binding="true" can-attach-label="true"> - <default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3"> - <preferred-size width="150" height="50" /> - </default-constraints> - </item> - <item class="javax.swing.JComboBox" icon="/com/intellij/uiDesigner/icons/comboBox.png" removable="false" auto-create-binding="true" can-attach-label="true"> - <default-constraints vsize-policy="0" hsize-policy="2" anchor="8" fill="1" /> - </item> - <item class="javax.swing.JTable" icon="/com/intellij/uiDesigner/icons/table.png" removable="false" auto-create-binding="true" can-attach-label="false"> - <default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3"> - <preferred-size width="150" height="50" /> - </default-constraints> - </item> - <item class="javax.swing.JList" icon="/com/intellij/uiDesigner/icons/list.png" removable="false" auto-create-binding="true" can-attach-label="false"> - <default-constraints vsize-policy="6" hsize-policy="2" anchor="0" fill="3"> - <preferred-size width="150" height="50" /> - </default-constraints> - </item> - <item class="javax.swing.JTree" icon="/com/intellij/uiDesigner/icons/tree.png" removable="false" auto-create-binding="true" can-attach-label="false"> - <default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3"> - <preferred-size width="150" height="50" /> - </default-constraints> - </item> - <item class="javax.swing.JTabbedPane" icon="/com/intellij/uiDesigner/icons/tabbedPane.png" removable="false" auto-create-binding="true" can-attach-label="false"> - <default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3"> - <preferred-size width="200" height="200" /> - </default-constraints> - </item> - <item class="javax.swing.JSplitPane" icon="/com/intellij/uiDesigner/icons/splitPane.png" removable="false" auto-create-binding="false" can-attach-label="false"> - <default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3"> - <preferred-size width="200" height="200" /> - </default-constraints> - </item> - <item class="javax.swing.JSpinner" icon="/com/intellij/uiDesigner/icons/spinner.png" removable="false" auto-create-binding="true" can-attach-label="true"> - <default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" /> - </item> - <item class="javax.swing.JSlider" icon="/com/intellij/uiDesigner/icons/slider.png" removable="false" auto-create-binding="true" can-attach-label="false"> - <default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" /> - </item> - <item class="javax.swing.JSeparator" icon="/com/intellij/uiDesigner/icons/separator.png" removable="false" auto-create-binding="false" can-attach-label="false"> - <default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3" /> - </item> - <item class="javax.swing.JProgressBar" icon="/com/intellij/uiDesigner/icons/progressbar.png" removable="false" auto-create-binding="true" can-attach-label="false"> - <default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1" /> - </item> - <item class="javax.swing.JToolBar" icon="/com/intellij/uiDesigner/icons/toolbar.png" removable="false" auto-create-binding="false" can-attach-label="false"> - <default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1"> - <preferred-size width="-1" height="20" /> - </default-constraints> - </item> - <item class="javax.swing.JToolBar$Separator" icon="/com/intellij/uiDesigner/icons/toolbarSeparator.png" removable="false" auto-create-binding="false" can-attach-label="false"> - <default-constraints vsize-policy="0" hsize-policy="0" anchor="0" fill="1" /> - </item> - <item class="javax.swing.JScrollBar" icon="/com/intellij/uiDesigner/icons/scrollbar.png" removable="false" auto-create-binding="true" can-attach-label="false"> - <default-constraints vsize-policy="6" hsize-policy="0" anchor="0" fill="2" /> - </item> - </group> - </component> -</project> -\ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml @@ -0,0 +1,231 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="AutoImportSettings"> + <option name="autoReloadType" value="SELECTIVE" /> + </component> + <component name="ChangeListManager"> + <list default="true" id="9436eb1e-de48-4f11-8ff7-f359340cb458" name="Changes" comment=""> + <change afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> + <change afterPath="$PROJECT_DIR$/bank/src/test/kotlin/LibeuFinApiTest.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/.idea/$PRODUCT_WORKSPACE_FILE$" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/.idea/.gitignore" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/.idea/codeStyles/Project.xml" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/.idea/codeStyles/codeStyleConfig.xml" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/.idea/dictionaries/dold.xml" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/.idea/gradle.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/gradle.xml" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/.idea/inspectionProfiles/Project_Default.xml" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/.idea/kotlinc.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/kotlinc.xml" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/.idea/libraries-with-intellij-classes.xml" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/.idea/misc.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/misc.xml" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/.idea/runConfigurations/SchedulingTest.xml" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/.idea/runConfigurations/run_sandbox.xml" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/.idea/runConfigurations/test_nexus.xml" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/.idea/runConfigurations/test_sandbox.xml" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/.idea/uiDesigner.xml" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/bank/README" beforeDir="false" afterPath="$PROJECT_DIR$/bank/README" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/bank/build.gradle" beforeDir="false" afterPath="$PROJECT_DIR$/bank/build.gradle" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/CircuitApi.kt" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/CircuitApi.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/ConversionService.kt" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/ConversionService.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/DB.kt" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/DB.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/Database.kt" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/Database.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/EbicsProtocolBackend.kt" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/EbicsProtocolBackend.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/JSON.kt" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/JSON.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/Main.kt" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/Main.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/XMLEbicsConverter.kt" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/XMLEbicsConverter.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/bankAccount.kt" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/main/kotlin/tech/libeufin/bank/bankAccount.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/bank/src/main/resources/logback.xml" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/main/resources/logback.xml" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/bank/src/test/kotlin/BalanceTest.kt" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/test/kotlin/BalanceTest.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/bank/src/test/kotlin/DBTest.kt" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/test/kotlin/DBTest.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/bank/src/test/kotlin/DatabaseTest.kt" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/test/kotlin/DatabaseTest.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/bank/src/test/kotlin/EbicsErrorTest.kt" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/test/kotlin/EbicsErrorTest.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/bank/src/test/kotlin/StringsTest.kt" beforeDir="false" afterPath="$PROJECT_DIR$/bank/src/test/kotlin/StringsTest.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/database-versioning/new/libeufin-bank-0001.sql" beforeDir="false" afterPath="$PROJECT_DIR$/database-versioning/new/libeufin-bank-0001.sql" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/database-versioning/new/procedures.sql" beforeDir="false" afterPath="$PROJECT_DIR$/database-versioning/new/procedures.sql" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/build.gradle" beforeDir="false" afterPath="$PROJECT_DIR$/nexus/build.gradle" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/ConversionServiceTest.kt" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/DbEventTest.kt" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/EbicsTest.kt" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/Iso20022Test.kt" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/JsonTest.kt" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/LetterFormatTest.kt" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/MakeEnv.kt" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/NexusApiTest.kt" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/PainTest.kt" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/PostFinance.kt" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/SandboxAccessApiTest.kt" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/SandboxBankAccountTest.kt" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/SandboxCircuitApiTest.kt" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/SandboxLegacyApiTest.kt" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/SchedulingTest.kt" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/SplitString.kt" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/SubjectNormalization.kt" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/TalerTest.kt" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/XLibeufinBankTest.kt" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/kotlin/XPathTest.kt" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/resources/iso20022-samples/camt.053/de.camt.053.001.02.xml" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/nexus/src/test/resources/logback-test.xml" beforeDir="false" /> + <change beforePath="$PROJECT_DIR$/util/build.gradle" beforeDir="false" afterPath="$PROJECT_DIR$/util/build.gradle" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/util/src/main/kotlin/Config.kt" beforeDir="false" afterPath="$PROJECT_DIR$/util/src/main/kotlin/Config.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/util/src/main/kotlin/DB.kt" beforeDir="false" afterPath="$PROJECT_DIR$/util/src/main/kotlin/DB.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/util/src/main/kotlin/HTTP.kt" beforeDir="false" afterPath="$PROJECT_DIR$/util/src/main/kotlin/HTTP.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/util/src/main/kotlin/iban.kt" beforeDir="false" afterPath="$PROJECT_DIR$/util/src/main/kotlin/iban.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/util/src/main/kotlin/startServer.kt" beforeDir="false" afterPath="$PROJECT_DIR$/util/src/main/kotlin/startServer.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/util/src/main/kotlin/time.kt" beforeDir="false" afterPath="$PROJECT_DIR$/util/src/main/kotlin/time.kt" afterDir="false" /> + <change beforePath="$PROJECT_DIR$/util/src/test/kotlin/StartServerTest.kt" beforeDir="false" /> + </list> + <option name="SHOW_DIALOG" value="false" /> + <option name="HIGHLIGHT_CONFLICTS" value="true" /> + <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" /> + <option name="LAST_RESOLUTION" value="IGNORE" /> + </component> + <component name="ExternalProjectsData"> + <projectState path="$PROJECT_DIR$"> + <ProjectState /> + </projectState> + </component> + <component name="ExternalProjectsManager"> + <system id="GRADLE"> + <state> + <task path="$PROJECT_DIR$"> + <activation /> + </task> + <projects_view /> + </state> + </system> + </component> + <component name="Git.Settings"> + <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> + </component> + <component name="MarkdownSettingsMigration"> + <option name="stateVersion" value="1" /> + </component> + <component name="ProjectId" id="2V4jS1FHAIvLu5eYODKLzGxNSP4" /> + <component name="ProjectViewState"> + <option name="hideEmptyMiddlePackages" value="true" /> + <option name="showLibraryContents" value="true" /> + </component> + <component name="PropertiesComponent">{ + &quot;keyToString&quot;: { + &quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;, + &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot; + } +}</component> + <component name="RunManager" selected="Gradle.LibeuFinApiTest.createAccountTest"> + <configuration name="DatabaseTest" type="GradleRunConfiguration" factoryName="Gradle" temporary="true"> + <ExternalSystemSettings> + <option name="executionName" /> + <option name="externalProjectPath" value="$PROJECT_DIR$" /> + <option name="externalSystemIdString" value="GRADLE" /> + <option name="scriptParameters" value="--quiet" /> + <option name="taskDescriptions"> + <list /> + </option> + <option name="taskNames"> + <list> + <option value=":bank:test" /> + <option value="--tests" /> + <option value="&quot;DatabaseTest&quot;" /> + </list> + </option> + <option name="vmOptions" /> + </ExternalSystemSettings> + <ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess> + <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess> + <DebugAllEnabled>false</DebugAllEnabled> + <method v="2" /> + </configuration> + <configuration name="DatabaseTest.bearerTokenTest" type="GradleRunConfiguration" factoryName="Gradle" temporary="true"> + <ExternalSystemSettings> + <option name="executionName" /> + <option name="externalProjectPath" value="$PROJECT_DIR$" /> + <option name="externalSystemIdString" value="GRADLE" /> + <option name="scriptParameters" value="--quiet" /> + <option name="taskDescriptions"> + <list /> + </option> + <option name="taskNames"> + <list> + <option value=":bank:test" /> + <option value="--tests" /> + <option value="&quot;DatabaseTest.bearerTokenTest&quot;" /> + </list> + </option> + <option name="vmOptions" /> + </ExternalSystemSettings> + <ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess> + <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess> + <DebugAllEnabled>false</DebugAllEnabled> + <method v="2" /> + </configuration> + <configuration name="LibeuFinApiTest.createAccountTest" type="GradleRunConfiguration" factoryName="Gradle" temporary="true"> + <ExternalSystemSettings> + <option name="executionName" /> + <option name="externalProjectPath" value="$PROJECT_DIR$" /> + <option name="externalSystemIdString" value="GRADLE" /> + <option name="scriptParameters" value="--quiet" /> + <option name="taskDescriptions"> + <list /> + </option> + <option name="taskNames"> + <list> + <option value=":bank:test" /> + <option value="--tests" /> + <option value="&quot;LibeuFinApiTest.createAccountTest&quot;" /> + </list> + </option> + <option name="vmOptions" /> + </ExternalSystemSettings> + <ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess> + <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess> + <DebugAllEnabled>false</DebugAllEnabled> + <method v="2" /> + </configuration> + <configuration name="libeufin [dependencies]" type="GradleRunConfiguration" factoryName="Gradle" temporary="true"> + <ExternalSystemSettings> + <option name="executionName" /> + <option name="externalProjectPath" value="$PROJECT_DIR$" /> + <option name="externalSystemIdString" value="GRADLE" /> + <option name="scriptParameters" /> + <option name="taskDescriptions"> + <list /> + </option> + <option name="taskNames"> + <list> + <option value="dependencies" /> + </list> + </option> + <option name="vmOptions" /> + </ExternalSystemSettings> + <ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess> + <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess> + <DebugAllEnabled>false</DebugAllEnabled> + <method v="2" /> + </configuration> + <list> + <item itemvalue="Gradle.libeufin [dependencies]" /> + <item itemvalue="Gradle.DatabaseTest" /> + <item itemvalue="Gradle.DatabaseTest.bearerTokenTest" /> + <item itemvalue="Gradle.LibeuFinApiTest.createAccountTest" /> + </list> + <recent_temporary> + <list> + <item itemvalue="Gradle.LibeuFinApiTest.createAccountTest" /> + <item itemvalue="Gradle.libeufin [dependencies]" /> + <item itemvalue="Gradle.DatabaseTest" /> + <item itemvalue="Gradle.DatabaseTest.bearerTokenTest" /> + </list> + </recent_temporary> + </component> + <component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" /> + <component name="TaskManager"> + <task active="true" id="Default" summary="Default task"> + <changelist id="9436eb1e-de48-4f11-8ff7-f359340cb458" name="Changes" comment="" /> + <created>1694102242996</created> + <option name="number" value="Default" /> + <option name="presentableId" value="Default" /> + <updated>1694102242996</updated> + </task> + <servers /> + </component> +</project> +\ No newline at end of file diff --git a/bank/build.gradle b/bank/build.gradle @@ -73,6 +73,9 @@ dependencies { implementation "io.ktor:ktor-server-test-host:$ktor_version" implementation "io.ktor:ktor-auth:$ktor_auth_version" implementation "io.ktor:ktor-serialization-jackson:$ktor_version" + // implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") + // implementation("io.ktor:ktor-serialization-gson:$ktor_version") + implementation "io.ktor:ktor-server-request-validation:$ktor_version" testImplementation 'org.jetbrains.kotlin:kotlin-test-junit:1.5.21' testImplementation 'org.jetbrains.kotlin:kotlin-test:1.5.21' diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CircuitApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CircuitApi.kt @@ -1,841 +0,0 @@ -package tech.libeufin.bank - -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.ktor.server.application.* -import io.ktor.http.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.bank.CashoutOperationsTable.uuid -import tech.libeufin.util.* -import java.io.File -import java.io.InputStreamReader -import java.math.BigDecimal -import java.util.concurrent.TimeUnit -import kotlin.text.toByteArray - -// CIRCUIT API TYPES -/** - * This type is used by clients to ask the bank a cash-out - * estimate to show to the customer before they confirm the - * cash-out creation. - */ -data class CircuitCashoutEstimateRequest( - /** - * This is the amount that the customer will get deducted - * from their regio bank account to fuel the cash-out operation. - */ - val amount_debit: String -) -data class CircuitCashoutRequest( - val subject: String?, - val amount_debit: String, // As specified by the user via the SPA. - val amount_credit: String, // What actually to transfer after the rates. - /** - * The String type here allows more flexibility with regard to - * the supported TAN methods. This way, supported TAN methods - * can be specified via the configuration or when starting the - * bank. OTOH, catching unsupported TAN methods only via the - * 'enum' type would require to change the source code upon every - * change in the TAN policy. - */ - val tan_channel: String? -) -const val FIAT_CURRENCY = "CHF" // FIXME: make configurable. -// Configuration response: -data class ConfigResp( - val name: String = "circuit", - val version: String = PROTOCOL_VERSION_UNIFIED, - val ratios_and_fees: RatioAndFees, - val fiat_currency: String = FIAT_CURRENCY -) - -// After fixing #7527, the values held by this -// type must be read from the configuration. -data class RatioAndFees( - val buy_at_ratio: Float = 1F, - val sell_at_ratio: Float = 0.95F, - val buy_in_fee: Float = 0F, - val sell_out_fee: Float = 0F -) -val ratiosAndFees = RatioAndFees() - -// User registration request -data class CircuitAccountRequest( - val username: String, - val password: String, - val contact_data: CircuitContactData, - val name: String, - val cashout_address: String, // payto - val internal_iban: String? // Shall be "= null" ? -) -// User contact data to send the TAN. -data class CircuitContactData( - val email: String?, - val phone: String? -) - -data class CircuitAccountReconfiguration( - val contact_data: CircuitContactData, - val cashout_address: String?, - val name: String? = null -) - -data class AccountPasswordChange( - val new_password: String -) - -/** - * That doesn't belong to the Access API because it - * contains the cash-out address and the contact data. - */ -data class CircuitAccountInfo( - val username: String, - val iban: String, - val contact_data: CircuitContactData, - val name: String, - val cashout_address: String? -) - -data class CashoutOperationInfo( - val status: CashoutOperationStatus, - val amount_credit: String, - val amount_debit: String, - val subject: String, - val creation_time: Long, // milliseconds - val confirmation_time: Long?, // milliseconds - val tan_channel: SupportedTanChannels, - val account: String, - val cashout_address: String, - val ratios_and_fees: RatioAndFees -) - -data class CashoutConfirmation(val tan: String) - -// Validate phone number -fun checkPhoneNumber(phoneNumber: String): Boolean { - // From Taler TypeScript - // /^\+[0-9 ]*$/; - val regex = "^\\+[1-9][0-9]+$" - val R = Regex(regex) - return R.matches(phoneNumber) -} - -// Validate e-mail address -fun checkEmailAddress(emailAddress: String): Boolean { - // From Taler TypeScript: - // /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; - val regex = "^[a-zA-Z0-9\\.]+@[a-zA-Z0-9\\.]+$" - val R = Regex(regex) - return R.matches(emailAddress) -} - -fun throwIfInstitutionalName(resourceName: String) { - if (resourceName == "bank" || resourceName == "admin") - throw forbidden("Can't operate on institutional resource '$resourceName'") -} - -fun generateCashoutSubject( - amountCredit: AmountWithCurrency, - amountDebit: AmountWithCurrency -): String { - return "Cash-out of ${amountDebit.currency}:${amountDebit.amount}" + - " to ${amountCredit.currency}:${amountCredit.amount}" -} - -/** - * By default, it takes the amount in the regional currency - * and applies ratio and fees to convert it to fiat. If the - * 'fromCredit' parameter is true, then it does the inverse - * operation: returns the regional amount that would lead to - * such fiat amount given in the 'amount' parameter. - */ -fun applyCashoutRatioAndFee( - amount: BigDecimal, - ratiosAndFees: RatioAndFees, - fromCredit: Boolean = false -): BigDecimal { - // Normal case, when the calculation starts from the regional amount. - if (!fromCredit) { - val maybeCashoutAmount = ((amount * ratiosAndFees.sell_at_ratio.toBigDecimal()) - - ratiosAndFees.sell_out_fee.toBigDecimal()).roundToTwoDigits() - // throws 500, since bank should not allow to get negative fiat amounts. - if (maybeCashoutAmount < BigDecimal.ZERO) { - logger.error("Cash-out operation caused a negative fiat output." + - " Regional amount was '$amount', cash-out ratio is '${ratiosAndFees.sell_at_ratio}," + - " cash-out fee is '${ratiosAndFees.sell_out_fee}''" - ) - throw internalServerError("Applying cash-out fees yielded negative fiat amount.") - } - return maybeCashoutAmount - } - // UI convenient case, when the calculation starts from the - // desired fiat amount that the user wants eventually be paid. - return ((amount + ratiosAndFees.sell_out_fee.toBigDecimal()) / - ratiosAndFees.sell_at_ratio.toBigDecimal()).roundToTwoDigits() -} - -/** - * NOTE: future versions take the supported TAN method from - * the configuration, or options passed when starting the bank. - */ -const val LIBEUFIN_TAN_TMP_FILE = "/tmp/libeufin-cashout-tan.txt" -enum class SupportedTanChannels { - SMS, - EMAIL, - FILE // Test channel writing the TAN to the LIBEUFIN_TAN_TMP_FILE location. -} -fun isTanChannelSupported(tanChannel: String): Boolean { - enumValues<SupportedTanChannels>().forEach { - if (tanChannel.uppercase() == it.name) return true - } - return false -} - -var EMAIL_TAN_CMD: String? = null -var SMS_TAN_CMD: String? = null - -// Convenience class to collect TAN data. -private data class TanData( - val cmd: String, - val address: String, - val msg: String -) - -/** - * Runs the command and returns True/False if that succeeded/failed. - * A failed command causes "500 Internal Server Error" to be responded - * along a cash-out creation. 'address' is a phone number or a e-mail address, - * according to which TAN channel is used. 'message' carries the TAN. - * - * The caller is expected to manage the exceptions thrown by this function. - */ -fun runTanCommand(command: String, address: String, message: String): Boolean { - val prep = ProcessBuilder(command, address) - prep.redirectErrorStream(true) // merge STDOUT and STDERR - val proc = prep.start() - proc.outputStream.write(message.toByteArray()) - proc.outputStream.flush(); proc.outputStream.close() - var isSuccessful = false - // Wait the command to finish. - proc.waitFor(10L, TimeUnit.SECONDS) - // Check if timed out. Kill if so. - if (proc.isAlive) { - logger.error("TAN command '$command' timed out, killing it.") - proc.destroy() - // Check if exited gracefully. Kill forcibly if not. - proc.waitFor(5L, TimeUnit.SECONDS) - if (proc.isAlive) { - logger.error("TAN command '$command' didn't terminate after killing it. Try forcefully.") - proc.destroyForcibly() - } - } - // Check if successful. Switch the state if so. - if (proc.exitValue() == 0) isSuccessful = true - // Log STDOUT and STDERR if failed. - if (!isSuccessful) - logger.error(InputStreamReader(proc.inputStream).readText()) - return isSuccessful -} - -fun circuitApi(circuitRoute: Route) { - // Abort a cash-out operation. - circuitRoute.post("/cashouts/{uuid}/abort") { - call.request.basicAuth() // both admin and author allowed - val arg = call.expectUriComponent("uuid") - // Parse and check the UUID. - val maybeUuid = parseUuid(arg) - val maybeOperation = transaction { - CashoutOperationEntity.find { uuid eq maybeUuid }.firstOrNull() - } - if (maybeOperation == null) - throw notFound("Cash-out operation $uuid not found.") - if (maybeOperation.status == CashoutOperationStatus.CONFIRMED) - throw SandboxError( - HttpStatusCode.PreconditionFailed, - "Cash-out operation '$uuid' was confirmed already." - ) - if (maybeOperation.status != CashoutOperationStatus.PENDING) - throw internalServerError("Found an unsupported cash-out operation state: ${maybeOperation.status}") - // Operation found and pending: delete from the database. - transaction { maybeOperation.delete() } - call.respond(HttpStatusCode.NoContent) - return@post - } - // Confirm a cash-out operation - circuitRoute.post("/cashouts/{uuid}/confirm") { - val user = call.request.basicAuth() - // Exclude admin from this operation. - if (user == "admin" || user == "bank") - throw conflict("Institutional user '$user' shouldn't confirm any cash-out.") - // Get the operation identifier. - val operationUuid = parseUuid(call.expectUriComponent("uuid")) - val op = transaction { - CashoutOperationEntity.find { - uuid eq operationUuid - }.firstOrNull() - } - // 404 if the operation is not found. - if (op == null) - throw notFound("Cash-out operation $operationUuid not found") - /** - * Check the TAN. Give precedence to the TAN found - * in the environment, for testing purposes. If that's - * not found, then check with the actual TAN found in - * the database. - */ - val req = call.receive<CashoutConfirmation>() - val maybeTanFromEnv = System.getenv("LIBEUFIN_CASHOUT_TEST_TAN") - if (maybeTanFromEnv != null) - logger.warn("TAN being read from the environment. Assuming tests are being run") - val checkTan = maybeTanFromEnv ?: op.tan - if (req.tan != checkTan) - throw forbidden("The confirmation of '${op.uuid}' has a wrong TAN '${req.tan}'") - /** - * Correct TAN. Wire the funds to the admin's bank account. After - * this step, the conversion monitor should detect this payment and - * soon initiate the final transfer towards the user fiat bank account. - * NOTE: the funds availability got already checked when this operation - * was created. On top of that, the 'wireTransfer()' helper does also - * check for funds availability. */ - val customer = maybeGetCustomer(user ?: throw SandboxError( - HttpStatusCode.ServiceUnavailable, - "This endpoint isn't served when the authentication is disabled." - )) - transaction { - if (op.cashoutAddress != customer?.cashout_address) throw conflict( - "Inconsistent cash-out address: ${op.cashoutAddress} vs ${customer?.cashout_address}" - ) - // 412 if the operation got already confirmed. - if (op.status == CashoutOperationStatus.CONFIRMED) - throw SandboxError( - HttpStatusCode.PreconditionFailed, - "Cash-out operation $operationUuid was already confirmed." - ) - wireTransfer( - debitAccount = op.account, - creditAccount = "admin", - subject = op.subject, - amount = op.amountDebit - ) - op.status = CashoutOperationStatus.CONFIRMED - op.confirmationTime = getSystemTimeNow().toInstant().toEpochMilli() - // TODO(signal this payment over LIBEUFIN_REGIO_INCOMING) - } - call.respond(HttpStatusCode.NoContent) - return@post - } - // Retrieve the status of a cash-out operation. - circuitRoute.get("/cashouts/{uuid}") { - call.request.basicAuth() // both admin and author - val operationUuid = call.expectUriComponent("uuid") - // Parse and check the UUID. - val maybeUuid = parseUuid(operationUuid) - // Get the operation from the database. - val maybeOperation = transaction { - CashoutOperationEntity.find { uuid eq maybeUuid }.firstOrNull() - } - if (maybeOperation == null) - throw notFound("Cash-out operation $operationUuid not found.") - val ret = CashoutOperationInfo( - amount_credit = maybeOperation.amountCredit, - amount_debit = maybeOperation.amountDebit, - subject = maybeOperation.subject, - status = maybeOperation.status, - creation_time = maybeOperation.creationTime, - confirmation_time = maybeOperation.confirmationTime, - tan_channel = maybeOperation.tanChannel, - account = maybeOperation.account, - cashout_address = maybeOperation.cashoutAddress, - ratios_and_fees = RatioAndFees( - buy_in_fee = maybeOperation.buyInFee.toFloat(), - buy_at_ratio = maybeOperation.buyAtRatio.toFloat(), - sell_out_fee = maybeOperation.sellOutFee.toFloat(), - sell_at_ratio = maybeOperation.sellAtRatio.toFloat() - ) - ) - call.respond(ret) - return@get - } - // Gets the list of all the cash-out operations, - // or those belonging to the account given as a parameter. - circuitRoute.get("/cashouts") { - val user = call.request.basicAuth() - val whichAccount = call.request.queryParameters["account"] - /** - * Only admin's allowed to omit the target account (= get - * all the accounts) or to check other customers cash-out - * operations. - */ - if (user != "admin" && whichAccount != user) throw forbidden( - "Ordinary users can only request their own account" - ) - /** - * At this point, the client has the rights over the account(s) - * whose operations are to be returned. Double-checking that - * Admin doesn't ask its own cash-outs, since that's not supported. - */ - if (whichAccount == "admin") throw badRequest("Cash-out for admin is not supported") - - // Preparing the response. - val node = jacksonObjectMapper().createObjectNode() - val maybeArray = node.putArray("cashouts") - - if (whichAccount == null) { // no target account, return all the cash-outs - transaction { - CashoutOperationEntity.all().forEach { - maybeArray.add(it.uuid.toString()) - } - } - } else { // do filter on the target account. - transaction { - CashoutOperationEntity.find { - CashoutOperationsTable.account eq whichAccount - }.forEach { - maybeArray.add(it.uuid.toString()) - } - } - } - if (maybeArray.size() == 0) { - call.respond(HttpStatusCode.NoContent) - return@get - } - call.respond(node) - return@get - } - circuitRoute.get("/cashouts/estimates") { - call.request.basicAuth() - val demobank = ensureDemobank(call) - // Optionally parsing param 'amount_debit' into number and checking its currency - val maybeAmountDebit: String? = call.request.queryParameters["amount_debit"] - val amountDebit: BigDecimal? = if (maybeAmountDebit != null) { - val amount = parseAmount(maybeAmountDebit) - if (amount.currency != demobank.config.currency) throw badRequest( - "parameter 'amount_debit' has the wrong currency: ${amount.currency}" - ) - try { amount.amount.toBigDecimal() } catch (e: Exception) { - throw badRequest("Cannot extract a number from 'amount_debit'") - } - } else null - // Optionally parsing param 'amount_credit' into number and checking its currency - val maybeAmountCredit: String? = call.request.queryParameters["amount_credit"] - val amountCredit: BigDecimal? = if (maybeAmountCredit != null) { - val amount = parseAmount(maybeAmountCredit) - if (amount.currency != FIAT_CURRENCY) throw badRequest( - "parameter 'amount_credit' has the wrong currency: ${amount.currency}" - ) - try { amount.amount.toBigDecimal() } catch (e: Exception) { - throw badRequest("Cannot extract a number from 'amount_credit'") - } - } else null - val respAmountCredit = if (amountDebit != null) { - val estimate = applyCashoutRatioAndFee(amountDebit, ratiosAndFees) - if (amountCredit != null && estimate != amountCredit) throw badRequest( - "Wrong calculation found in 'amount_credit', bank estimates: $estimate" - ) - estimate - } else null - if (amountDebit == null && amountCredit == null) throw badRequest( - "Both 'amount_credit' and 'amount_debit' are missing" - ) - val respAmountDebit = if (amountCredit != null) { - val estimate = applyCashoutRatioAndFee( - amountCredit, - ratiosAndFees, - fromCredit = true - ) - if (amountDebit != null && estimate != amountDebit) throw badRequest( - "Wrong calculation found in 'amount_credit', bank estimates: $estimate" - ) - estimate - } else null - call.respond(object { - val amount_credit = "$FIAT_CURRENCY:$respAmountCredit" - val amount_debit = "${demobank.config.currency}:$respAmountDebit" - }) - return@get - } - - // Create a cash-out operation. - circuitRoute.post("/cashouts") { - val user = call.request.basicAuth() - if (user == "admin" || user == "bank") throw forbidden("$user can't cash-out.") - // No suitable default user, when the authentication is disabled. - if (user == null) throw SandboxError( - HttpStatusCode.ServiceUnavailable, - "This endpoint isn't served when the authentication is disabled." - ) - val req = call.receive<CircuitCashoutRequest>() - - // validate amounts: well-formed and supported currency. - val amountDebit = parseAmount(req.amount_debit) // amount before rates. - val amountCredit = parseAmount(req.amount_credit) // amount after rates, as expected by the client - val demobank = ensureDemobank(call) - // Currency check of the cash-out's circuit part. - if (amountDebit.currency != demobank.config.currency) - throw badRequest("'${req::amount_debit.name}' (${req.amount_debit})" + - " doesn't match the regional currency (${demobank.config.currency})" - ) - // Currency check of the cash-out's fiat part. - if (amountCredit.currency != FIAT_CURRENCY) - throw badRequest("'${req::amount_credit.name}' (${req.amount_credit})" + - " doesn't match the fiat currency ($FIAT_CURRENCY)." - ) - // check if TAN is supported. Default to SMS, if that's missing. - val tanChannel = req.tan_channel?.uppercase() ?: SupportedTanChannels.SMS.name - if (!isTanChannelSupported(tanChannel)) - throw SandboxError( - HttpStatusCode.ServiceUnavailable, - "TAN channel '$tanChannel' not supported." - ) - // check if the user contact data would allow the TAN channel. - val customer: DemobankCustomerEntity? = maybeGetCustomer(username = user) - if (customer == null) throw internalServerError( - "Customer profile '$user' not found after authenticating it." - ) - if (customer.cashout_address == null) throw SandboxError( - HttpStatusCode.PreconditionFailed, - "Cash-out address not found. Did the user register via Circuit API?" - ) - if ((tanChannel == SupportedTanChannels.EMAIL.name) && (customer.email == null)) - throw conflict("E-mail address not found for '$user'. Can't send the TAN") - if ((tanChannel == SupportedTanChannels.SMS.name) && (customer.phone == null)) - throw conflict("Phone number not found for '$user'. Can't send the TAN") - // check rates correctness - val amountDebitAsNumber = BigDecimal(amountDebit.amount) - val expectedAmountCredit = applyCashoutRatioAndFee(amountDebitAsNumber, ratiosAndFees) - val amountCreditAsNumber = BigDecimal(amountCredit.amount).roundToTwoDigits() - if (expectedAmountCredit != amountCreditAsNumber) { - throw badRequest("Rates application are incorrect." + - " The expected amount to credit is: ${expectedAmountCredit}," + - " but ${amountCredit.amount} was specified.") - } - // check that the balance is sufficient - val balance = getBalance( - user, - demobank.name - ) - val balanceCheck = balance - amountDebitAsNumber - if (balanceCheck < BigDecimal.ZERO && balanceCheck.abs() > BigDecimal(demobank.config.usersDebtLimit)) - throw SandboxError( - HttpStatusCode.PreconditionFailed, - "Cash-out not possible due to insufficient funds. Balance ${balance.toPlainString()} would reach ${balanceCheck.toPlainString()}" - ) - // generate a subject if that's missing - val cashoutSubject = req.subject ?: generateCashoutSubject( - amountCredit = amountCredit, - amountDebit = amountDebit - ) - val op = transaction { - CashoutOperationEntity.new { - this.amountDebit = req.amount_debit - this.amountCredit = req.amount_credit - this.buyAtRatio = ratiosAndFees.buy_at_ratio.toString() - this.buyInFee = ratiosAndFees.buy_in_fee.toString() - this.sellAtRatio = ratiosAndFees.sell_at_ratio.toString() - this.sellOutFee = ratiosAndFees.sell_out_fee.toString() - this.subject = cashoutSubject - this.creationTime = getSystemTimeNow().toInstant().toEpochMilli() - this.tanChannel = SupportedTanChannels.valueOf(tanChannel) - this.account = user - this.tan = getRandomString(5) - this.cashoutAddress = customer.cashout_address ?: throw internalServerError( - "Cash-out address for '$user' not found, after previous check succeeded" - ) - } - } - when (tanChannel) { - SupportedTanChannels.EMAIL.name -> { - val isSuccessful = try { - runTanCommand( - command = EMAIL_TAN_CMD ?: throw internalServerError( - "E-mail TAN supported but the command" + - " was not found. See the --email-tan option from 'serve'" - ), - address = customer.email ?: throw internalServerError( - "Customer has no e-mail address, but previous check should" + - " have detected it!" - ), - message = op.tan - ) - } catch (e: Exception) { - logger.error("Sending the e-mail TAN to ${customer.email} was impossible." + - " Reason: ${e.message}") - throw internalServerError("Could not send the e-mail TAN.") - } - if (!isSuccessful) - throw internalServerError("E-mail TAN command failed.") - } - SupportedTanChannels.SMS.name -> { - val isSuccessful = try { - runTanCommand( - command = SMS_TAN_CMD ?: throw internalServerError( - "SMS TAN supported but the command" + - " was not found. See the --sms-tan option from 'serve'" - ), - address = customer.phone ?: throw internalServerError( - "Customer has no phone number, but previous check should" + - " have detected it!" - - ), - message = op.tan - ) - - } catch (e: Exception) { - logger.error("Sending the SMS TAN to ${customer.phone} was impossible." + - " Reason: ${e.message}") - throw internalServerError("Could not send the SMS TAN.") - } - if (!isSuccessful) - throw internalServerError("SMS TAN command failed.") - } - SupportedTanChannels.FILE.name -> { - try { - File(LIBEUFIN_TAN_TMP_FILE).writeText(op.tan) - } catch (e: Exception) { - logger.error("Could not write to $LIBEUFIN_TAN_TMP_FILE. Reason: ${e.message}") - throw internalServerError("File TAN failed.") - } - } - else -> - throw internalServerError("The bank tried an unsupported TAN channel: $tanChannel.") - } - call.respond(HttpStatusCode.Accepted, object {val uuid = op.uuid}) - return@post - } - // Get Circuit-relevant account data. - circuitRoute.get("/accounts/{resourceName}") { - val username = call.request.basicAuth() - val resourceName = call.expectUriComponent("resourceName") - throwIfInstitutionalName(resourceName) - if (!allowOwnerOrAdmin(username, resourceName)) throw forbidden( - "User $username has no rights over $resourceName" - ) - val customer = getCustomer(resourceName) - /** - * CUSTOMER AND BANK ACCOUNT INVARIANT. - * - * After having found a 'customer' associated with the resourceName - * - see previous line -, the bank must ensure that a 'bank account' - * exist under the same resourceName. If that fails, the bank broke the - * invariant and should respond 500. - */ - val bankAccount = getBankAccountFromLabel(resourceName, withBankFault = true) - /** - * Throwing when name or cash-out address aren't found ensures - * that the customer was indeed added via the Circuit API, as opposed - * to the Access API. - */ - call.respond(CircuitAccountInfo( - username = customer.username, - name = customer.name ?: throw internalServerError( - "Account '$resourceName' was found without owner's name." - ), - cashout_address = customer.cashout_address, - contact_data = CircuitContactData( - email = customer.email, - phone = customer.phone - ), - iban = bankAccount.iban - )) - return@get - } - - // Get summary of all the accounts. - circuitRoute.get("/accounts") { - call.request.basicAuth(onlyAdmin = true) - val maybeFilter: String? = call.request.queryParameters["filter"] - /** - * Equip the given filter with left and right catch-all wildcards, - * otherwise use one catch-all wildcard. - */ - val filter = if (maybeFilter != null) { - "%${maybeFilter}%" - } else "%" - val customers = mutableListOf<Any>() - val demobank = ensureDemobank(call) - transaction { - /** - * This block builds the DB query so that IF the %-wildcard was - * given, then BOTH name and name-less accounts are returned. - */ - val query: Op<Boolean> = SqlExpressionBuilder.run { - val like = DemobankCustomersTable.name.like(filter) - /** - * This IF statement is needed because Postgres would NOT - * match a null column even with the %-wildcard. - */ - if (filter == "%") { - return@run like.or(DemobankCustomersTable.name.isNull()) - } - return@run like - } - DemobankCustomerEntity.find { query }.forEach { - customers.add(object { - val username = it.username - val name = it.name - val balance = getBalanceForJson( - getBalance(it.username, demobank.name), - demobank.config.currency - ) - val debitThreshold = getMaxDebitForUser( - it.username, - demobank.name - ) - }) - } - StdOutSqlLogger - } - if (customers.size == 0) { - call.respond(HttpStatusCode.NoContent) - return@get - } - call.respond(object {val customers = customers}) - return@get - } - - // Change password. - circuitRoute.patch("/accounts/{customerUsername}/auth") { - val username = call.request.basicAuth() - val customerUsername = call.expectUriComponent("customerUsername") - throwIfInstitutionalName(customerUsername) - if (!allowOwnerOrAdmin(username, customerUsername)) throw forbidden( - "User $username has no rights over $customerUsername" - ) - // Flow here means admin or username have the rights for this operation. - val req = call.receive<AccountPasswordChange>() - /** - * The resource/customer might still not exist, in case admin has requested. - * On the other hand, when ordinary customers request, their existence is checked - * along the basic authentication check. - */ - transaction { - val customer = getCustomer(customerUsername) // throws 404, if not found. - customer.passwordHash = CryptoUtil.hashpw(req.new_password) - } - call.respond(HttpStatusCode.NoContent) - return@patch - } - // Change account (mostly contact) data. - circuitRoute.patch("/accounts/{resourceName}") { - val username = call.request.basicAuth() - if (username == null) - throw internalServerError("Authentication disabled, don't have a default for this request.") - val resourceName = call.expectUriComponent("resourceName") - throwIfInstitutionalName(resourceName) - if(!allowOwnerOrAdmin(username, resourceName)) throw forbidden( - "User $username has no rights over $resourceName" - ) - // account found and authentication succeeded - val req = call.receive<CircuitAccountReconfiguration>() - // Only admin's allowed to change the legal name - if (req.name != null && username != "admin") throw forbidden( - "Only admin can change the user legal name" - ) - if ((req.contact_data.email != null) && (!checkEmailAddress(req.contact_data.email))) - throw badRequest("Invalid e-mail address: ${req.contact_data.email}") - if ((req.contact_data.phone != null) && (!checkPhoneNumber(req.contact_data.phone))) - throw badRequest("Invalid phone number: ${req.contact_data.phone}") - try { if (req.cashout_address != null) parsePayto(req.cashout_address) } - catch (e: InvalidPaytoError) { - throw badRequest("Invalid cash-out address: ${req.cashout_address}") - } - transaction { - val user = getCustomer(resourceName) - user.email = req.contact_data.email - user.phone = req.contact_data.phone - user.cashout_address = req.cashout_address - } - call.respond(HttpStatusCode.NoContent) - return@patch - } - // Create new account. - circuitRoute.post("/accounts") { - call.request.basicAuth(onlyAdmin = true) - val req = call.receive<CircuitAccountRequest>() - // Validity and availability check on the input data. - if (req.contact_data.email != null) { - if (!checkEmailAddress(req.contact_data.email)) - throw badRequest("Invalid e-mail address: ${req.contact_data.email}. Won't register") - val maybeEmailConflict = transaction { - DemobankCustomerEntity.find { - DemobankCustomersTable.email eq req.contact_data.email - }.firstOrNull() - } - // Warning since two individuals claimed one same e-mail address. - if (maybeEmailConflict != null) - throw conflict("Won't register user ${req.username}: e-mail conflict on ${req.contact_data.email}") - } - if (req.contact_data.phone != null) { - if (!checkPhoneNumber(req.contact_data.phone)) - throw badRequest("Invalid phone number: ${req.contact_data.phone}. Won't register") - - val maybePhoneConflict = transaction { - DemobankCustomerEntity.find { - DemobankCustomersTable.phone eq req.contact_data.phone - }.firstOrNull() - } - // Warning since two individuals claimed one same phone number. - if (maybePhoneConflict != null) - throw conflict("Won't register user ${req.username}: phone conflict on ${req.contact_data.phone}") - } - /** - * Check that cash-out address parses. IBAN is not - * check-summed in this version; the cash-out operation - * just fails for invalid IBANs and the user has then - * the chance to update their IBAN. - */ - try { - parsePayto(req.cashout_address) - } - catch (e: InvalidPaytoError) { - throw badRequest("Won't register account ${req.username}: invalid cash-out address: ${req.cashout_address}") - } - transaction { - val newAccount = insertNewAccount( - username = req.username, - password = req.password, - name = req.name, - iban = req.internal_iban, - demobank = ensureDemobank(call).name - ) - newAccount.customer.phone = req.contact_data.phone - newAccount.customer.email = req.contact_data.email - newAccount.customer.cashout_address = req.cashout_address - } - call.respond(HttpStatusCode.NoContent) - return@post - } - // Get (conversion rates via) config values. - circuitRoute.get("/config") { - call.respond(ConfigResp(ratios_and_fees = ratiosAndFees)) - return@get - } - // Only Admin and only when balance is zero. - circuitRoute.delete("/accounts/{resourceName}") { - call.request.basicAuth(onlyAdmin = true) - val resourceName = call.expectUriComponent("resourceName") - throwIfInstitutionalName(resourceName) - val customer = getCustomer(resourceName) - val bankAccount = getBankAccountFromLabel( - resourceName, - withBankFault = true // See comment "CUSTOMER AND BANK ACCOUNT INVARIANT". - ) - val balance: BigDecimal = getBalance(bankAccount) - if (!isAmountZero(balance)) { - logger.error("Account $resourceName has $balance balance. Won't delete it") - throw SandboxError( - HttpStatusCode.PreconditionFailed, - "Account $resourceName doesn't have zero balance. Won't delete it" - ) - } - transaction { - bankAccount.delete() - customer.delete() - } - call.respond(HttpStatusCode.NoContent) - return@delete - } -} -\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/ConversionService.kt b/bank/src/main/kotlin/tech/libeufin/bank/ConversionService.kt @@ -1,433 +0,0 @@ -package tech.libeufin.bank - -import CamtBankAccountEntry -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.ktor.client.* -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.util.* -import java.math.BigDecimal -import kotlin.system.exitProcess - -/** - * This file contains the logic for downloading/submitting incoming/outgoing - * fiat transactions to Nexus. It needs the following values for operating. - * - * 1. Nexus URL. - * 2. Credentials to authenticate at Nexus JSON API. - * 3. Long-polling interval. - * 4. Frequency of the download loop. - * - * Notes: - * - * 1. The account to credit on incoming transactions is ALWAYS "admin". - * 2. The time to submit a new payment is as soon as "admin" receives one - * incoming regional payment. - * 3. At this time, Nexus does NOT offer long polling when it serves the - * transactions via its JSON API. => Fixed. - * 4. At this time, Nexus does NOT offer any filter when it serves the - * transactions via its JSON API. => Can be fixed by using the TWG. - */ - -// DEFINITIONS AND HELPERS - -/** - * Timeout the HTTP client waits for the server to respond, - * after the request is made. - */ -val waitTimeout = 30000L - -/** - * Time to wait before HTTP requesting again to the server. - * This helps to avoid tight cycles in case the server responds - * quickly or the client doesn't long-poll. - */ -val newIterationTimeout = 2000L - -/** - * Response format of Nexus GET /transactions. - */ -data class TransactionItem( - val index: String, - val camtData: CamtBankAccountEntry -) -data class NexusTransactions( - val transactions: List<TransactionItem> -) - -/** - * This exception signals that the buy-in service could NOT - * GET the list of fiat transactions from Nexus due to a client - * error. Because this is fatal (e.g. wrong credentials, URL not found..), - * the service should be stopped. - */ -class BuyinClientError : Exception() - -/** - * This exception signals that POSTing a cash-out operation - * to Nexus failed due to the client. This is a fatal condition - * therefore the monitor should be stopped. - */ -class CashoutClientError : Exception() -/** - * Executes the 'block' function every 'loopNewReqMs' milliseconds. - * Does not exit/fail the process upon exceptions - just logs them. - */ -fun downloadLoop(block: () -> Unit) { - // Needs "runBlocking {}" to call "delay()" and in case 'block' - // contains suspend functions. - runBlocking { - while(true) { - try { block() } - catch (e: BuyinClientError) { - logger.error("The buy-in monitor had a client error while GETting new" + - " transactions from Neuxs. Stopping it") - // Rethrowing and let the caller manage it - throw e - } - // Tolerating any other error type that's not due to the client. - catch (e: Exception) { - logger.error("Sandbox fiat-incoming monitor excepted: ${e.message}") - } - delay(newIterationTimeout) - } - } -} - -// BUY-IN SIDE. - -/** - * Applies the buy-in ratio and fees to the fiat amount - * that came from Nexus. The result is the regional amount - * that will be wired to the exchange Sandbox account. - */ -fun applyBuyinRatioAndFees( - amount: BigDecimal, - ratiosAndFees: RatioAndFees -): BigDecimal { - val maybeBuyinAmount = ((amount * ratiosAndFees.buy_at_ratio.toBigDecimal()) - - ratiosAndFees.buy_in_fee.toBigDecimal()).roundToTwoDigits() - // Bank's fault, as buying in should never lead to negative. - if (maybeBuyinAmount < BigDecimal.ZERO) { - logger.error("Negative buy-in scenario: input fiat amount was '${amount}'" + - ", buy-in ratio was '${ratiosAndFees.buy_at_ratio}'," + - " buy-in fee was '${ratiosAndFees.buy_in_fee}'") - throw internalServerError("Applying buy-in fees yielded negative regional amount") - } - return maybeBuyinAmount -} - -private fun ensureDisabledRedirects(client: HttpClient) { - client.config { - if (followRedirects) throw Exception( - "HTTP client follows redirects, please disable." - ) - } -} -/** - * This function downloads the incoming fiat transactions from Nexus, - * stores them into the database and triggers the related wire transfer - * to the Taler exchange (to be specified in 'accountToCredit'). Once - * started, this function is not supposed to return, except on _client - * side_ errors. On server side errors it pauses and retries. When - * it returns, the caller is expected to handle the error. - */ -fun buyinMonitor( - demobankName: String, // used to get config values. - client: HttpClient, - accountToCredit: String, - accountToDebit: String = "admin" -) { - ensureDisabledRedirects(client) - val demobank = ensureDemobank(demobankName) - /** - * Getting the config values to send authenticated requests - * to Nexus. Sandbox needs one account at Nexus before being - * able to use these values. - */ - val nexusBaseUrl = getConfigValueOrThrow(demobank.config::nexusBaseUrl) - val usernameAtNexus = getConfigValueOrThrow(demobank.config::usernameAtNexus) - val passwordAtNexus = getConfigValueOrThrow(demobank.config::passwordAtNexus) - /** - * This is the endpoint where Nexus serves all the transactions that - * have ingested from the fiat bank. - */ - val endpoint = "bank-accounts/$usernameAtNexus/transactions" - val uriWithoutStart = joinUrl(nexusBaseUrl, endpoint) + "?long_poll_ms=$waitTimeout" - - // downloadLoop does already try-catch (without failing the process). - downloadLoop { - /** - * This bank account will act as the debtor, once a new fiat - * payment is detected. It's the debtor that pays the related - * regional amount to the exchange, in order to start a withdrawal - * operation (in regional coins). - */ - val debitBankAccount = getBankAccountFromLabel(accountToDebit) - /** - * Setting the 'start' URI param in the following command - * lets Sandbox receive only unseen payments from Nexus. - */ - val uriWithStart = "$uriWithoutStart&start=${debitBankAccount.lastFiatFetch}" - runBlocking { - // Maybe get new fiat transactions. - logger.debug("GETting fiat transactions from: $uriWithStart") - val resp = client.get(uriWithStart) { - expectSuccess = false // Avoids excepting on !2xx - basicAuth(usernameAtNexus, passwordAtNexus) - } - // The server failed, pause and try again - if (resp.status.value.toString().startsWith('5')) { - logger.error("Buy-in monitor requested to a failing Nexus. Retry.") - logger.error("Nexus responded: ${resp.bodyAsText()}") - return@runBlocking - } - // The client failed, fail the process. - if (resp.status.value.toString().startsWith('4')) { - logger.error("Buy-in monitor failed at GETting to Nexus. Stopping the buy-in monitor.") - logger.error("Nexus responded: ${resp.bodyAsText()}") - throw BuyinClientError() - } - // Expect 200 OK. What if 3xx? - if (resp.status.value != HttpStatusCode.OK.value) { - logger.error("Unhandled response status ${resp.status.value}, failing Sandbox") - throw BuyinClientError() - } - // Nexus responded 200 OK, analyzing the result. - /** - * Wire to "admin" if the subject is a public key, or do - * nothing otherwise. - */ - val respObj = jacksonObjectMapper().readValue( - resp.bodyAsText(), - NexusTransactions::class.java - ) // errors are logged by the caller (without failing). - respObj.transactions.forEach { - // Ignoring payments with an invalid reserved public key. - if (extractReservePubFromSubject(it.camtData.getSingletonSubject()) == null) - return@forEach - // Extracts the amount and checks it's at most two fractional digits. - val maybeValidAmount = it.camtData.amount.value - if (!validatePlainAmount(maybeValidAmount)) { - logger.error("Nexus gave one amount with invalid fractional digits: $maybeValidAmount." + - " The transaction has index ${it.index}") - // Advancing the last fetched pointer, to avoid GETting - // this invalid payment again. - transaction { - debitBankAccount.refresh() - debitBankAccount.lastFiatFetch = it.index - } - } - val convertedAmount = applyBuyinRatioAndFees( - maybeValidAmount.toBigDecimal(), - ratiosAndFees - ) - transaction { - wireTransfer( - debitAccount = accountToDebit, - creditAccount = accountToCredit, - demobank = demobankName, - subject = it.camtData.getSingletonSubject(), - amount = "${demobank.config.currency}:$convertedAmount" - ) - // Nexus enqueues the transactions such that the index increases. - // If Sandbox crashes here, it'll ask again using the last successful - // index as the start parameter. Being this an exclusive bound, only - // transactions later than it are expected. - debitBankAccount.refresh() - debitBankAccount.lastFiatFetch = it.index - } - } - } - } -} - -/* DB query helper that fetches the latest cash-out operations that were - confirmed in the regional currency. A cash-out operation is 'confirmed' - when the bank account pointed by the parameter 'bankAccountLabel' gets - one incoming payment. - - The List return type (instead of SizedIterable) lets the caller NOT open - a transaction block to access the values -- although some operations _on - the values_ may be forbidden. -*/ -fun getUnsubmittedTransactions(bankAccountLabel: String): List<BankAccountTransactionEntity> { - return transaction { - val bankAccount = getBankAccountFromLabel(bankAccountLabel) - val lowerExclusiveLimit = bankAccount.lastFiatSubmission?.id?.value ?: 0 - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.id greater lowerExclusiveLimit and ( - BankAccountTransactionsTable.direction eq "CRDT" - ) and (BankAccountTransactionsTable.account eq bankAccount.id) - }.sortedBy { it.id }.map { it } - /* The latest payment must occupy the highest index, - to reliably update the 'lastFiatSubmission' column of - the bank account. */ - } -} - -// CASH-OUT SIDE. - -/** - * This function listens for regio-incoming events (LIBEUFIN_REGIO_TX) - * on the 'watchedBankAccount' and submits the related cash-out payment - * to Nexus. The fiat payment will then take place ENTIRELY on Nexus' - * responsibility. - */ -suspend fun cashoutMonitor( - httpClient: HttpClient, - watchedBankAccount: String = "admin", - demobankName: String = "default", // used to get config values. - dbEventTimeout: Long = 0 // 0 waits forever. -) { - ensureDisabledRedirects(httpClient) - // Register for a REGIO_TX event. - val eventChannel = buildChannelName( - NotificationsChannelDomains.LIBEUFIN_REGIO_TX, - watchedBankAccount - ) - val objectMapper = jacksonObjectMapper() - val demobank = getDemobank(demobankName) - val bankAccount = getBankAccountFromLabel(watchedBankAccount) - val config = demobank?.config ?: throw internalServerError( - "Demobank '$demobankName' has no configuration." - ) - /** - * The monitor needs the cash-out currency to correctly POST - * payment initiations at Nexus. Recall: Nexus bank accounts - * do not mandate any particular currency, as they serve as mere - * bridges to the backing bank. And: a backing bank may have - * multiple currencies, or the backing bank may not explicitly - * specify any currencies to be _the_ currency of the backed - * bank account. - */ - if (config.cashoutCurrency == null) { - logger.error("Config lacks cash-out currency.") - exitProcess(1) - } - val nexusBaseUrl = getConfigValueOrThrow(config::nexusBaseUrl) - val usernameAtNexus = getConfigValueOrThrow(config::usernameAtNexus) - val passwordAtNexus = getConfigValueOrThrow(config::passwordAtNexus) - val paymentInitEndpoint = nexusBaseUrl.run { - var nexusBaseUrlFromConfig = this - if (!nexusBaseUrlFromConfig.endsWith('/')) - nexusBaseUrlFromConfig += '/' - /** - * WARNING: Nexus gives the possibility to have bank account names - * DIFFERENT from their owner's username. Sandbox however MUST have - * its Nexus bank account named THE SAME as its username. - */ - nexusBaseUrlFromConfig + "bank-accounts/$usernameAtNexus/payment-initiations" - } - while (true) { - val listenHandle = PostgresListenHandle(eventChannel) - // pessimistically LISTEN - listenHandle.postgresListen() - // but optimistically check for data, case some - // arrived _before_ the LISTEN. - var newTxs = getUnsubmittedTransactions(watchedBankAccount) - // Data found, UNLISTEN. - if (newTxs.isNotEmpty()) { - logger.debug("Found cash-out's without waiting any DB event.") - listenHandle.postgresUnlisten() - } - // Data not found, wait. - else { - logger.debug("Need to wait a DB event for new cash-out's") - val isNotificationArrived = listenHandle.waitOnIODispatchers(dbEventTimeout) - if (isNotificationArrived && listenHandle.receivedPayload == "CRDT") - newTxs = getUnsubmittedTransactions(watchedBankAccount) - } - if (newTxs.isEmpty()) { - logger.debug("DB event timeout expired") - continue - } - logger.debug("POSTing new cash-out's") - newTxs.forEach { - logger.debug("POSTing cash-out '${it.subject}' to $paymentInitEndpoint") - val body = object { - /** - * This field is UID of the request _as assigned by the - * client_. That helps to reconcile transactions or lets - * Nexus implement idempotency. It will NOT identify the created - * resource at the server side. The ID of the created resource is - * assigned _by Nexus_ and communicated in the (successful) response. - */ - val uid = it.accountServicerReference - val iban = it.creditorIban - val bic = it.creditorBic - val amount = "${config.cashoutCurrency}:${it.amount}" - val subject = it.subject - val name = it.creditorName - } - val resp = try { - httpClient.post(paymentInitEndpoint) { - expectSuccess = false // Avoids excepting on !2xx - basicAuth(usernameAtNexus, passwordAtNexus) - contentType(ContentType.Application.Json) - setBody(objectMapper.writeValueAsString(body)) - } - } - // Hard-error, response did not even arrive. - catch (e: Exception) { - logger.error("Cash-out monitor could not reach Nexus. Pause and retry") - logger.error(e.message) - /** - * Explicit delaying because the monitor normally - * waits on DB events, and this retry likely won't - * wait on a DB event. - */ - delay(2000) - return@forEach - } - // Server fault. Pause and retry. - if (resp.status.value.toString().startsWith('5')) { - logger.error("Cash-out monitor POSTed to a failing Nexus. Pause and retry") - logger.error("Server responded: ${resp.bodyAsText()}") - /** - * Explicit delaying because the monitor normally - * waits on DB events, and this retry likely won't - * wait on a DB event. - */ - delay(2000L) - return@forEach - } - // Client fault, fail Sandbox. - if (resp.status.value.toString().startsWith('4')) { - logger.error("Cash-out monitor failed at POSTing to Nexus.") - logger.error("Nexus responded: ${resp.bodyAsText()}") - throw CashoutClientError() - } - // Expecting 200 OK. What if 3xx? - if (resp.status.value != HttpStatusCode.OK.value) { - logger.error("Cash-out monitor, unhandled response status: ${resp.status.value}.") - throw CashoutClientError() - } - // Successful case, mark the wire transfer as submitted, - // and advance the pointer to the last submitted payment. - val responseBody = resp.bodyAsText() - transaction { - CashoutSubmissionEntity.new { - localTransaction = it.id - submissionTime = resp.responseTime.timestamp - /** - * The following block associates the submitted payment - * to the UID that Nexus assigned to it. It is currently not - * used in Sandbox, but might help for reconciliation. - */ - if (responseBody.isNotEmpty()) - maybeNexusResposnse = responseBody - } - // Advancing the 'last submitted bookmark', to avoid - // handling the same transaction multiple times. - bankAccount.lastFiatSubmission = it - } - } - } -} diff --git a/bank/src/main/kotlin/tech/libeufin/bank/DB.kt b/bank/src/main/kotlin/tech/libeufin/bank/DB.kt @@ -1,747 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2019 Stanisci and Dold. - - * LibEuFin is free software; you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation; either version 3, or - * (at your option) any later version. - - * LibEuFin is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General - * Public License for more details. - - * You should have received a copy of the GNU Affero General Public - * License along with LibEuFin; see the file COPYING. If not, see - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.bank - -import io.ktor.http.* -import org.jetbrains.exposed.dao.Entity -import org.jetbrains.exposed.dao.EntityClass -import org.jetbrains.exposed.dao.IntEntity -import org.jetbrains.exposed.dao.LongEntity -import org.jetbrains.exposed.dao.IntEntityClass -import org.jetbrains.exposed.dao.LongEntityClass -import org.jetbrains.exposed.dao.id.EntityID -import org.jetbrains.exposed.dao.id.IdTable -import org.jetbrains.exposed.dao.id.IntIdTable -import org.jetbrains.exposed.dao.id.LongIdTable -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.util.* -import kotlin.reflect.* -import kotlin.reflect.full.* - -/** - * All the states to give a subscriber. - */ -enum class SubscriberState { - /** - * No keys at all given to the bank. - */ - NEW, - - /** - * Only INI electronic message was successfully sent. - */ - PARTIALLY_INITIALIZED_INI, - - /**r - * Only HIA electronic message was successfully sent. - */ - PARTIALLY_INITIALIZED_HIA, - - /** - * Both INI and HIA were electronically sent with success. - */ - INITIALIZED, - - /** - * All the keys accounted in INI and HIA have been confirmed - * via physical mail. - */ - READY -} - -/** - * All the states that one key can be assigned. - */ -enum class KeyState { - - /** - * The key was never communicated. - */ - MISSING, - - /** - * The key has been electronically sent. - */ - NEW, - - /** - * The key has been confirmed (either via physical mail - * or electronically -- e.g. with certificates) - */ - RELEASED -} - -/** - * Stores one config object to the database. Each field - * name and value populate respectively the configKey and - * configValue columns. Rows are defined in the following way: - * demobankName | configKey | configValue - */ -fun insertConfigPairs(config: DemobankConfig, override: Boolean = false) { - // Fill the config key-value pairs in the DB. - config::class.declaredMemberProperties.forEach { configField -> - val maybeValue = configField.getter.call(config) - if (override) { - val maybeConfigPair = DemobankConfigPairEntity.find { - DemobankConfigPairsTable.configKey eq configField.name - }.firstOrNull() - if (maybeConfigPair == null) - throw internalServerError("Cannot override config value '${configField.name}' not found.") - maybeConfigPair.configValue = maybeValue?.toString() - return@forEach - } - DemobankConfigPairEntity.new { - this.demobankName = config.demobankName - this.configKey = configField.name - this.configValue = maybeValue?.toString() - } - } -} - -object DemobankConfigPairsTable : LongIdTable() { - val demobankName = text("demobankName") - val configKey = text("configKey") - val configValue = text("configValue").nullable() -} - -class DemobankConfigPairEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<DemobankConfigPairEntity>(DemobankConfigPairsTable) - var demobankName by DemobankConfigPairsTable.demobankName - var configKey by DemobankConfigPairsTable.configKey - var configValue by DemobankConfigPairsTable.configValue -} - -object DemobankConfigsTable : LongIdTable() { - val name = text("hostname") -} - -// Helpers for handling config values in memory. -typealias DemobankConfigKey = String -typealias DemobankConfigValue = String? -fun Pair<DemobankConfigKey, DemobankConfigValue>.expectValue(): String { - if (this.second == null) throw internalServerError("Config value for '${this.first}' is null in the database.") - return this.second as String -} - -class DemobankConfigEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<DemobankConfigEntity>(DemobankConfigsTable) - var name by DemobankConfigsTable.name - /** - * This object gets defined by parsing all the configuration - * values found in the DB for one demobank. Those values are - * retrieved from _another_ table. - */ - val config: DemobankConfig by lazy { - // Getting all the values for this demobank. - val configPairs: List<Pair<DemobankConfigKey, DemobankConfigValue>> = transaction { - val maybeConfigPairs = DemobankConfigPairEntity.find { - DemobankConfigPairsTable.demobankName.eq(name) - } - if (maybeConfigPairs.empty()) throw SandboxError( - HttpStatusCode.InternalServerError, - "No config values of $name were found in the database" - ) - // Copying results to a DB-agnostic list, to later operate out of "transaction {}" - maybeConfigPairs.map { Pair(it.configKey, it.configValue) } - } - // Building the args to instantiate a DemobankConfig (non-Exposed) object. - val args = mutableMapOf<KParameter, Any?>() - // For each constructor parameter name, find the same-named database entry. - val configClass = DemobankConfig::class - if (configClass.primaryConstructor == null) { - throw SandboxError( - HttpStatusCode.InternalServerError, - "${configClass.simpleName} primaryConstructor is null." - ) - } - if (configClass.primaryConstructor?.parameters == null) { - throw SandboxError( - HttpStatusCode.InternalServerError, - "${configClass.simpleName} primaryConstructor" + - " arguments is null. Cannot set any config value." - ) - } - // For each field in the config object, find the respective DB row. - configClass.primaryConstructor?.parameters?.forEach { par: KParameter -> - val configPairFromDb: Pair<DemobankConfigKey, DemobankConfigValue>? - = configPairs.firstOrNull { - configPair: Pair<DemobankConfigKey, DemobankConfigValue> -> - configPair.first == par.name - } - if (configPairFromDb == null) { - throw SandboxError( - HttpStatusCode.InternalServerError, - "Config key '${par.name}' not found in the database." - ) - } - when(par.type) { - // non-nullable - typeOf<Boolean>() -> { args[par] = configPairFromDb.expectValue().toBoolean() } - typeOf<Int>() -> { args[par] = configPairFromDb.expectValue().toInt() } - // nullable - typeOf<Boolean?>() -> { args[par] = configPairFromDb.second?.toBoolean() } - typeOf<Int?>() -> { args[par] = configPairFromDb.second?.toInt() } - else -> args[par] = configPairFromDb.second - } - } - // Proceeding now to instantiate the config class, and make it a field of this type. - configClass.primaryConstructor!!.callBy(args) - } -} - -/** - * Users who are allowed to log into the demo bank. - * Created via the /demobanks/{demobankname}/register endpoint. - */ -object DemobankCustomersTable : LongIdTable() { - val username = text("username") - val passwordHash = text("passwordHash") - val name = text("name").nullable() - val email = text("email").nullable() - val phone = text("phone").nullable() - val cashout_address = text("cashout_address").nullable() -} - -class DemobankCustomerEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<DemobankCustomerEntity>(DemobankCustomersTable) - var username by DemobankCustomersTable.username - var passwordHash by DemobankCustomersTable.passwordHash - var name by DemobankCustomersTable.name - var email by DemobankCustomersTable.email - var phone by DemobankCustomersTable.phone - var cashout_address by DemobankCustomersTable.cashout_address -} - -/** - * This table stores RSA public keys of subscribers. - */ -object EbicsSubscriberPublicKeysTable : IntIdTable() { - val rsaPublicKey = blob("rsaPublicKey") - val state = enumeration("state", KeyState::class) -} - -class EbicsSubscriberPublicKeyEntity(id: EntityID<Int>) : IntEntity(id) { - companion object : IntEntityClass<EbicsSubscriberPublicKeyEntity>(EbicsSubscriberPublicKeysTable) - var rsaPublicKey by EbicsSubscriberPublicKeysTable.rsaPublicKey - var state by EbicsSubscriberPublicKeysTable.state -} - -/** - * Ebics 'host'(s) that are served by one Sandbox instance. - */ -object EbicsHostsTable : IntIdTable() { - val hostID = text("hostID") - val ebicsVersion = text("ebicsVersion") - val signaturePrivateKey = blob("signaturePrivateKey") - val encryptionPrivateKey = blob("encryptionPrivateKey") - val authenticationPrivateKey = blob("authenticationPrivateKey") -} - -class EbicsHostEntity(id: EntityID<Int>) : IntEntity(id) { - companion object : IntEntityClass<EbicsHostEntity>(EbicsHostsTable) - var hostId by EbicsHostsTable.hostID - var ebicsVersion by EbicsHostsTable.ebicsVersion - var signaturePrivateKey by EbicsHostsTable.signaturePrivateKey - var encryptionPrivateKey by EbicsHostsTable.encryptionPrivateKey - var authenticationPrivateKey by EbicsHostsTable.authenticationPrivateKey -} - -/** - * Ebics Subscribers table. - */ -object EbicsSubscribersTable : IntIdTable() { - val userId = text("userID") - val partnerId = text("partnerID") - val systemId = text("systemID").nullable() - val hostId = text("hostID") - val signatureKey = reference("signatureKey", EbicsSubscriberPublicKeysTable).nullable() - val encryptionKey = reference("encryptionKey", EbicsSubscriberPublicKeysTable).nullable() - val authenticationKey = reference("authorizationKey", EbicsSubscriberPublicKeysTable).nullable() - val nextOrderID = integer("nextOrderID") - val state = enumeration("state", SubscriberState::class) - val bankAccount = reference( - "bankAccount", - BankAccountsTable, - onDelete = ReferenceOption.CASCADE - ).nullable() -} - -class EbicsSubscriberEntity(id: EntityID<Int>) : IntEntity(id) { - companion object : IntEntityClass<EbicsSubscriberEntity>(EbicsSubscribersTable) - var userId by EbicsSubscribersTable.userId - var partnerId by EbicsSubscribersTable.partnerId - var systemId by EbicsSubscribersTable.systemId - var hostId by EbicsSubscribersTable.hostId - var signatureKey by EbicsSubscriberPublicKeyEntity optionalReferencedOn EbicsSubscribersTable.signatureKey - var encryptionKey by EbicsSubscriberPublicKeyEntity optionalReferencedOn EbicsSubscribersTable.encryptionKey - var authenticationKey by EbicsSubscriberPublicKeyEntity optionalReferencedOn EbicsSubscribersTable.authenticationKey - var nextOrderID by EbicsSubscribersTable.nextOrderID - var state by EbicsSubscribersTable.state - var bankAccount by BankAccountEntity optionalReferencedOn EbicsSubscribersTable.bankAccount -} - -/** - * Details of a download order. - */ -object EbicsDownloadTransactionsTable : IdTable<String>() { - override val id = text("transactionID").entityId() - val orderType = text("orderType") - val host = reference("host", EbicsHostsTable) - val subscriber = reference("subscriber", EbicsSubscribersTable) - val encodedResponse = text("encodedResponse") - val transactionKeyEnc = blob("transactionKeyEnc") - val numSegments = integer("numSegments") - val segmentSize = integer("segmentSize") - val receiptReceived = bool("receiptReceived") -} - -class EbicsDownloadTransactionEntity(id: EntityID<String>) : Entity<String>(id) { - companion object : EntityClass<String, EbicsDownloadTransactionEntity>(EbicsDownloadTransactionsTable) - - var orderType by EbicsDownloadTransactionsTable.orderType - var host by EbicsHostEntity referencedOn EbicsDownloadTransactionsTable.host - var subscriber by EbicsSubscriberEntity referencedOn EbicsDownloadTransactionsTable.subscriber - var encodedResponse by EbicsDownloadTransactionsTable.encodedResponse - var numSegments by EbicsDownloadTransactionsTable.numSegments - var transactionKeyEnc by EbicsDownloadTransactionsTable.transactionKeyEnc - var segmentSize by EbicsDownloadTransactionsTable.segmentSize - var receiptReceived by EbicsDownloadTransactionsTable.receiptReceived -} - -/** - * Details of a upload order. - */ -object EbicsUploadTransactionsTable : IdTable<String>() { - override val id = text("transactionID").entityId() - val orderType = text("orderType") - val orderID = text("orderID") - val host = reference("host", EbicsHostsTable) - val subscriber = reference("subscriber", EbicsSubscribersTable) - val numSegments = integer("numSegments") - val lastSeenSegment = integer("lastSeenSegment") - val transactionKeyEnc = blob("transactionKeyEnc") -} - -class EbicsUploadTransactionEntity(id: EntityID<String>) : Entity<String>(id) { - companion object : EntityClass<String, EbicsUploadTransactionEntity>(EbicsUploadTransactionsTable) - var orderType by EbicsUploadTransactionsTable.orderType - var orderID by EbicsUploadTransactionsTable.orderID - var host by EbicsHostEntity referencedOn EbicsUploadTransactionsTable.host - var subscriber by EbicsSubscriberEntity referencedOn EbicsUploadTransactionsTable.subscriber - var numSegments by EbicsUploadTransactionsTable.numSegments - var lastSeenSegment by EbicsUploadTransactionsTable.lastSeenSegment - var transactionKeyEnc by EbicsUploadTransactionsTable.transactionKeyEnc -} - -/** - * FIXME: document this. - */ -object EbicsOrderSignaturesTable : IntIdTable() { - val orderID = text("orderID") - val orderType = text("orderType") - val partnerID = text("partnerID") - val userID = text("userID") - val signatureAlgorithm = text("signatureAlgorithm") - val signatureValue = blob("signatureValue") -} - -class EbicsOrderSignatureEntity(id: EntityID<Int>) : IntEntity(id) { - companion object : IntEntityClass<EbicsOrderSignatureEntity>(EbicsOrderSignaturesTable) - var orderID by EbicsOrderSignaturesTable.orderID - var orderType by EbicsOrderSignaturesTable.orderType - var partnerID by EbicsOrderSignaturesTable.partnerID - var userID by EbicsOrderSignaturesTable.userID - var signatureAlgorithm by EbicsOrderSignaturesTable.signatureAlgorithm - var signatureValue by EbicsOrderSignaturesTable.signatureValue -} - -/** - * FIXME: document this. - */ -object EbicsUploadTransactionChunksTable : IdTable<String>() { - override val id = text("transactionID").entityId() - val chunkIndex = integer("chunkIndex") - val chunkContent = blob("chunkContent") -} - -// FIXME: Is upload chunking not implemented somewhere?! -class EbicsUploadTransactionChunkEntity(id: EntityID<String>) : Entity<String>(id) { - companion object : EntityClass<String, EbicsUploadTransactionChunkEntity>(EbicsUploadTransactionChunksTable) - var chunkIndex by EbicsUploadTransactionChunksTable.chunkIndex - var chunkContent by EbicsUploadTransactionChunksTable.chunkContent -} - - -/** - * Holds those transactions that aren't yet reported in a Camt.053 document. - * After reporting those, the table gets emptied. Rows are merely references - * to the main ledger. - */ -object BankAccountFreshTransactionsTable : LongIdTable() { - val transactionRef = reference( - "transaction", - BankAccountTransactionsTable, - onDelete = ReferenceOption.CASCADE - ) -} -class BankAccountFreshTransactionEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<BankAccountFreshTransactionEntity>(BankAccountFreshTransactionsTable) - var transactionRef by BankAccountTransactionEntity referencedOn BankAccountFreshTransactionsTable.transactionRef -} - -/** - * Table that keeps all the payments initiated by PAIN.001. - */ -object BankAccountTransactionsTable : LongIdTable() { - val creditorIban = text("creditorIban") - val creditorBic = text("creditorBic").nullable() - val creditorName = text("creditorName") - val debtorIban = text("debtorIban") - val debtorBic = text("debtorBic").nullable() - val debtorName = text("debtorName") - val subject = text("subject") - // Amount is a BigDecimal in String form. - val amount = text("amount") - val currency = text("currency") - // Milliseconds since the Epoch. - val date = long("date") - - /** - * UID assigned to the payment by Sandbox. Despite the camt-looking - * name, this UID is always given, even when no EBICS or camt are being - * served. - */ - val accountServicerReference = text("accountServicerReference") - /** - * The following two values are pain.001 specific. Sandbox stores - * them when it serves EBICS connections. - */ - val pmtInfId = text("pmtInfId").nullable() - val endToEndId = text("EndToEndId").nullable() - val direction = text("direction") - /** - * Bank account of the party whose 'direction' refers. This version allows - * only both parties to be registered at the running Sandbox. - */ - val account = reference( - "account", BankAccountsTable, - onDelete = ReferenceOption.CASCADE - ) - // Redundantly storing the demobank for query convenience. - val demobank = reference("demobank", DemobankConfigsTable) -} - -class BankAccountTransactionEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<BankAccountTransactionEntity>(BankAccountTransactionsTable) { - override fun new(init: BankAccountTransactionEntity.() -> Unit): BankAccountTransactionEntity { - /** - * Fresh transactions are those that wait to be included in a - * "history" report, likely a Camt.5x message. The "fresh transactions" - * table keeps a list of such transactions. - */ - val freshTx = super.new(init) - BankAccountFreshTransactionsTable.insert { - it[transactionRef] = freshTx.id - } - /** - * The bank account involved in this transaction points to - * it as the "last known" transaction, to make it easier to - * build histories that depend on such record. - */ - freshTx.account.lastTransaction = freshTx - return freshTx - } - } - var creditorIban by BankAccountTransactionsTable.creditorIban - var creditorBic by BankAccountTransactionsTable.creditorBic - var creditorName by BankAccountTransactionsTable.creditorName - var debtorIban by BankAccountTransactionsTable.debtorIban - var debtorBic by BankAccountTransactionsTable.debtorBic - var debtorName by BankAccountTransactionsTable.debtorName - var subject by BankAccountTransactionsTable.subject - var amount by BankAccountTransactionsTable.amount - var currency by BankAccountTransactionsTable.currency - var date by BankAccountTransactionsTable.date - var accountServicerReference by BankAccountTransactionsTable.accountServicerReference - var pmtInfId by BankAccountTransactionsTable.pmtInfId - var endToEndId by BankAccountTransactionsTable.endToEndId - var direction by BankAccountTransactionsTable.direction - var account by BankAccountEntity referencedOn BankAccountTransactionsTable.account - var demobank by DemobankConfigEntity referencedOn BankAccountTransactionsTable.demobank -} - -/** - * Table that keeps information about which bank accounts (iban+bic+name) - * are active in the system. In the current version, 'label' and 'owner' - * are always equal; future versions may change this, when one customer can - * own multiple bank accounts. - */ -object BankAccountsTable : IntIdTable() { - val balance = text("balance").default("0") - val iban = text("iban") - val bic = text("bic").default("SANDBOXX") - val label = text("label").uniqueIndex("accountLabelIndex") - /** - * This field is the username of the customer that owns the - * bank account. Admin is the only exception: that can specify - * this field as "admin" although no customer backs it. - */ - val owner = text("owner") - val isPublic = bool("isPublic").default(false) - val demoBank = reference("demoBank", DemobankConfigsTable) - - /** - * Point to the last transaction related to this account, regardless - * of it being credit or debit. This reference helps to construct - * history results that start from / depend on the last transaction. - */ - val lastTransaction = reference("lastTransaction", BankAccountTransactionsTable).nullable() - - /** - * Points to the transaction that was last submitted by the conversion - * service to Nexus, in order to initiate a fiat payment related to a - * cash-out operation. - */ - val lastFiatSubmission = reference("lastFiatSubmission", BankAccountTransactionsTable).nullable() - - /** - * Tracks the last fiat payment that was read from Nexus. This tracker - * gets updated ONLY IF the exchange gets successfully paid with the related - * amount in the regional currency. - */ - val lastFiatFetch = text("lastFiatFetch").default("0") -} - -class BankAccountEntity(id: EntityID<Int>) : IntEntity(id) { - companion object : IntEntityClass<BankAccountEntity>(BankAccountsTable) - - var balance by BankAccountsTable.balance - var iban by BankAccountsTable.iban - var bic by BankAccountsTable.bic - var label by BankAccountsTable.label - var owner by BankAccountsTable.owner - var isPublic by BankAccountsTable.isPublic - var demoBank by DemobankConfigEntity referencedOn BankAccountsTable.demoBank - var lastTransaction by BankAccountTransactionEntity optionalReferencedOn BankAccountsTable.lastTransaction - var lastFiatSubmission by BankAccountTransactionEntity optionalReferencedOn BankAccountsTable.lastFiatSubmission - var lastFiatFetch by BankAccountsTable.lastFiatFetch -} - -object BankAccountStatementsTable : IntIdTable() { - val statementId = text("statementId") - val creationTime = long("creationTime") - val xmlMessage = text("xmlMessage") - val bankAccount = reference("bankAccount", BankAccountsTable) - // Signed BigDecimal representing a Camt.053 CLBD field. - val balanceClbd = text("balanceClbd").nullable() -} - -class BankAccountStatementEntity(id: EntityID<Int>) : IntEntity(id) { - companion object : IntEntityClass<BankAccountStatementEntity>(BankAccountStatementsTable) - var statementId by BankAccountStatementsTable.statementId - var creationTime by BankAccountStatementsTable.creationTime - var xmlMessage by BankAccountStatementsTable.xmlMessage - var bankAccount by BankAccountEntity referencedOn BankAccountStatementsTable.bankAccount - var balanceClbd by BankAccountStatementsTable.balanceClbd -} - -enum class CashoutOperationStatus { CONFIRMED, PENDING } -object CashoutOperationsTable : LongIdTable() { - val uuid = uuid("uuid").autoGenerate() - /** - * This amount is the one the user entered in the cash-out - * dialog. That will show up as the outgoing transfer in their - * local currency bank account. - */ - val amountDebit = text("amountDebit") - val amountCredit = text("amountCredit") - val buyAtRatio = text("buyAtRatio") - val buyInFee = text("buyInFee") - val sellAtRatio = text("sellAtRatio") - val sellOutFee = text("sellOutFee") - val subject = text("subject") - val creationTime = long("creationTime") // in milliseconds. - val confirmationTime = long("confirmationTime").nullable() // in milliseconds. - val tanChannel = enumeration("tanChannel", SupportedTanChannels::class) - val account = text("account") - val cashoutAddress = text("cashoutAddress") - val tan = text("tan") - val status = enumeration("status", CashoutOperationStatus::class).default(CashoutOperationStatus.PENDING) -} - -class CashoutOperationEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<CashoutOperationEntity>(CashoutOperationsTable) - var uuid by CashoutOperationsTable.uuid - var amountDebit by CashoutOperationsTable.amountDebit - var amountCredit by CashoutOperationsTable.amountCredit - var buyAtRatio by CashoutOperationsTable.buyAtRatio - var buyInFee by CashoutOperationsTable.buyInFee - var sellAtRatio by CashoutOperationsTable.sellAtRatio - var sellOutFee by CashoutOperationsTable.sellOutFee - var subject by CashoutOperationsTable.subject - var creationTime by CashoutOperationsTable.creationTime - var confirmationTime by CashoutOperationsTable.confirmationTime - var tanChannel by CashoutOperationsTable.tanChannel - var account by CashoutOperationsTable.account - var cashoutAddress by CashoutOperationsTable.cashoutAddress - var tan by CashoutOperationsTable.tan - var status by CashoutOperationsTable.status -} -object TalerWithdrawalsTable : LongIdTable() { - val wopid = uuid("wopid").autoGenerate() - val amount = text("amount") // $currency:x.y - /** - * Turns to true after the wallet gave the reserve public key - * and the exchange details to the bank. - */ - val selectionDone = bool("selectionDone").default(false) - val aborted = bool("aborted").default(false) - /** - * Turns to true after the wire transfer to the exchange bank account - * gets completed _on the bank's side_. This does never guarantees that - * the payment arrived at the exchange's bank yet. - */ - val confirmationDone = bool("confirmationDone").default(false) - val reservePub = text("reservePub").nullable() - val selectedExchangePayto = text("selectedExchangePayto").nullable() - val walletBankAccount = reference("walletBankAccount", BankAccountsTable) -} -class TalerWithdrawalEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<TalerWithdrawalEntity>(TalerWithdrawalsTable) - var wopid by TalerWithdrawalsTable.wopid - var selectionDone by TalerWithdrawalsTable.selectionDone - var confirmationDone by TalerWithdrawalsTable.confirmationDone - var reservePub by TalerWithdrawalsTable.reservePub - var selectedExchangePayto by TalerWithdrawalsTable.selectedExchangePayto - var amount by TalerWithdrawalsTable.amount - var walletBankAccount by BankAccountEntity referencedOn TalerWithdrawalsTable.walletBankAccount - var aborted by TalerWithdrawalsTable.aborted -} - -object BankAccountReportsTable : IntIdTable() { - val reportId = text("reportId") - val creationTime = long("creationTime") - val xmlMessage = text("xmlMessage") - val bankAccount = reference("bankAccount", BankAccountsTable) -} - -/** - * This table tracks the cash-out requests that Sandbox sends to Nexus. - * Only successful requests make it to this table. Failed request would - * either _stop_ the conversion service (for client-side errors) or get retried - * at a later time (for server-side errors.) - */ -object CashoutSubmissionsTable: LongIdTable() { - val localTransaction = reference("localTransaction", BankAccountTransactionsTable).uniqueIndex() - val maybeNexusResponse = text("maybeNexusResponse").nullable() - val submissionTime = long("submissionTime").nullable() // failed don't have it. -} - -class CashoutSubmissionEntity(id: EntityID<Long>) : LongEntity(id) { - companion object : LongEntityClass<CashoutSubmissionEntity>(CashoutSubmissionsTable) - var localTransaction by CashoutSubmissionsTable.localTransaction - var maybeNexusResposnse by CashoutSubmissionsTable.maybeNexusResponse - var submissionTime by CashoutSubmissionsTable.submissionTime -} - -fun dbDropTables(connStringFromEnv: String) { - connectWithSchema(getJdbcConnectionFromPg(connStringFromEnv)) - if (isPostgres()) { - val ret = execCommand( - listOf( - "libeufin-load-sql", - "-d", - connStringFromEnv, - "-s", - "sandbox", - "-r" // the drop option - ), - /** - * Tolerating a failure here helps to manage the case - * where an empty database is attempted to be dropped. - */ - throwIfFails = false - ) - if (ret != 0) - logger.warn("Dropping the sandbox tables failed. Was the DB filled before?") - return - } - transaction { - SchemaUtils.drop( - CashoutSubmissionsTable, - EbicsSubscribersTable, - EbicsSubscriberPublicKeysTable, - EbicsHostsTable, - EbicsDownloadTransactionsTable, - EbicsUploadTransactionsTable, - EbicsUploadTransactionChunksTable, - EbicsOrderSignaturesTable, - BankAccountTransactionsTable, - BankAccountFreshTransactionsTable, - BankAccountsTable, - BankAccountReportsTable, - BankAccountStatementsTable, - DemobankConfigsTable, - DemobankConfigPairsTable, - TalerWithdrawalsTable, - DemobankCustomersTable, - CashoutOperationsTable - ) - } - -} - -fun dbCreateTables(connStringFromEnv: String) { - connectWithSchema(getJdbcConnectionFromPg(connStringFromEnv)) - if (isPostgres()) { - execCommand(listOf( - "libeufin-load-sql", - "-d", - connStringFromEnv, - "-s", - "sandbox" - )) - return - } - // Still using the legacy way for other DBMSs, like SQLite. - transaction { - SchemaUtils.create( - CashoutSubmissionsTable, - DemobankConfigsTable, - DemobankConfigPairsTable, - EbicsSubscribersTable, - EbicsSubscriberPublicKeysTable, - EbicsHostsTable, - EbicsDownloadTransactionsTable, - EbicsUploadTransactionsTable, - EbicsUploadTransactionChunksTable, - EbicsOrderSignaturesTable, - BankAccountTransactionsTable, - BankAccountFreshTransactionsTable, - BankAccountsTable, - BankAccountReportsTable, - BankAccountStatementsTable, - TalerWithdrawalsTable, - DemobankCustomersTable, - CashoutOperationsTable - ) - } -} diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -14,26 +14,29 @@ data class Customer( val login: String, val passwordHash: String, val name: String, - val email: String, - val phone: String, - val cashoutPayto: String, - val cashoutCurrency: String + val dbRowId: Long? = null, // mostly used when retrieving records. + val email: String?, + val phone: String?, + val cashoutPayto: String?, + val cashoutCurrency: String? ) +fun Customer.expectRowId(): Long = this.dbRowId ?: throw internalServerError("Cutsomer '${this.login}' had no DB row ID") data class TalerAmount( val value: Long, val frac: Int ) +// BIC got removed, because it'll be expressed in the internal_payto_uri. data class BankAccount( - val iban: String, - val bic: String, - val bankAccountLabel: String, + val internalPaytoUri: String, val owningCustomerId: Long, val isPublic: Boolean = false, - val lastNexusFetchRowId: Long, + val isTalerExchange: Boolean = false, + val lastNexusFetchRowId: Long = 0L, val balance: TalerAmount? = null, - val hasDebt: Boolean + val hasDebt: Boolean, + val maxDebt: TalerAmount ) enum class TransactionDirection { @@ -44,6 +47,25 @@ enum class TanChannel { sms, email, file } +enum class TokenScope { + readonly, readwrite +} + +data class BearerToken( + val content: ByteArray, + val scope: TokenScope, + val creationTime: Long, + val expirationTime: Long, + /** + * Serial ID of the database row that hosts the bank customer + * that is associated with this token. NOTE: if the token is + * refreshed by a client that doesn't have a user+password login + * in the system, the creator remains always the original bank + * customer that created the very first token. + */ + val bankCustomer: Long +) + data class BankInternalTransaction( val creditorAccountId: Long, val debtorAccountId: Long, @@ -56,11 +78,9 @@ data class BankInternalTransaction( ) data class BankAccountTransaction( - val creditorIban: String, - val creditorBic: String, + val creditorPaytoUri: String, val creditorName: String, - val debtorIban: String, - val debtorBic: String, + val debtorPaytoUri: String, val debtorName: String, val subject: String, val amount: TalerAmount, @@ -98,7 +118,7 @@ data class Cashout( val tanChannel: TanChannel, val tanCode: String, val bankAccount: Long, - val cashoutAddress: String, + val credit_payto_uri: String, val cashoutCurrency: String ) @@ -170,7 +190,15 @@ class Database(private val dbConfig: String) { } // CUSTOMERS - fun customerCreate(customer: Customer): Boolean { + /** + * This method INSERTs a new customer into the database and + * returns its row ID. That is useful because often a new user + * ID has to be specified in more database records, notably in + * bank accounts to point at their owners. + * + * In case of conflict, this method returns null. + */ + fun customerCreate(customer: Customer): Long? { reconnect() val stmt = prepare(""" INSERT INTO customers ( @@ -182,7 +210,8 @@ class Database(private val dbConfig: String) { ,cashout_payto ,cashout_currency ) - VALUES (?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?) + RETURNING customer_id """ ) stmt.setString(1, customer.login) @@ -193,12 +222,84 @@ class Database(private val dbConfig: String) { stmt.setString(6, customer.cashoutPayto) stmt.setString(7, customer.cashoutCurrency) - return myExecute(stmt) + val res = try { + stmt.executeQuery() + } catch (e: SQLException) { + logger.error(e.message) + if (e.errorCode == 0) return null // unique constraint violation. + throw e // rethrow on other errors. + } + res.use { + if (!it.next()) + throw internalServerError("SQL RETURNING gave nothing.") + return it.getLong("customer_id") + } + } + + fun customerPwAuth(login: String, pwHash: String): Customer? { + reconnect() + val stmt = prepare(""" + SELECT + name, + email, + phone, + cashout_payto, + cashout_currency + FROM customers + WHERE login=? AND password_hash=? + """) + stmt.setString(1, login) + stmt.setString(2, pwHash) + val rs = stmt.executeQuery() + rs.use { + if (!rs.next()) return null + return Customer( + login = login, + passwordHash = pwHash, + name = it.getString("name"), + phone = it.getString("phone"), + email = it.getString("email"), + cashoutCurrency = it.getString("cashout_currency"), + cashoutPayto = it.getString("cashout_payto") + ) + } + } + + // Mostly used to get customers out of bearer tokens. + fun customerGetFromRowId(customer_id: Long): Customer? { + reconnect() + val stmt = prepare(""" + SELECT + login, + password_hash, + name, + email, + phone, + cashout_payto, + cashout_currency + FROM customers + WHERE customer_id=? + """) + stmt.setLong(1, customer_id) + val rs = stmt.executeQuery() + rs.use { + if (!rs.next()) return null + return Customer( + login = it.getString("login"), + passwordHash = it.getString("password_hash"), + name = it.getString("name"), + phone = it.getString("phone"), + email = it.getString("email"), + cashoutCurrency = it.getString("cashout_currency"), + cashoutPayto = it.getString("cashout_payto") + ) + } } fun customerGetFromLogin(login: String): Customer? { reconnect() val stmt = prepare(""" SELECT + customer_id, password_hash, name, email, @@ -219,84 +320,140 @@ class Database(private val dbConfig: String) { phone = it.getString("phone"), email = it.getString("email"), cashoutCurrency = it.getString("cashout_currency"), - cashoutPayto = it.getString("cashout_payto") + cashoutPayto = it.getString("cashout_payto"), + dbRowId = it.getLong("customer_id") ) } } // Possibly more "customerGetFrom*()" to come. + // BEARER TOKEN + fun bearerTokenCreate(token: BearerToken): Boolean { + reconnect() + val stmt = prepare(""" + INSERT INTO bearer_tokens + (content, + creation_time, + expiration_time, + scope, + bank_customer + ) VALUES + (?, ?, ?, ?::token_scope_enum, ?) + """) + stmt.setBytes(1, token.content) + stmt.setLong(2, token.creationTime) + stmt.setLong(3, token.expirationTime) + stmt.setString(4, token.scope.name) + stmt.setLong(5, token.bankCustomer) + + return myExecute(stmt) + } + fun bearerTokenGet(token: ByteArray): BearerToken? { + reconnect() + val stmt = prepare(""" + SELECT + expiration_time, + creation_time, + bank_customer, + scope + FROM bearer_tokens + WHERE content=?; + """) + + stmt.setBytes(1, token) + stmt.executeQuery().use { + if (!it.next()) return null + return BearerToken( + content = token, + creationTime = it.getLong("creation_time"), + expirationTime = it.getLong("expiration_time"), + bankCustomer = it.getLong("bank_customer"), + scope = it.getString("scope").run { + if (this == TokenScope.readwrite.name) return@run TokenScope.readwrite + if (this == TokenScope.readonly.name) return@run TokenScope.readonly + else throw internalServerError("Wrong token scope found in the database: $this") + } + ) + } + } + // BANK ACCOUNTS // Returns false on conflicts. fun bankAccountCreate(bankAccount: BankAccount): Boolean { reconnect() + // FIXME: likely to be changed to only do internal_payto_uri val stmt = prepare(""" INSERT INTO bank_accounts - (iban - ,bic - ,bank_account_label + (internal_payto_uri ,owning_customer_id ,is_public - ,last_nexus_fetch_row_id + ,is_taler_exchange + ,max_debt ) - VALUES (?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, (?, ?)::taler_amount) """) - stmt.setString(1, bankAccount.iban) - stmt.setString(2, bankAccount.bic) - stmt.setString(3, bankAccount.bankAccountLabel) - stmt.setLong(4, bankAccount.owningCustomerId) - stmt.setBoolean(5, bankAccount.isPublic) - stmt.setLong(6, bankAccount.lastNexusFetchRowId) + stmt.setString(1, bankAccount.internalPaytoUri) + stmt.setLong(2, bankAccount.owningCustomerId) + stmt.setBoolean(3, bankAccount.isPublic) + stmt.setBoolean(4, bankAccount.isTalerExchange) + stmt.setLong(5, bankAccount.maxDebt.value) + stmt.setInt(6, bankAccount.maxDebt.frac) // using the default zero value for the balance. return myExecute(stmt) } fun bankAccountSetMaxDebt( - bankAccountLabel: String, + owningCustomerId: Long, maxDebt: TalerAmount ): Boolean { reconnect() val stmt = prepare(""" UPDATE bank_accounts SET max_debt=(?,?)::taler_amount - WHERE bank_account_label=? + WHERE owning_customer_id=? """) stmt.setLong(1, maxDebt.value) stmt.setInt(2, maxDebt.frac) - stmt.setString(3, bankAccountLabel) + stmt.setLong(3, owningCustomerId) return myExecute(stmt) } - fun bankAccountGetFromLabel(bankAccountLabel: String): BankAccount? { + fun bankAccountGetFromOwnerId(ownerId: Long): BankAccount? { reconnect() val stmt = prepare(""" SELECT - iban - ,bic + internal_payto_uri ,owning_customer_id ,is_public + ,is_taler_exchange ,last_nexus_fetch_row_id - ,(balance).val AS balance_value + ,(balance).val AS balance_val ,(balance).frac AS balance_frac ,has_debt + ,(max_debt).val AS max_debt_val + ,(max_debt).frac AS max_debt_frac FROM bank_accounts - WHERE bank_account_label=? + WHERE owning_customer_id=? """) - stmt.setString(1, bankAccountLabel) + stmt.setLong(1, ownerId) val rs = stmt.executeQuery() rs.use { if (!it.next()) return null return BankAccount( - iban = it.getString("iban"), - bic = it.getString("bic"), + internalPaytoUri = it.getString("internal_payto_uri"), balance = TalerAmount( - it.getLong("balance_value"), + it.getLong("balance_val"), it.getInt("balance_frac") ), - bankAccountLabel = bankAccountLabel, lastNexusFetchRowId = it.getLong("last_nexus_fetch_row_id"), owningCustomerId = it.getLong("owning_customer_id"), - hasDebt = it.getBoolean("has_debt") + hasDebt = it.getBoolean("has_debt"), + isTalerExchange = it.getBoolean("is_taler_exchange"), + maxDebt = TalerAmount( + value = it.getLong("max_debt_val"), + frac = it.getInt("max_debt_frac") + ) ) } } @@ -355,11 +512,9 @@ class Database(private val dbConfig: String) { reconnect() val stmt = prepare(""" SELECT - creditor_iban - ,creditor_bic + creditor_payto_uri ,creditor_name - ,debtor_iban - ,debtor_bic + ,debtor_payto_uri ,debtor_name ,subject ,(amount).val AS amount_val @@ -386,11 +541,9 @@ class Database(private val dbConfig: String) { do { ret.add( BankAccountTransaction( - creditorIban = it.getString("creditor_iban"), - creditorBic = it.getString("creditor_bic"), + creditorPaytoUri = it.getString("creditor_payto_uri"), creditorName = it.getString("creditor_name"), - debtorIban = it.getString("debtor_iban"), - debtorBic = it.getString("debtor_bic"), + debtorPaytoUri = it.getString("debtor_payto_uri"), debtorName = it.getString("debtor_name"), amount = TalerAmount( it.getLong("amount_val"), @@ -409,7 +562,8 @@ class Database(private val dbConfig: String) { paymentInformationId = it.getString("payment_information_id"), subject = it.getString("subject"), transactionDate = it.getLong("transaction_date") - )) + ) + ) } while (it.next()) return ret } @@ -516,7 +670,7 @@ class Database(private val dbConfig: String) { ,tan_channel ,tan_code ,bank_account - ,cashout_address + ,credit_payto_uri ,cashout_currency ) VALUES ( @@ -552,7 +706,7 @@ class Database(private val dbConfig: String) { stmt.setString(14, op.tanChannel.name) stmt.setString(15, op.tanCode) stmt.setLong(16, op.bankAccount) - stmt.setString(17, op.cashoutAddress) + stmt.setString(17, op.credit_payto_uri) stmt.setString(18, op.cashoutCurrency) return myExecute(stmt) } @@ -610,7 +764,7 @@ class Database(private val dbConfig: String) { ,tan_channel ,tan_code ,bank_account - ,cashout_address + ,credit_payto_uri ,cashout_currency ,tan_confirmation_time ,local_transaction @@ -635,7 +789,7 @@ class Database(private val dbConfig: String) { value = it.getLong("buy_in_fee_val"), frac = it.getInt("buy_in_fee_frac") ), - cashoutAddress = it.getString("cashout_address"), + credit_payto_uri = it.getString("credit_payto_uri"), cashoutCurrency = it.getString("cashout_currency"), cashoutUuid = opUuid, creationTime = it.getLong("creation_time"), diff --git a/bank/src/main/kotlin/tech/libeufin/bank/EbicsProtocolBackend.kt b/bank/src/main/kotlin/tech/libeufin/bank/EbicsProtocolBackend.kt @@ -1,1436 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2019 Stanisci and Dold. - - * LibEuFin is free software; you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation; either version 3, or - * (at your option) any later version. - - * LibEuFin is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General - * Public License for more details. - - * You should have received a copy of the GNU Affero General Public - * License along with LibEuFin; see the file COPYING. If not, see - * <http://www.gnu.org/licenses/> - */ - - -package tech.libeufin.bank - -import io.ktor.server.application.* -import io.ktor.http.ContentType -import io.ktor.http.HttpStatusCode -import io.ktor.server.request.* -import io.ktor.server.response.respond -import io.ktor.server.response.respondText -import io.ktor.util.AttributeKey -import io.ktor.util.date.* -import org.apache.xml.security.binding.xmldsig.RSAKeyValueType -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.statements.api.ExposedBlob -import org.jetbrains.exposed.sql.transactions.transaction -import org.w3c.dom.Document -import tech.libeufin.util.* -import tech.libeufin.util.XMLUtil.Companion.signEbicsResponse -import tech.libeufin.util.ebics_h004.* -import tech.libeufin.util.ebics_hev.HEVResponse -import tech.libeufin.util.ebics_hev.SystemReturnCodeType -import tech.libeufin.util.ebics_s001.SignatureTypes -import tech.libeufin.util.ebics_s001.UserSignatureData -import java.math.BigDecimal -import java.security.interfaces.RSAPrivateCrtKey -import java.security.interfaces.RSAPublicKey -import java.sql.Connection -import java.util.* -import java.util.zip.DeflaterInputStream -import java.util.zip.InflaterInputStream - -val EbicsHostIdAttribute = AttributeKey<String>("RequestedEbicsHostID") - -data class PainParseResult( - val creditorIban: String, - val creditorName: String, - val creditorBic: String?, - val debtorIban: String, - val debtorName: String, - val debtorBic: String?, - val subject: String, - val amount: String, - val currency: String, - val pmtInfId: String, - val endToEndId: String, - val msgId: String -) - -open class EbicsRequestError( - val errorText: String, - val errorCode: String -) : Exception("$errorText (EBICS error code: $errorCode)") - -class EbicsNoDownloadDataAvailable(reason: String? = null) : EbicsRequestError( - "[EBICS_NO_DOWNLOAD_DATA_AVAILABLE]" + if (reason != null) " $reason" else "", - "090005" -) - -class EbicsInvalidRequestError : EbicsRequestError( - "[EBICS_INVALID_REQUEST] Invalid request", - "060102" -) -class EbicsAccountAuthorisationFailed(reason: String) : EbicsRequestError( - "[EBICS_ACCOUNT_AUTHORISATION_FAILED] $reason", - "091302" -) - -/** - * This error is thrown whenever the Subscriber's state is not suitable - * for the requested action. For example, the subscriber sends a EbicsRequest - * message without having first uploaded their keys (#5973). - */ -class EbicsSubscriberStateError : EbicsRequestError( - "[EBICS_INVALID_USER_OR_USER_STATE] Subscriber unknown or subscriber state inadmissible", - "091002" -) -// hint should mention at least the userID -class EbicsUserUnknown(hint: String) : EbicsRequestError( - "[EBICS_USER_UNKNOWN] $hint", - "091003" -) - -class EbicsOrderParamsIgnored(hint: String) : EbicsRequestError( - "[EBICS_ORDER_PARAMS_IGNORED] $hint", - "031001" -) - - -open class EbicsKeyManagementError(private val errorText: String, private val errorCode: String) : - Exception("EBICS key management error: $errorText ($errorCode)") - -private class EbicsInvalidXmlError : EbicsKeyManagementError( - "[EBICS_INVALID_XML]", - "091010" -) - -private class EbicsUnsupportedOrderType : EbicsRequestError( - "[EBICS_UNSUPPORTED_ORDER_TYPE] Order type not supported", - "091005" -) - -/** - * Used here also for "Internal server error". For example, when the - * sandbox itself generates a invalid XML response. - */ -class EbicsProcessingError(detail: String?) : EbicsRequestError( - // a missing detail is already the bank's fault. - "[EBICS_PROCESSING_ERROR] " + (detail ?: "bank internal error"), - "091116" -) - -class EbicsAmountCheckError(detail: String): EbicsRequestError( - "[EBICS_AMOUNT_CHECK_FAILED] $detail", - "091303" -) - -suspend fun respondEbicsTransfer( - call: ApplicationCall, - errorText: String, - errorCode: String -) { - /** - * Because this handler runs for any error, it could - * handle the case where the Ebics host ID is unknown due - * to an invalid request. Recall: Sandbox is multi-host, and - * which Ebics host was requested belongs to the request document. - * - * Therefore, because any Ebics response - * should speak for one Ebics host, we can't respond any Ebics - * type when the Ebics host ID remains unknown due to invalid - * request. Instead, we'll respond plain text: - */ - if (!call.attributes.contains(EbicsHostIdAttribute)) { - call.respondText("Invalid document.", status = HttpStatusCode.BadRequest) - return - } - val resp = EbicsResponse.createForUploadWithError( - errorText, - errorCode, - // For now, phase gets hard-coded as TRANSFER, - // because errors during initialization should have - // already been caught by the chunking logic. - EbicsTypes.TransactionPhaseType.TRANSFER - ) - val hostAuthPriv = transaction { - val host = EbicsHostEntity.find { - EbicsHostsTable.hostID.upperCase() eq call.attributes[EbicsHostIdAttribute] - .uppercase() - }.firstOrNull() ?: throw SandboxError( - HttpStatusCode.InternalServerError, - "Requested Ebics host ID (${call.attributes[EbicsHostIdAttribute]}) not found." - ) - CryptoUtil.loadRsaPrivateKey(host.authenticationPrivateKey.bytes) - } - call.respondText( - signEbicsResponse(resp, hostAuthPriv), - ContentType.Application.Xml, - HttpStatusCode.OK - ) -} - -private suspend fun ApplicationCall.respondEbicsKeyManagement( - errorText: String, - errorCode: String, - bankReturnCode: String, - dataTransfer: CryptoUtil.EncryptionResult? = null, - orderId: String? = null -) { - val responseXml = EbicsKeyManagementResponse().apply { - version = "H004" - header = EbicsKeyManagementResponse.Header().apply { - authenticate = true - mutable = EbicsKeyManagementResponse.MutableHeaderType().apply { - reportText = errorText - returnCode = errorCode - if (orderId != null) { - this.orderID = orderId - } - } - _static = EbicsKeyManagementResponse.EmptyStaticHeader() - } - body = EbicsKeyManagementResponse.Body().apply { - this.returnCode = EbicsKeyManagementResponse.ReturnCode().apply { - this.authenticate = true - this.value = bankReturnCode - } - if (dataTransfer != null) { - this.dataTransfer = EbicsKeyManagementResponse.DataTransfer().apply { - this.dataEncryptionInfo = EbicsTypes.DataEncryptionInfo().apply { - this.authenticate = true - this.transactionKey = dataTransfer.encryptedTransactionKey - this.encryptionPubKeyDigest = EbicsTypes.PubKeyDigest().apply { - this.algorithm = "http://www.w3.org/2001/04/xmlenc#sha256" - this.version = "E002" - this.value = dataTransfer.pubKeyDigest - } - } - this.orderData = EbicsKeyManagementResponse.OrderData().apply { - this.value = Base64.getEncoder().encodeToString(dataTransfer.encryptedData) - } - } - } - } - } - val text = XMLUtil.convertJaxbToString(responseXml) - // logger.info("responding with:\n${text}") - if (!XMLUtil.validateFromString(text)) throw SandboxError( - HttpStatusCode.InternalServerError, - "Outgoint EBICS key management response is invalid" - ) - respondText(text, ContentType.Application.Xml, HttpStatusCode.OK) -} - -fun <T> expectNonNull(x: T?): T { - if (x == null) { - throw EbicsProtocolError(HttpStatusCode.BadRequest, "expected non-null value") - } - return x; -} - -private fun getRelatedParty(branch: XmlElementBuilder, payment: XLibeufinBankTransaction) { - val otherParty = object { - var ibanPath = "CdtrAcct/Id/IBAN" - var namePath = "Cdtr/Nm" - var iban = payment.creditorIban - var name = payment.creditorName - var bicPath = "CdtrAgt/FinInstnId/BIC" - var bic = payment.creditorBic - } - if (payment.direction == XLibeufinBankDirection.CREDIT) { - otherParty.iban = payment.debtorIban - otherParty.ibanPath = "DbtrAcct/Id/IBAN" - otherParty.namePath = "Dbtr/Nm" - otherParty.name = payment.debtorName - otherParty.bic = payment.debtorBic - otherParty.bicPath = "DbtrAgt/FinInstnId/BIC" - } - branch.element("RltdPties") { - element(otherParty.namePath) { - text(otherParty.name) - } - element(otherParty.ibanPath) { - text(otherParty.iban) - } - } - val otherPartyBic = otherParty.bic - if (otherPartyBic != null) { - branch.element("RltdAgts") { - element(otherParty.bicPath) { - text(otherPartyBic) - } - } - } -} - -// This should fix #6269. -private fun getCreditDebitInd(balance: BigDecimal): String { - if (balance < BigDecimal.ZERO) return "DBIT" - return "CRDT" -} - -fun buildCamtString( - type: Int, - subscriberIban: String, - history: MutableList<XLibeufinBankTransaction>, - currency: String -): SandboxCamt { - /** - * ID types required: - * - * - Message Id - * - Statement / Report Id - * - Electronic sequence number - * - Legal sequence number - * - Entry Id by the Servicer - * - Payment information Id - * - Proprietary code of the bank transaction - * - Id of the servicer (Issuer and Code) - */ - val camtCreationTime = getSystemTimeNow() // FIXME: should this be the payment time? - val dashedDate = camtCreationTime.toDashedDate() - val zonedDateTime = camtCreationTime.toZonedString() - val creationTimeMillis = camtCreationTime.toInstant().toEpochMilli() - val messageId = "sandbox-${creationTimeMillis / 1000}-${getRandomString(10)}" - - val camtMessage = constructXml(indent = true) { - root("Document") { - attribute("xmlns", "urn:iso:std:iso:20022:tech:xsd:camt.0${type}.001.02") - attribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance") - attribute( - "xsi:schemaLocation", - "urn:iso:std:iso:20022:tech:xsd:camt.0${type}.001.02 camt.0${type}.001.02.xsd" - ) - element(if (type == 53) "BkToCstmrStmt" else "BkToCstmrAcctRpt") { - element("GrpHdr") { - element("MsgId") { - text(messageId) - } - element("CreDtTm") { - text(zonedDateTime) - } - } - element(if (type == 52) "Rpt" else "Stmt") { - element("Id") { - text("0") - } - element("ElctrncSeqNb") { - text("0") - } - element("LglSeqNb") { - text("0") - } - element("CreDtTm") { - text(zonedDateTime) - } - element("Acct") { - // mandatory account identifier - element("Id/IBAN") { - text(subscriberIban) - } - element("Ccy") { - text(currency) - } - element("Ownr/Nm") { - text("Debitor/Owner Name") - } - element("Svcr/FinInstnId") { - element("Nm") { - text("Libeufin Bank") - } - element("Othr") { - element("Id") { - text("0") - } - element("Issr") { - text("XY") - } - } - } - } - history.forEach { - this.element("Ntry") { - element("Amt") { - attribute("Ccy", it.currency) - text(it.amount) - } - element("CdtDbtInd") { - text( - if (subscriberIban.equals(it.creditorIban)) - "CRDT" else "DBIT" - ) - } - element("Sts") { - /* Status of the entry (see 2.4.2.15.5 from the ISO20022 reference document.) - * From the original text: - * "Status of an entry on the books of the account servicer" */ - text("BOOK") - } - element("BookgDt/Dt") { - text(dashedDate) - } // date of the booking - element("ValDt/Dt") { - text(dashedDate) - } // date of assets' actual (un)availability - element("AcctSvcrRef") { - text(it.uid) - } - element("BkTxCd") { - /* "Set of elements used to fully identify the type of underlying - * transaction resulting in an entry". */ - element("Domn") { - element("Cd") { - text("PMNT") - } - element("Fmly") { - element("Cd") { - text("ICDT") - } - element("SubFmlyCd") { - text("ESCT") - } - } - } - element("Prtry") { - element("Cd") { - text("0") - } - element("Issr") { - text("XY") - } - } - } - element("NtryDtls/TxDtls") { - element("Refs") { - element("MsgId") { - text(it.msgId ?: "NOTPROVIDED") - } - element("PmtInfId") { - text(it.pmtInfId ?: "NOTPROVIDED") - } - element("EndToEndId") { - text(it.endToEndId ?: "NOTPROVIDED") - } - } - element("AmtDtls/TxAmt/Amt") { - attribute("Ccy", currency) - text(it.amount) - } - element("BkTxCd") { - element("Domn") { - element("Cd") { - text("PMNT") - } - element("Fmly") { - element("Cd") { - text("ICDT") - } - element("SubFmlyCd") { - text("ESCT") - } - } - } - element("Prtry") { - element("Cd") { - text("0") - } - element("Issr") { - text("XY") - } - } - } - getRelatedParty(this, it) - element("RmtInf/Ustrd") { - text(it.subject) - } - } - } - } - } - } - } - } - return SandboxCamt( - camtMessage = camtMessage, - messageId = messageId, - creationTime = creationTimeMillis - ) -} - -/** - * Builds CAMT response. - * - * @param type 52 or 53. - */ -private fun constructCamtResponse( - type: Int, - subscriber: EbicsSubscriberEntity, - dateRange: Pair<Long, Long>? -): List<String> { - if (type != 53 && type != 52) throw EbicsUnsupportedOrderType() - val bankAccount = getBankAccountFromSubscriber(subscriber) - val history = mutableListOf<XLibeufinBankTransaction>() - if (type == 52) { - if (dateRange != null) { - logger.debug("Finding date-ranged transactions for account: ${bankAccount.label}, range: ${dateRange.first}, ${dateRange.second}") - transaction { - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.account eq bankAccount.id and - BankAccountTransactionsTable.date.between( - dateRange.first, dateRange.second - ) - }.forEach { history.add(getHistoryElementFromTransactionRow(it)) } - } - } else - transaction { - BankAccountFreshTransactionEntity.all().forEach { - if (it.transactionRef.account.label == bankAccount.label) { - history.add(getHistoryElementFromTransactionRow(it)) - } - } - } - if (history.size == 0) throw EbicsNoDownloadDataAvailable() - val camtData = buildCamtString( - type, - bankAccount.iban, - history, - bankAccount.demoBank.config.currency - ) - val paymentsList: String = if (logger.isDebugEnabled) { - var ret = " It includes the payments:" - for (p in history) ret += "\n- ${p.subject}" - ret - } else "" - logger.debug("camt.052 document '${camtData.messageId}' generated.$paymentsList") - return listOf(camtData.camtMessage) - } // end of C52 case. - val ret = mutableListOf<String>() - /** - * Retrieve all the records whose creation date lies into the - * time range given in the function parameters. - */ - if (dateRange != null) { - logger.debug("Serving C53 with date range: $dateRange") - BankAccountStatementEntity.find { - BankAccountStatementsTable.creationTime.between( - dateRange.first, - dateRange.second) and( - BankAccountStatementsTable.bankAccount eq bankAccount.id) - }.forEach { - logger.debug("Including Camt.053: ${it.statementId}") - ret.add(it.xmlMessage) - } - } else { - logger.debug("Serving C53 without date range.") - // No time range was given, hence pick the latest statement. - BankAccountStatementEntity.find { - BankAccountStatementsTable.bankAccount eq bankAccount.id - }.lastOrNull().apply { - if (this != null) { - logger.debug("Including Camt.053: ${this.statementId}") - ret.add(this.xmlMessage) - } - } - } - if (ret.size == 0) throw EbicsNoDownloadDataAvailable() - return ret -} - -/** - * TSD (test download) message. - * - * This is a non-standard EBICS order type use by LibEuFin to - * test download transactions. - * - * In the future, additional parameters (size, chunking, inject fault for retry) might - * be added to the order parameters. - */ -private fun handleEbicsTSD(): ByteArray { - return "Hello World\n".repeat(1024).toByteArray() -} - -private fun handleEbicsPTK(): ByteArray { - return "Hello I am a dummy PTK response.".toByteArray() -} - -private fun parsePain001(paymentRequest: String): PainParseResult { - val painDoc = XMLUtil.parseStringIntoDom(paymentRequest) - return destructXml(painDoc) { - requireRootElement("Document") { - requireUniqueChildNamed("CstmrCdtTrfInitn") { - val msgId = requireUniqueChildNamed("GrpHdr") { - requireUniqueChildNamed("MsgId") { focusElement.textContent } - } - requireUniqueChildNamed("PmtInf") { - val debtorName = requireUniqueChildNamed("Dbtr"){ - requireUniqueChildNamed("Nm") { - focusElement.textContent - } - } - val debtorIban = requireUniqueChildNamed("DbtrAcct"){ - requireUniqueChildNamed("Id") { - requireUniqueChildNamed("IBAN") { - focusElement.textContent - } - } - } - val debtorBic = requireUniqueChildNamed("DbtrAgt"){ - requireUniqueChildNamed("FinInstnId") { - requireUniqueChildNamed("BIC") { - focusElement.textContent - } - } - } - val pmtInfId = requireUniqueChildNamed("PmtInfId") { focusElement.textContent } - val txDetails = requireUniqueChildNamed("CdtTrfTxInf") { - object { - val creditorIban = requireUniqueChildNamed("CdtrAcct") { - requireUniqueChildNamed("Id") { - requireUniqueChildNamed("IBAN") { focusElement.textContent } - } - } - val creditorName = requireUniqueChildNamed("Cdtr") { - requireUniqueChildNamed("Nm") { - focusElement.textContent - } - } - val creditorBic = maybeUniqueChildNamed("CdtrAgt") { - requireUniqueChildNamed("FinInstnId") { - requireUniqueChildNamed("BIC") { - focusElement.textContent - } - } - } - val amt = requireUniqueChildNamed("Amt") { - requireOnlyChild { focusElement } - } - val subject = requireUniqueChildNamed("RmtInf") { - requireUniqueChildNamed("Ustrd") { focusElement.textContent } - } - val endToEndId = requireUniqueChildNamed("PmtId") { - requireUniqueChildNamed("EndToEndId") { focusElement.textContent } - } - } - } - /** - * NOTE: this check breaks the compatibility with pain.001, - * because that allows up to 5 fractional digits. For Taler - * compatibility however, we enforce the max 2 fractional digits policy. - */ - if (!validatePlainAmount(txDetails.amt.textContent)) { - throw EbicsProcessingError( - "Amount number malformed: ${txDetails.amt.textContent}" - ) - } - PainParseResult( - currency = txDetails.amt.getAttribute("Ccy"), - amount = txDetails.amt.textContent, - subject = txDetails.subject, - debtorIban = debtorIban, - debtorName = debtorName, - debtorBic = debtorBic, - creditorName = txDetails.creditorName, - creditorIban = txDetails.creditorIban, - creditorBic = txDetails.creditorBic, - pmtInfId = pmtInfId, - endToEndId = txDetails.endToEndId, - msgId = msgId - ) - } - } - } - } -} - -/** - * Process a payment request in the pain.001 format. Note: - * the receiver IBAN is NOT checked to have one account at - * the Sandbox. That's because (1) it leaves open to send - * payments outside of the running Sandbox and (2) may ease - * tests where the preparation logic can skip creating also - * the receiver account. */ -private fun handleCct( - paymentRequest: String, - requestingSubscriber: EbicsSubscriberEntity -) { - val parseResult = parsePain001(paymentRequest) - logger.debug("Handling Pain.001: ${parseResult.pmtInfId}, " + - "for payment: ${parseResult.subject}") - transaction(Connection.TRANSACTION_SERIALIZABLE, repetitionAttempts = 10) { - // Check that subscriber has a bank account - // and that they have rights over the debtor IBAN - if (requestingSubscriber.bankAccount == null) throw EbicsProcessingError( - "Subscriber '${requestingSubscriber.userId}' does not have a bank account." - ) - if (requestingSubscriber.bankAccount!!.iban != parseResult.debtorIban) throw - EbicsAccountAuthorisationFailed( - "Subscriber '${requestingSubscriber.userId}' does not have rights" + - " over the debtor IBAN '${parseResult.debtorIban}'" - ) - val maybeExist = BankAccountTransactionEntity.find { - BankAccountTransactionsTable.pmtInfId eq parseResult.pmtInfId - }.firstOrNull() - if (maybeExist != null) { - logger.info( - "Nexus submitted twice the Pain: ${maybeExist.pmtInfId}. Not taking any action." + - " Sandbox gave it this reference: ${maybeExist.accountServicerReference}" - ) - return@transaction - } - val bankAccount = getBankAccountFromIban(parseResult.debtorIban) - if (parseResult.currency != bankAccount.demoBank.config.currency) throw EbicsRequestError( - "[EBICS_PROCESSING_ERROR] Currency (${parseResult.currency}) not supported.", - "091116" - ) - // Check for the debit case. - val maybeAmount = try { - BigDecimal(parseResult.amount) - } catch (e: Exception) { - logger.warn("Although PAIN validated, BigDecimal didn't parse its amount (${parseResult.amount})!") - throw EbicsProcessingError("The CCT request contains an invalid amount: ${parseResult.amount}") - } - if (maybeDebit(bankAccount.label, maybeAmount, bankAccount.demoBank.name)) - throw EbicsAmountCheckError("The requested amount (${parseResult.amount}) would exceed the debit threshold") - logger.debug("Wire-transfer'ing endToEndId: ${parseResult.endToEndId}") - wireTransfer( - bankAccount.label, - getBankAccountFromIban(parseResult.creditorIban).label, - bankAccount.demoBank.name, - parseResult.subject, - "${parseResult.currency}:${parseResult.amount}", - endToEndId = parseResult.endToEndId - ) - } -} - -/** - * This handler reports all the fresh transactions, belonging - * to the querying subscriber. - */ -private fun handleEbicsC52(requestContext: RequestContext): ByteArray { - val maybeDateRange = requestContext.requestObject.header.static.orderDetails?.orderParams - val dateRange: Pair<Long, Long>? = if (maybeDateRange is EbicsRequest.StandardOrderParams) { - val start: Long? = maybeDateRange.dateRange?.start?.toGregorianCalendar()?.timeInMillis - val end: Long? = maybeDateRange.dateRange?.end?.toGregorianCalendar()?.timeInMillis - Pair(start ?: 0L, end ?: Long.MAX_VALUE) - } else null - logger.debug("Date range: $dateRange") - val report = constructCamtResponse( - 52, - requestContext.subscriber, - dateRange = dateRange - ) - sandboxAssert( - report.size == 1, - "C52 response contains more than one Camt.052 document" - ) - if (!XMLUtil.validateFromString(report[0])) { - logger.error("This document was generated invalid:\n${report[0]}") - throw EbicsProcessingError("One outgoing report was found invalid.") - } - return report.map { it.toByteArray() }.zip() -} - -private fun handleEbicsC53(requestContext: RequestContext): ByteArray { - // Fetch date range. - val orderParams = requestContext.requestObject.header.static.orderDetails?.orderParams // as EbicsRequest.StandardOrderParams - val dateRange = if (orderParams != null) { - val standardOrderParams = orderParams as EbicsRequest.StandardOrderParams - val start = standardOrderParams.dateRange?.start?.toGregorianCalendar()?.timeInMillis - val end = standardOrderParams.dateRange?.end?.toGregorianCalendar()?.timeInMillis - if (start == null || end == null) { - // only accepting when both start/end are given. - null - } else { - Pair(start, end) - } - } else - null - /** - * By multiple statements, this function is responsible to return - * a list of Strings: one for each statement. - */ - val camtStatements = constructCamtResponse( - 53, - requestContext.subscriber, - dateRange - ) - camtStatements.forEach { - if (!XMLUtil.validateFromString(it)) { - logger.error("This document was generated invalid:\n$it") - throw EbicsProcessingError("One outgoing statement was found invalid.") - } - } - return camtStatements.map { it.toByteArray() }.zip() -} - -private suspend fun ApplicationCall.handleEbicsHia(header: EbicsUnsecuredRequest.Header, orderData: ByteArray) { - InflaterInputStream(orderData.inputStream()).use { it.readAllBytes() } - val keyObject = EbicsOrderUtil.decodeOrderDataXml<HIARequestOrderData>(orderData) - val encPubXml = keyObject.encryptionPubKeyInfo.pubKeyValue.rsaKeyValue - val authPubXml = keyObject.authenticationPubKeyInfo.pubKeyValue.rsaKeyValue - val encPub = CryptoUtil.loadRsaPublicKeyFromComponents(encPubXml.modulus, encPubXml.exponent) - val authPub = CryptoUtil.loadRsaPublicKeyFromComponents(authPubXml.modulus, authPubXml.exponent) - - val ok = transaction { - val ebicsSubscriber = findEbicsSubscriber(header.static.partnerID, header.static.userID, header.static.systemID) - if (ebicsSubscriber == null) { - logger.warn("ebics subscriber not found") - throw EbicsInvalidRequestError() - } - when (ebicsSubscriber.state) { - SubscriberState.NEW -> {} - SubscriberState.PARTIALLY_INITIALIZED_INI -> {} - SubscriberState.PARTIALLY_INITIALIZED_HIA, SubscriberState.INITIALIZED, SubscriberState.READY -> { - return@transaction false - } - } - - ebicsSubscriber.authenticationKey = EbicsSubscriberPublicKeyEntity.new { - this.rsaPublicKey = ExposedBlob(authPub.encoded) - state = KeyState.NEW - } - ebicsSubscriber.encryptionKey = EbicsSubscriberPublicKeyEntity.new { - this.rsaPublicKey = ExposedBlob(encPub.encoded) - state = KeyState.NEW - } - ebicsSubscriber.state = when (ebicsSubscriber.state) { - SubscriberState.NEW -> SubscriberState.PARTIALLY_INITIALIZED_HIA - SubscriberState.PARTIALLY_INITIALIZED_INI -> SubscriberState.INITIALIZED - else -> throw Exception("internal invariant failed") - } - return@transaction true - } - if (ok) { - respondEbicsKeyManagement("[EBICS_OK]", "000000", "000000") - } else { - respondEbicsKeyManagement("[EBICS_INVALID_USER_OR_USER_STATE]", "091002", "000000") - } -} - -private suspend fun ApplicationCall.handleEbicsIni(header: EbicsUnsecuredRequest.Header, orderData: ByteArray) { - InflaterInputStream(orderData.inputStream()).use { it.readAllBytes() } - val keyObject = EbicsOrderUtil.decodeOrderDataXml<SignatureTypes.SignaturePubKeyOrderData>(orderData) - val sigPubXml = keyObject.signaturePubKeyInfo.pubKeyValue.rsaKeyValue - val sigPub = CryptoUtil.loadRsaPublicKeyFromComponents(sigPubXml.modulus, sigPubXml.exponent) - - val ok = transaction { - val ebicsSubscriber = - findEbicsSubscriber(header.static.partnerID, header.static.userID, header.static.systemID) - if (ebicsSubscriber == null) { - logger.warn("ebics subscriber, ${dumpEbicsSubscriber(header.static)}, not found") - throw EbicsUserUnknown(dumpEbicsSubscriber(header.static)) - } - when (ebicsSubscriber.state) { - SubscriberState.NEW -> {} - SubscriberState.PARTIALLY_INITIALIZED_HIA -> {} - SubscriberState.PARTIALLY_INITIALIZED_INI, SubscriberState.INITIALIZED, SubscriberState.READY -> { - return@transaction false - } - } - ebicsSubscriber.signatureKey = EbicsSubscriberPublicKeyEntity.new { - this.rsaPublicKey = ExposedBlob(sigPub.encoded) - state = KeyState.NEW - } - ebicsSubscriber.state = when (ebicsSubscriber.state) { - SubscriberState.NEW -> SubscriberState.PARTIALLY_INITIALIZED_INI - SubscriberState.PARTIALLY_INITIALIZED_HIA -> SubscriberState.INITIALIZED - else -> throw Error("internal invariant failed") - } - return@transaction true - } - logger.info("Signature key inserted in database _and_ subscriber state changed accordingly") - if (ok) { - respondEbicsKeyManagement("[EBICS_OK]", "000000", "000000") - } else { - respondEbicsKeyManagement("[EBICS_INVALID_USER_OR_USER_STATE]", "091002", "000000") - } -} - -private suspend fun ApplicationCall.handleEbicsHpb( - ebicsHostInfo: EbicsHostPublicInfo, - requestDocument: Document, - header: EbicsNpkdRequest.Header -) { - val subscriberKeys = transaction { - val ebicsSubscriber = - findEbicsSubscriber(header.static.partnerID, header.static.userID, header.static.systemID) - if (ebicsSubscriber == null) { - throw EbicsInvalidRequestError() - } - if (ebicsSubscriber.state != SubscriberState.INITIALIZED) { - throw EbicsSubscriberStateError() - } - val authPubBlob = ebicsSubscriber.authenticationKey!!.rsaPublicKey - val encPubBlob = ebicsSubscriber.encryptionKey!!.rsaPublicKey - val sigPubBlob = ebicsSubscriber.signatureKey!!.rsaPublicKey - SubscriberKeys( - CryptoUtil.loadRsaPublicKey(authPubBlob.bytes), - CryptoUtil.loadRsaPublicKey(encPubBlob.bytes), - CryptoUtil.loadRsaPublicKey(sigPubBlob.bytes) - ) - } - val validationResult = - XMLUtil.verifyEbicsDocument(requestDocument, subscriberKeys.authenticationPublicKey) - if (!validationResult) { - throw EbicsKeyManagementError("invalid signature", "90000") - } - val hpbRespondeData = HPBResponseOrderData().apply { - this.authenticationPubKeyInfo = EbicsTypes.AuthenticationPubKeyInfoType().apply { - this.authenticationVersion = "X002" - this.pubKeyValue = EbicsTypes.PubKeyValueType().apply { - this.rsaKeyValue = RSAKeyValueType().apply { - this.exponent = ebicsHostInfo.authenticationPublicKey.publicExponent.toByteArray() - this.modulus = ebicsHostInfo.authenticationPublicKey.modulus.toByteArray() - } - } - } - this.encryptionPubKeyInfo = EbicsTypes.EncryptionPubKeyInfoType().apply { - this.encryptionVersion = "E002" - this.pubKeyValue = EbicsTypes.PubKeyValueType().apply { - this.rsaKeyValue = RSAKeyValueType().apply { - this.exponent = ebicsHostInfo.encryptionPublicKey.publicExponent.toByteArray() - this.modulus = ebicsHostInfo.encryptionPublicKey.modulus.toByteArray() - } - } - } - this.hostID = ebicsHostInfo.hostID - } - val compressedOrderData = EbicsOrderUtil.encodeOrderDataXml(hpbRespondeData) - val encryptionResult = CryptoUtil.encryptEbicsE002(compressedOrderData, subscriberKeys.encryptionPublicKey) - respondEbicsKeyManagement("[EBICS_OK]", "000000", "000000", encryptionResult, "OR01") -} - -/** - * Find the ebics host corresponding to the one specified in the header. - */ -private fun ensureEbicsHost(requestHostID: String): EbicsHostPublicInfo { - return transaction { - val ebicsHost = - EbicsHostEntity.find { EbicsHostsTable.hostID.upperCase() eq requestHostID.uppercase(Locale.getDefault()) }.firstOrNull() - if (ebicsHost == null) { - logger.warn("client requested unknown HostID ${requestHostID}") - throw EbicsKeyManagementError("[EBICS_INVALID_HOST_ID]", "091011") - } - val encryptionPrivateKey = CryptoUtil.loadRsaPrivateKey(ebicsHost.encryptionPrivateKey.bytes) - val authenticationPrivateKey = CryptoUtil.loadRsaPrivateKey(ebicsHost.authenticationPrivateKey.bytes) - EbicsHostPublicInfo( - requestHostID, - CryptoUtil.getRsaPublicFromPrivate(encryptionPrivateKey), - CryptoUtil.getRsaPublicFromPrivate(authenticationPrivateKey) - ) - } -} -fun receiveEbicsXmlInternal(xmlData: String): Document { - // logger.debug("Data received: $xmlData") - val requestDocument: Document = XMLUtil.parseStringIntoDom(xmlData) - if (!XMLUtil.validateFromDom(requestDocument)) { - println("Problematic document was: $requestDocument") - throw EbicsInvalidXmlError() - } - return requestDocument -} - -private fun makePartnerInfo(subscriber: EbicsSubscriberEntity): EbicsTypes.PartnerInfo { - val bankAccount = getBankAccountFromSubscriber(subscriber) - val customerProfile = getCustomer(bankAccount.label) - return EbicsTypes.PartnerInfo().apply { - this.accountInfoList = listOf( - EbicsTypes.AccountInfo().apply { - this.id = bankAccount.label - this.accountHolder = customerProfile.name ?: "Never Given" - this.accountNumberList = listOf( - EbicsTypes.GeneralAccountNumber().apply { - this.international = true - this.value = bankAccount.iban - } - ) - this.currency = bankAccount.demoBank.config.currency - this.description = "Ordinary Bank Account" - this.bankCodeList = listOf( - EbicsTypes.GeneralBankCode().apply { - this.international = true - this.value = bankAccount.bic - } - ) - } - ) - this.addressInfo = EbicsTypes.AddressInfo().apply { - this.name = "Address Info Object" - } - this.bankInfo = EbicsTypes.BankInfo().apply { - this.hostID = subscriber.hostId - } - this.orderInfoList = listOf( - EbicsTypes.AuthOrderInfoType().apply { - this.description = "Transactions statement" - this.orderType = "C53" - this.transferType = "Download" - }, - EbicsTypes.AuthOrderInfoType().apply { - this.description = "Transactions report" - this.orderType = "C52" - this.transferType = "Download" - }, - EbicsTypes.AuthOrderInfoType().apply { - this.description = "Payment initiation (ZIPped payload)" - this.orderType = "CCC" - this.transferType = "Upload" - }, - EbicsTypes.AuthOrderInfoType().apply { - this.description = "Payment initiation (plain text payload)" - this.orderType = "CCT" - this.transferType = "Upload" - }, - EbicsTypes.AuthOrderInfoType().apply { - this.description = "vmk" - this.orderType = "VMK" - this.transferType = "Download" - }, - EbicsTypes.AuthOrderInfoType().apply { - this.description = "sta" - this.orderType = "STA" - this.transferType = "Download" - } - ) - } -} - -private fun handleEbicsHtd(requestContext: RequestContext): ByteArray { - val htd = HTDResponseOrderData().apply { - this.partnerInfo = makePartnerInfo(requestContext.subscriber) - this.userInfo = EbicsTypes.UserInfo().apply { - this.name = "Some User" - this.userID = EbicsTypes.UserIDType().apply { - this.status = 5 - this.value = requestContext.subscriber.userId - } - this.permissionList = listOf( - EbicsTypes.UserPermission().apply { - this.orderTypes = "C53 C52 CCC VMK STA" - } - ) - } - } - val str = XMLUtil.convertJaxbToString(htd) - return str.toByteArray() -} - -private fun handleEbicsHkd(requestContext: RequestContext): ByteArray { - val hkd = HKDResponseOrderData().apply { - this.partnerInfo = makePartnerInfo(requestContext.subscriber) - this.userInfoList = listOf( - EbicsTypes.UserInfo().apply { - this.name = "Some User" - this.userID = EbicsTypes.UserIDType().apply { - this.status = 1 - this.value = requestContext.subscriber.userId - } - this.permissionList = listOf( - EbicsTypes.UserPermission().apply { - this.orderTypes = "C54 C53 C52 CCC" - } - ) - }) - } - val str = XMLUtil.convertJaxbToString(hkd) - return str.toByteArray() -} - -private data class RequestContext( - val ebicsHost: EbicsHostEntity, - val subscriber: EbicsSubscriberEntity, - val clientEncPub: RSAPublicKey, - val clientAuthPub: RSAPublicKey, - val clientSigPub: RSAPublicKey, - val hostEncPriv: RSAPrivateCrtKey, - val hostAuthPriv: RSAPrivateCrtKey, - val requestObject: EbicsRequest, - val uploadTransaction: EbicsUploadTransactionEntity?, - val downloadTransaction: EbicsDownloadTransactionEntity? -) - -/** - * Get segmentation values and the EBICS transaction ID, before - * handing the response to 'createForDownloadTransferPhase()'. - */ -private fun handleEbicsDownloadTransactionTransfer(requestContext: RequestContext): EbicsResponse { - val segmentNumber = - requestContext.requestObject.header.mutable.segmentNumber?.value ?: throw EbicsInvalidRequestError() - val transactionID = requestContext.requestObject.header.static.transactionID ?: throw EbicsInvalidRequestError() - val downloadTransaction = requestContext.downloadTransaction ?: throw AssertionError() - return EbicsResponse.createForDownloadTransferPhase( - transactionID, - downloadTransaction.numSegments, - downloadTransaction.segmentSize, - downloadTransaction.encodedResponse, - segmentNumber.toInt() - ) -} - -private fun handleEbicsDownloadTransactionInitialization(requestContext: RequestContext): EbicsResponse { - val orderType = - requestContext.requestObject.header.static.orderDetails?.orderType ?: throw EbicsInvalidRequestError() - val nonce = requestContext.requestObject.header.static.nonce - val transactionID = EbicsOrderUtil.generateTransactionId() - logger.debug( - "Handling download initialization for order type $orderType, " + - "nonce: ${nonce?.toHexString() ?: "not given"}, " + - "transaction ID: $transactionID" - ) - val response = when (orderType) { - "HTD" -> handleEbicsHtd(requestContext) - "HKD" -> handleEbicsHkd(requestContext) - "C53" -> handleEbicsC53(requestContext) - "C52" -> handleEbicsC52(requestContext) - "TSD" -> handleEbicsTSD() - "PTK" -> handleEbicsPTK() - else -> throw EbicsInvalidXmlError() - } - val compressedResponse = DeflaterInputStream(response.inputStream()).use { - it.readAllBytes() - } - val enc = CryptoUtil.encryptEbicsE002(compressedResponse, requestContext.clientEncPub) - val encodedResponse = Base64.getEncoder().encodeToString(enc.encryptedData) - - val segmentSize = 4096 - val totalSize = encodedResponse.length - val numSegments = ((totalSize + segmentSize - 1) / segmentSize) - - EbicsDownloadTransactionEntity.new(transactionID) { - this.subscriber = requestContext.subscriber - this.host = requestContext.ebicsHost - this.orderType = orderType - this.segmentSize = segmentSize - this.transactionKeyEnc = ExposedBlob(enc.encryptedTransactionKey) - this.encodedResponse = encodedResponse - this.numSegments = numSegments - this.receiptReceived = false - } - /** - * In case of C52, the payload (that includes all the pending - * transactions) got at this point persisted into the database. - * The next block causes such transactions NOT to be returned - * along the next C52 request. - */ - if (orderType == "C52") { - val account = getBankAccountFromSubscriber(requestContext.subscriber) - BankAccountFreshTransactionEntity.all().forEach { - if (it.transactionRef.account.label == account.label) - it.delete() - } - } - return EbicsResponse.createForDownloadInitializationPhase( - transactionID, - numSegments, - segmentSize, - enc, // has customer key - encodedResponse - ) -} - -private fun handleEbicsUploadTransactionInitialization(requestContext: RequestContext): EbicsResponse { - val orderType = - requestContext.requestObject.header.static.orderDetails?.orderType ?: throw EbicsInvalidRequestError() - val transactionID = EbicsOrderUtil.generateTransactionId() - logger.debug("Handling upload initialization for order $orderType, " + - "transactionID $transactionID, nonce: " + - (requestContext.requestObject.header.static.nonce?.toHexString() ?: "not given") - ) - val oidn = requestContext.subscriber.nextOrderID++ - if (EbicsOrderUtil.checkOrderIDOverflow(oidn)) throw NotImplementedError() - val orderID = EbicsOrderUtil.computeOrderIDFromNumber(oidn) - val numSegments = - requestContext.requestObject.header.static.numSegments ?: throw EbicsInvalidRequestError() - val transactionKeyEnc = - requestContext.requestObject.body.dataTransfer?.dataEncryptionInfo?.transactionKey - ?: throw EbicsInvalidRequestError() - val encPubKeyDigest = - requestContext.requestObject.body.dataTransfer?.dataEncryptionInfo?.encryptionPubKeyDigest?.value - ?: throw EbicsInvalidRequestError() - val encSigData = requestContext.requestObject.body.dataTransfer?.signatureData?.value - ?: throw EbicsInvalidRequestError() - val decryptedSignatureData = CryptoUtil.decryptEbicsE002( - CryptoUtil.EncryptionResult( - transactionKeyEnc, - encPubKeyDigest, - encSigData - ), requestContext.hostEncPriv - ) - val plainSigData = InflaterInputStream(decryptedSignatureData.inputStream()).use { - it.readAllBytes() - } - EbicsUploadTransactionEntity.new(transactionID) { - this.host = requestContext.ebicsHost - this.subscriber = requestContext.subscriber - this.lastSeenSegment = 0 - this.orderType = orderType - this.orderID = orderID - this.numSegments = numSegments.toInt() - this.transactionKeyEnc = ExposedBlob(transactionKeyEnc) - } - val sigObj = XMLUtil.convertStringToJaxb<UserSignatureData>(plainSigData.toString(Charsets.UTF_8)) - for (sig in sigObj.value.orderSignatureList ?: listOf()) { - logger.debug("inserting order signature for orderID $orderID, order type $orderType, transaction '$transactionID'") - EbicsOrderSignatureEntity.new { - this.orderID = orderID - this.orderType = orderType - this.partnerID = sig.partnerID - this.userID = sig.userID - this.signatureAlgorithm = sig.signatureVersion - this.signatureValue = ExposedBlob(sig.signatureValue) - } - } - return EbicsResponse.createForUploadInitializationPhase(transactionID, orderID) -} - -private fun handleEbicsUploadTransactionTransmission(requestContext: RequestContext): EbicsResponse { - val uploadTransaction = requestContext.uploadTransaction ?: throw EbicsInvalidRequestError() - val requestObject = requestContext.requestObject - val requestSegmentNumber = - requestContext.requestObject.header.mutable.segmentNumber?.value?.toInt() ?: throw EbicsInvalidRequestError() - val requestTransactionID = requestObject.header.static.transactionID ?: throw EbicsInvalidRequestError() - if (requestSegmentNumber == 1 && uploadTransaction.numSegments == 1) { - val encOrderData = - requestObject.body.dataTransfer?.orderData ?: throw EbicsInvalidRequestError() - val zippedData = CryptoUtil.decryptEbicsE002( - uploadTransaction.transactionKeyEnc.bytes, - Base64.getDecoder().decode(encOrderData), - requestContext.hostEncPriv - ) - val unzippedData = - InflaterInputStream(zippedData.inputStream()).use { it.readAllBytes() } - - val sigs = EbicsOrderSignatureEntity.find { - (EbicsOrderSignaturesTable.orderID eq uploadTransaction.orderID) and - (EbicsOrderSignaturesTable.orderType eq uploadTransaction.orderType) - } - if (sigs.count() == 0L) { - throw EbicsInvalidRequestError() - } - for (sig in sigs) { - if (sig.signatureAlgorithm == "A006") { - - val signedData = CryptoUtil.digestEbicsOrderA006(unzippedData) - val res1 = CryptoUtil.verifyEbicsA006( - sig.signatureValue.bytes, - signedData, - requestContext.clientSigPub - ) - if (!res1) { - throw EbicsInvalidRequestError() - } - - } else { - throw NotImplementedError() - } - } - if (getOrderTypeFromTransactionId(requestTransactionID) == "CCT") { - handleCct(unzippedData.toString(Charsets.UTF_8), - requestContext.subscriber - ) - } - return EbicsResponse.createForUploadTransferPhase( - requestTransactionID, - requestSegmentNumber, - true, - uploadTransaction.orderID - ) - } else { - throw NotImplementedError() - } -} -// req.header.static.hostID. -private fun makeRequestContext(requestObject: EbicsRequest): RequestContext { - val staticHeader = requestObject.header.static - val requestedHostId = staticHeader.hostID - val ebicsHost = - EbicsHostEntity.find { EbicsHostsTable.hostID.upperCase() eq requestedHostId.uppercase(Locale.getDefault()) } - .firstOrNull() - val requestTransactionID = requestObject.header.static.transactionID - var downloadTransaction: EbicsDownloadTransactionEntity? = null - var uploadTransaction: EbicsUploadTransactionEntity? = null - val subscriber = if (requestTransactionID != null) { - downloadTransaction = EbicsDownloadTransactionEntity.findById(requestTransactionID.uppercase(Locale.getDefault())) - if (downloadTransaction != null) { - downloadTransaction.subscriber - } else { - uploadTransaction = EbicsUploadTransactionEntity.findById(requestTransactionID) - uploadTransaction?.subscriber - } - } else { - val partnerID = staticHeader.partnerID ?: throw EbicsInvalidRequestError() - val userID = staticHeader.userID ?: throw EbicsInvalidRequestError() - findEbicsSubscriber(partnerID, userID, staticHeader.systemID) - } - - if (ebicsHost == null) throw EbicsInvalidRequestError() - - /** - * NOTE: production logic must check against READY state (the - * one activated after the subscriber confirms their keys via post) - */ - if (subscriber == null || subscriber.state != SubscriberState.INITIALIZED) - throw EbicsSubscriberStateError() - - val hostAuthPriv = CryptoUtil.loadRsaPrivateKey( - ebicsHost.authenticationPrivateKey.bytes - ) - val hostEncPriv = CryptoUtil.loadRsaPrivateKey( - ebicsHost.encryptionPrivateKey.bytes - ) - val clientAuthPub = - CryptoUtil.loadRsaPublicKey(subscriber.authenticationKey!!.rsaPublicKey.bytes) - val clientEncPub = - CryptoUtil.loadRsaPublicKey(subscriber.encryptionKey!!.rsaPublicKey.bytes) - val clientSigPub = - CryptoUtil.loadRsaPublicKey(subscriber.signatureKey!!.rsaPublicKey.bytes) - - return RequestContext( - hostAuthPriv = hostAuthPriv, - hostEncPriv = hostEncPriv, - clientAuthPub = clientAuthPub, - clientEncPub = clientEncPub, - clientSigPub = clientSigPub, - ebicsHost = ebicsHost, - requestObject = requestObject, - subscriber = subscriber, - downloadTransaction = downloadTransaction, - uploadTransaction = uploadTransaction - ) -} - -suspend fun ApplicationCall.ebicsweb() { - val requestDocument = this.request.call.receive<Document>() - val requestedHostID = requestDocument.getElementsByTagName("HostID") - this.attributes.put( - EbicsHostIdAttribute, - requestedHostID.item(0).textContent - ) - when (requestDocument.documentElement.localName) { - "ebicsUnsecuredRequest" -> { - val requestObject = requestDocument.toObject<EbicsUnsecuredRequest>() - logger.info("Serving a ${requestObject.header.static.orderDetails.orderType} request") - - val orderData = requestObject.body.dataTransfer.orderData.value - val header = requestObject.header - - when (header.static.orderDetails.orderType) { - "INI" -> handleEbicsIni(header, orderData) - "HIA" -> handleEbicsHia(header, orderData) - else -> throw EbicsInvalidXmlError() - } - } - "ebicsHEVRequest" -> { - val hevResponse = HEVResponse().apply { - this.systemReturnCode = SystemReturnCodeType().apply { - this.reportText = "[EBICS_OK]" - this.returnCode = "000000" - } - this.versionNumber = listOf(HEVResponse.VersionNumber.create("H004", "02.50")) - } - - val strResp = XMLUtil.convertJaxbToString(hevResponse) - if (!XMLUtil.validateFromString(strResp)) throw SandboxError( - HttpStatusCode.InternalServerError, - "Outgoing HEV response is invalid" - ) - respondText(strResp, ContentType.Application.Xml, HttpStatusCode.OK) - } - // FIXME: should check subscriber state? - "ebicsNoPubKeyDigestsRequest" -> { - val requestObject = requestDocument.toObject<EbicsNpkdRequest>() - val hostInfo = ensureEbicsHost(requestObject.header.static.hostID) - when (requestObject.header.static.orderDetails.orderType) { - "HPB" -> handleEbicsHpb(hostInfo, requestDocument, requestObject.header) - else -> throw EbicsInvalidXmlError() - } - } - // FIXME: must check subscriber state. - "ebicsRequest" -> { - val requestObject = requestDocument.toObject<EbicsRequest>() - val responseXmlStr = transaction(Connection.TRANSACTION_SERIALIZABLE, repetitionAttempts = 10) { - // Step 1 of 3: Get information about the host and subscriber - val requestContext = makeRequestContext(requestObject) - // Step 2 of 3: Validate the signature - val verifyResult = XMLUtil.verifyEbicsDocument(requestDocument, requestContext.clientAuthPub) - if (!verifyResult) { - throw EbicsAccountAuthorisationFailed("Subscriber's signature did not verify") - } - // Step 3 of 3: Generate response - val ebicsResponse: EbicsResponse = when (requestObject.header.mutable.transactionPhase) { - EbicsTypes.TransactionPhaseType.INITIALISATION -> { - if (requestObject.header.static.numSegments == null) { - handleEbicsDownloadTransactionInitialization(requestContext) - } else { - handleEbicsUploadTransactionInitialization(requestContext) - } - } - EbicsTypes.TransactionPhaseType.TRANSFER -> { - if (requestContext.uploadTransaction != null) { - handleEbicsUploadTransactionTransmission(requestContext) - } else if (requestContext.downloadTransaction != null) { - handleEbicsDownloadTransactionTransfer(requestContext) - } else { - throw AssertionError() - } - } - EbicsTypes.TransactionPhaseType.RECEIPT -> { - val requestTransactionID = - requestObject.header.static.transactionID ?: throw EbicsInvalidRequestError() - if (requestContext.downloadTransaction == null) - throw EbicsInvalidRequestError() - logger.debug("Handling download receipt for EBICS transaction: " + - requestTransactionID) - /** - * The receipt phase means that the client has already - * received all the data related to the current download - * transaction. Hence this data can now be removed from - * the database. - */ - val ebicsData = transaction { - EbicsDownloadTransactionEntity.findById(requestTransactionID) - } - if (ebicsData == null) - throw SandboxError( - HttpStatusCode.InternalServerError, - "EBICS transaction $requestTransactionID was not" + - "found in the database for deletion.", - LibeufinErrorCode.LIBEUFIN_EC_INCONSISTENT_STATE - ) - ebicsData.delete() - val receiptCode = - requestObject.body.transferReceipt?.receiptCode ?: throw EbicsInvalidRequestError() - EbicsResponse.createForDownloadReceiptPhase(requestTransactionID, receiptCode == 0) - } - } - signEbicsResponse(ebicsResponse, requestContext.hostAuthPriv) - } - if (!XMLUtil.validateFromString(responseXmlStr)) throw SandboxError( - HttpStatusCode.InternalServerError, - "Outgoing EBICS XML is invalid" - ) - respondText(responseXmlStr, ContentType.Application.Xml, HttpStatusCode.OK) - } - else -> { - /* Log to console and return "unknown type" */ - logger.info("Unknown message, just logging it!") - respond( - HttpStatusCode.NotImplemented, - SandboxError( - HttpStatusCode.NotImplemented, - "Not Implemented" - ) - ) - } - } -} diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/Helpers.kt @@ -1,472 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2020 Taler Systems S.A. - * - * LibEuFin is free software; you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation; either version 3, or - * (at your option) any later version. - * - * LibEuFin is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General - * Public License for more details. - * - * You should have received a copy of the GNU Affero General Public - * License along with LibEuFin; see the file COPYING. If not, see - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.bank - -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.SerializationFeature -import io.ktor.server.application.* -import io.ktor.http.HttpStatusCode -import io.ktor.server.request.* -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.util.* -import java.security.interfaces.RSAPublicKey -import java.util.* -import java.util.zip.DeflaterInputStream -import kotlin.reflect.KProperty - -data class DemobankConfig( - val allowRegistrations: Boolean, - val currency: String, - val cashoutCurrency: String? = null, - val bankDebtLimit: Int, - val usersDebtLimit: Int, - val withSignupBonus: Boolean, - val demobankName: String, // demobank name. - val captchaUrl: String? = null, - val smsTan: String? = null, // fixme: move the config subcommand - val emailTan: String? = null, // fixme: same as above. - val suggestedExchangeBaseUrl: String? = null, - val suggestedExchangePayto: String? = null, - val nexusBaseUrl: String? = null, - val usernameAtNexus: String? = null, - val passwordAtNexus: String? = null, - val enableConversionService: Boolean = false -) - -fun <T>getConfigValueOrThrow(configKey: KProperty<T?>): T { - return configKey.getter.call() ?: throw nullConfigValueError(configKey.name) -} - -/** - * Helps to communicate Camt values without having - * to parse the XML each time one is needed. - */ -data class SandboxCamt( - val camtMessage: String, - val messageId: String, - /** - * That is the number of SECONDS since Epoch. This - * value is exactly what goes into the Camt document. - */ - val creationTime: Long -) - -/** - * DB helper inserting a new "account" into the database. - * The account is made of a 'customer' and 'bank account' - * object. The helper checks first that the username is - * acceptable (chars, no institutional names, available - * names); then checks that IBAN is available and then adds - * the two database objects under the given demobank. This - * function contains the common logic shared by the Access - * and Circuit API. Additional data that is peculiar to one - * API should be added separately. - * - * It returns a AccountPair type. That contains the customer - * object and the bank account; the caller may this way add custom - * values to them. */ -data class AccountPair( - val customer: DemobankCustomerEntity, - val bankAccount: BankAccountEntity -) -fun insertNewAccount(username: String, - password: String, - name: String? = null, // tests and access API may not give one. - iban: String? = null, - demobank: String = "default", - isPublic: Boolean = false): AccountPair { - requireValidResourceName(username) - // Forbid institutional usernames. - if (username == "bank" || username == "admin") { - logger.info("Username: $username not allowed.") - throw forbidden("Username: $username is not allowed.") - } - return transaction { - val demobankFromDb = getDemobank(demobank) - // Bank's fault, because when this function gets - // called, the demobank must exist. - if (demobankFromDb == null) { - logger.error("Demobank '$demobank' not found. Won't add account $username") - throw internalServerError("Demobank $demobank not found. Won't add account $username") - } - // Generate a IBAN if the caller didn't provide one. - val newIban = iban ?: getIban() - // Check IBAN collisions. - val checkIbanExist = BankAccountEntity.find(BankAccountsTable.iban eq newIban).firstOrNull() - if (checkIbanExist != null) { - logger.info("IBAN $newIban not available. Won't register username $username") - throw conflict("IBAN $iban not available.") - } - // Check username availability. - val checkCustomerExist = DemobankCustomerEntity.find { - DemobankCustomersTable.username eq username - }.firstOrNull() - if (checkCustomerExist != null) { - throw SandboxError( - HttpStatusCode.Conflict, - "Username $username not available." - ) - } - val newCustomer = DemobankCustomerEntity.new { - this.username = username - passwordHash = CryptoUtil.hashpw(password) - this.name = name // nullable - } - // Actual account creation. - val newBankAccount = BankAccountEntity.new { - this.iban = newIban - /** - * For now, keep same semantics of Pybank: a username - * is AS WELL a bank account label. In other words, it - * identifies a customer AND a bank account. The reason - * to have the two values (label and owner) is to allow - * multiple bank accounts being owned by one customer. - */ - label = username - owner = username - this.demoBank = demobankFromDb - this.isPublic = isPublic - } - if (demobankFromDb.config.withSignupBonus) - newBankAccount.bonus("${demobankFromDb.config.currency}:100") - AccountPair(customer = newCustomer, bankAccount = newBankAccount) - } -} - -/** - * Return true if access to the bank account can be granted, - * false otherwise. - * - * Given the policy of having bank account names matching - * their owner's username, this function enforces such policy - * with the exception that 'admin' can access every bank - * account. A null username indicates disabled authentication - * checks, hence it grants the access. - */ -fun allowOwnerOrAdmin(username: String?, bankAccountLabel: String): Boolean { - if (username == null) return true - if (username == "admin") return true - return username == bankAccountLabel -} - -/** - * Throws exception if the credentials are wrong. - * - * Return: - * - null if the authentication is disabled (during tests, for example). - * This facilitates tests because allows requests to lack entirely an - * Authorization header. - * - the username of the authenticated user - * - throw exception when the authentication fails - * - * Note: at this point it is ONLY checked whether the user provided - * a valid password for the username mentioned in the Authorization header. - * The actual access to the resources must be later checked by each handler. - */ -fun ApplicationRequest.basicAuth(onlyAdmin: Boolean = false): String? { - val withAuth = this.call.ensureAttribute(WITH_AUTH_ATTRIBUTE_KEY) - if (!withAuth) { - logger.info("Authentication is disabled - assuming tests currently running.") - return null - } - val credentials = getHTTPBasicAuthCredentials(this) - if (credentials.first == "admin") { - // env must contain the admin password, because --with-auth is true. - val adminPassword: String = this.call.ensureAttribute(ADMIN_PASSWORD_ATTRIBUTE_KEY) - if (credentials.second != adminPassword) throw unauthorized( - "Admin authentication failed" - ) - return credentials.first - } - if (onlyAdmin) throw forbidden("Only admin allowed.") - val passwordHash = transaction { - val customer = getCustomer(credentials.first) - customer.passwordHash - } - if (!CryptoUtil.checkPwOrThrow(credentials.second, passwordHash)) - throw unauthorized("Customer '${credentials.first}' gave wrong credentials") - return credentials.first -} - -fun sandboxAssert(condition: Boolean, reason: String) { - if (!condition) throw SandboxError(HttpStatusCode.InternalServerError, reason) -} - -fun getOrderTypeFromTransactionId(transactionID: String): String { - val uploadTransaction = transaction { - EbicsUploadTransactionEntity.findById(transactionID) - } ?: throw SandboxError( - /** - * NOTE: at this point, it might even be the server's fault. - * For example, if it failed to store a ID earlier. - */ - HttpStatusCode.NotFound, - "Could not retrieve order type for transaction: $transactionID" - ) - return uploadTransaction.orderType -} - -fun getHistoryElementFromTransactionRow(dbRow: BankAccountTransactionEntity): XLibeufinBankTransaction { - return XLibeufinBankTransaction( - subject = dbRow.subject, - creditorIban = dbRow.creditorIban, - creditorBic = dbRow.creditorBic, - creditorName = dbRow.creditorName, - debtorIban = dbRow.debtorIban, - debtorBic = dbRow.debtorBic, - debtorName = dbRow.debtorName, - date = dbRow.date.toString(), - amount = dbRow.amount, - currency = dbRow.currency, - // UID assigned by the bank itself. - uid = dbRow.accountServicerReference, - direction = XLibeufinBankDirection.convertCamtDirectionToXLibeufin(dbRow.direction), - // UIDs as gotten from a pain.001 (from EBICS connections.) - pmtInfId = dbRow.pmtInfId, - endToEndId = dbRow.endToEndId - ) -} - -fun printConfig(demobank: DemobankConfigEntity) { - val ret = ObjectMapper() - ret.configure(SerializationFeature.INDENT_OUTPUT, true) - println( - ret.writeValueAsString(object { - val currency = demobank.config.currency - val bankDebtLimit = demobank.config.bankDebtLimit - val usersDebtLimit = demobank.config.usersDebtLimit - val allowRegistrations = demobank.config.allowRegistrations - val name = demobank.name // always 'default' - val withSignupBonus = demobank.config.withSignupBonus - val captchaUrl = demobank.config.captchaUrl - val suggestedExchangeBaseUrl = demobank.config.suggestedExchangeBaseUrl - val suggestedExchangePayto = demobank.config.suggestedExchangePayto - }) - ) -} - -fun getHistoryElementFromTransactionRow( - dbRow: BankAccountFreshTransactionEntity -): XLibeufinBankTransaction { - return getHistoryElementFromTransactionRow(dbRow.transactionRef) -} - -/** - * Need to be called within a transaction {} block. It - * is acceptable to pass a bank account's label as the - * parameter, because usernames can only own one bank - * account whose label equals the owner's username. - * - * Future versions may relax this policy to allow one - * customer to own multiple bank accounts. - */ -fun getCustomer(username: String): DemobankCustomerEntity { - return maybeGetCustomer(username) ?: throw notFound("Customer '${username}' not found") -} -fun maybeGetCustomer(username: String): DemobankCustomerEntity? { - return transaction { - DemobankCustomerEntity.find { - DemobankCustomersTable.username eq username - }.firstOrNull() - } -} - -/** - * Get person name from a customer's username, or throw - * exception if not found. - */ -fun getPersonNameFromCustomer(customerUsername: String): String { - return when (customerUsername) { - "admin" -> "Admin" - else -> transaction { - val ownerCustomer = DemobankCustomerEntity.find( - DemobankCustomersTable.username eq customerUsername - ).firstOrNull() ?: run { - logger.error("Customer '${customerUsername}' not found, couldn't get their name.") - throw SandboxError( - HttpStatusCode.InternalServerError, - "'$customerUsername' not a customer." - ) - - } - ownerCustomer.name ?: "Never given." - } - } -} - -fun getDefaultDemobank(): DemobankConfigEntity { - return transaction { - DemobankConfigEntity.find { - DemobankConfigsTable.name eq "default" - }.firstOrNull() - } ?: throw SandboxError( - HttpStatusCode.InternalServerError, - "Default demobank is missing." - ) -} - -fun getWithdrawalOperation(opId: String): TalerWithdrawalEntity { - val uuid = parseUuid(opId) - return transaction { - TalerWithdrawalEntity.find { - TalerWithdrawalsTable.wopid eq uuid - }.firstOrNull() ?: throw SandboxError( - HttpStatusCode.NotFound, "Withdrawal operation $opId not found." - ) - } -} - -fun getBankAccountFromPayto(paytoUri: String): BankAccountEntity { - val paytoParse = parsePayto(paytoUri) - return getBankAccountFromIban(paytoParse.iban) -} - -fun getBankAccountFromIban(iban: String): BankAccountEntity { - return transaction { - BankAccountEntity.find(BankAccountsTable.iban eq iban).firstOrNull() - } ?: throw SandboxError( - HttpStatusCode.NotFound, - "Did not find a bank account for $iban" - ) -} - -/** - * The argument 'withBankFault' represents the case where - * _the bank_ must ensure that a resource (in this case a bank - * account) exists. For example, every 'customer' should have - * a 'bank account', and if a customer is found without a bank - * account, then the bank broke such condition. - */ -fun getBankAccountFromLabel( - label: String, - demobank: String = "default", - withBankFault: Boolean = false -): BankAccountEntity { - val maybeDemobank = getDemobank(demobank) - if (maybeDemobank == null) { - logger.error("Demobank '$demobank' not found") - throw SandboxError( - HttpStatusCode.NotFound, - "Demobank '$demobank' not found" - ) - } - return getBankAccountFromLabel( - label, - maybeDemobank, - withBankFault - ) -} - -// Get bank account DAO, given its name and demobank. -fun getBankAccountFromLabel( - label: String, - demobank: DemobankConfigEntity, - withBankFault: Boolean = false // documented along the other same-named function. -): BankAccountEntity { - val maybeBankAccount = transaction { - BankAccountEntity.find( - BankAccountsTable.label eq label and ( - BankAccountsTable.demoBank eq demobank.id - ) - ).firstOrNull() - } - if (maybeBankAccount == null && withBankFault) - throw internalServerError( - "Bank account $label was not found, but it should." - ) - if (maybeBankAccount == null) - throw notFound( - "Bank account $label was not found." - ) - return maybeBankAccount -} - -fun getBankAccountFromSubscriber(subscriber: EbicsSubscriberEntity): BankAccountEntity { - return transaction { - subscriber.bankAccount ?: throw SandboxError( - HttpStatusCode.NotFound, - "Subscriber doesn't have any bank account" - ) - } -} - -fun BankAccountEntity.bonus(amount: String) { - wireTransfer( - "admin", - this.label, - this.demoBank.name, - "Sign-up bonus", - amount - ) -} - -fun ensureDemobank(call: ApplicationCall): DemobankConfigEntity { - return ensureDemobank(call.expectUriComponent("demobankid")) -} - -fun ensureDemobank(name: String): DemobankConfigEntity { - return transaction { - DemobankConfigEntity.find { - DemobankConfigsTable.name eq name - }.firstOrNull() ?: throw notFound("Demobank '$name' not found. Was it ever created?") - } -} - -fun getDemobank(name: String?): DemobankConfigEntity? { - return transaction { - if (name == null) { - DemobankConfigEntity.all().firstOrNull() - } else { - DemobankConfigEntity.find { - DemobankConfigsTable.name eq name - }.firstOrNull() - } - } -} - -fun getEbicsSubscriberFromDetails(userID: String, partnerID: String, hostID: String): EbicsSubscriberEntity { - return transaction { - EbicsSubscriberEntity.find { - (EbicsSubscribersTable.userId eq userID) and (EbicsSubscribersTable.partnerId eq partnerID) and - (EbicsSubscribersTable.hostId eq hostID) - }.firstOrNull() ?: throw SandboxError( - HttpStatusCode.NotFound, - "Ebics subscriber (${userID}, ${partnerID}, ${hostID}) not found" - ) - } -} - -/** - * Compress, encrypt, encode a EBICS payload. The payload - * is assumed to be a Zip archive with only one entry. - * Return the customer key (second element) along the data. - */ -fun prepareEbicsPayload( - payload: String, pub: RSAPublicKey -): Pair<String, CryptoUtil.EncryptionResult> { - val zipSingleton = mutableListOf(payload.toByteArray()).zip() - val compressedResponse = DeflaterInputStream(zipSingleton.inputStream()).use { - it.readAllBytes() - } - val enc = CryptoUtil.encryptEbicsE002(compressedResponse, pub) - return Pair(Base64.getEncoder().encodeToString(enc.encryptedData), enc) -} diff --git a/bank/src/main/kotlin/tech/libeufin/bank/JSON.kt b/bank/src/main/kotlin/tech/libeufin/bank/JSON.kt @@ -1,154 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2019 Stanisci and Dold. - - * LibEuFin is free software; you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation; either version 3, or - * (at your option) any later version. - - * LibEuFin is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General - * Public License for more details. - - * You should have received a copy of the GNU Affero General Public - * License along with LibEuFin; see the file COPYING. If not, see - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.bank - -import tech.libeufin.util.PaymentInfo - -data class WithdrawalRequest( - /** - * Note: the currency is redundant, because at each point during - * the execution the Demobank should have a handle of the currency. - */ - val amount: String // $CURRENCY:X.Y -) -data class BalanceJson( - val amount: String, - val credit_debit_indicator: String -) -data class Demobank( - val currency: String, - val name: String, - val userDebtLimit: Int, - val bankDebtLimit: Int, - val allowRegistrations: Boolean -) -/** - * Used to show the list of Ebics hosts that exist - * in the system. - */ -data class EbicsHostsResponse( - val ebicsHosts: List<String> -) - -data class EbicsHostCreateRequest( - val hostID: String, - val ebicsVersion: String -) - -/** - * List type that show all the payments existing in the system. - */ -data class AccountTransactions( - val payments: MutableList<PaymentInfo> = mutableListOf() -) - -/** - * Used to create AND show one Ebics subscriber. - */ -data class EbicsSubscriberInfo( - val hostID: String, - val partnerID: String, - val userID: String, - val systemID: String? = null, - val demobankAccountLabel: String -) - -data class AdminGetSubscribers( - var subscribers: MutableList<EbicsSubscriberInfo> = mutableListOf() -) - -/** - * The following definition is obsolete because it - * doesn't allow to specify a demobank that will host - * the Ebics subscriber. */ -data class EbicsSubscriberObsoleteApi( - val hostID: String, - val partnerID: String, - val userID: String, - val systemID: String? = null -) - -/** - * Allows the admin to associate a new bank account - * to a EBICS subscriber. - */ -data class EbicsBankAccountRequest( - val subscriber: EbicsSubscriberObsoleteApi, - val iban: String, - val bic: String, - val name: String, - /** - * This value labels the bank account to be created - * AND its owner. The 'owner' is a bank's customer - * whose username equals this label AND has the rights - * over such bank accounts. - */ - val label: String -) - -data class CustomerRegistration( - val username: String, - val password: String, - val isPublic: Boolean = false, - // When missing, it's autogenerated. - val iban: String?, - // When missing, stays null in the DB. - val name: String? -) - -// Could be used as a general bank account info container. -data class PublicAccountInfo( - val balance: String, - val iban: String, - // Name / Label of the bank account _and_ of the - // Sandbox username that owns it. - val accountLabel: String - // more ..? -) - -data class CamtParams( - // name/label of the bank account to query. - val bankaccount: String, - val type: Int, - // need range parameter -) - -data class TalerWithdrawalStatus( - val selection_done: Boolean, - val transfer_done: Boolean, - val amount: String, - val wire_types: List<String> = listOf("iban"), - val suggested_exchange: String? = null, - val sender_wire: String? = null, - val aborted: Boolean, - // Not needed with CLI wallets. - val confirm_transfer_url: String? -) - -data class TalerWithdrawalSelection( - val reserve_pub: String, - val selected_exchange: String? -) - -data class SandboxConfig( - val currency: String, - val version: String, - val name: String -) -\ 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 @@ -1,575 +1,111 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2019 Stanisci and Dold. - - * LibEuFin is free software; you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation; either version 3, or - * (at your option) any later version. - - * LibEuFin is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General - * Public License for more details. - - * You should have received a copy of the GNU Affero General Public - * License along with LibEuFin; see the file COPYING. If not, see - * <http://www.gnu.org/licenses/> - */ - package tech.libeufin.bank -import UtilError -import com.fasterxml.jackson.core.util.DefaultIndenter -import com.fasterxml.jackson.core.util.DefaultPrettyPrinter -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.SerializationFeature -import com.fasterxml.jackson.module.kotlin.KotlinFeature -import com.fasterxml.jackson.module.kotlin.KotlinModule -import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.core.context -import com.github.ajalt.clikt.core.subcommands -import com.github.ajalt.clikt.output.CliktHelpFormatter -import com.github.ajalt.clikt.parameters.arguments.argument -import com.github.ajalt.clikt.parameters.options.* -import com.github.ajalt.clikt.parameters.types.int -import execThrowableOrTerminate -import io.ktor.server.application.* import io.ktor.http.* import io.ktor.serialization.jackson.* +import io.ktor.server.application.* import io.ktor.server.plugins.* +import io.ktor.server.plugins.requestvalidation.* +import io.ktor.server.plugins.callloging.* import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.plugins.cors.routing.* import io.ktor.server.plugins.statuspages.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* -import io.ktor.server.util.* -import io.ktor.server.plugins.callloging.* -import io.ktor.server.plugins.cors.routing.* -import io.ktor.util.date.* -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.statements.api.ExposedBlob -import org.jetbrains.exposed.sql.transactions.transaction import org.slf4j.Logger import org.slf4j.LoggerFactory import org.slf4j.event.Level -import org.w3c.dom.Document -import startServer import tech.libeufin.util.* -import java.math.BigDecimal -import java.net.URL -import java.security.interfaces.RSAPublicKey -import javax.xml.bind.JAXBContext -import kotlin.system.exitProcess +import javax.xml.bind.ValidationException +// GLOBALS val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank") -const val PROTOCOL_VERSION_UNIFIED = "0:0:0" // Every protocol is still using the same version. -const val SANDBOX_DB_ENV_VAR_NAME = "LIBEUFIN_SANDBOX_DB_CONNECTION" -private val adminPassword: String? = System.getenv("LIBEUFIN_SANDBOX_ADMIN_PASSWORD") -var WITH_AUTH = true // Needed by helpers too, hence not making it private. +val db = Database(System.getProperty("BANK_DB_CONNECTION_STRING")) -// Internal error type. -data class SandboxError( - val statusCode: HttpStatusCode, - val reason: String, - val errorCode: LibeufinErrorCode? = null -) : Exception(reason) - -// HTTP response error type. -data class SandboxErrorJson(val error: SandboxErrorDetailJson) -data class SandboxErrorDetailJson(val type: String, val description: String) - -class DefaultExchange : CliktCommand("Set default Taler exchange for a demobank.") { - init { context { helpFormatter = CliktHelpFormatter(showDefaultValues = true) } } - private val exchangeBaseUrl by argument("EXCHANGE-BASEURL", "base URL of the default exchange") - private val exchangePayto by argument("EXCHANGE-PAYTO", "default exchange's payto-address") - private val demobank by option("--demobank", help = "Which demobank defaults to EXCHANGE").default("default") +// TYPES +data class ChallengeContactData( + val email: String? = null, + val phone: String? = null +) +data class RegisterAccountRequest( + val username: String, + val password: String, + val name: String, + val is_public: Boolean = false, + val is_taler_exchange: Boolean = false, + val challenge_contact_data: ChallengeContactData, + val cashout_payto_uri: String?, + val internal_payto_uri: String? +) - override fun run() { - val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) - execThrowableOrTerminate { - dbCreateTables(dbConnString) - transaction { - val maybeDemobank: DemobankConfigEntity? = DemobankConfigEntity.find { - DemobankConfigsTable.name eq demobank - }.firstOrNull() - if (maybeDemobank == null) { - System.err.println("Error, demobank $demobank not found.") - exitProcess(1) - } - val config = maybeDemobank.config - /** - * Iterating over the config object's field that hold the exchange - * base URL and Payto. The iteration is only used to retrieve the - * correct names of the DB column 'configKey', because this is named - * after such fields. - */ - listOf( - Pair(config::suggestedExchangeBaseUrl, exchangeBaseUrl), - Pair(config::suggestedExchangePayto, exchangePayto) - ).forEach { - val maybeConfigPair = DemobankConfigPairEntity.find { - DemobankConfigPairsTable.demobankName eq demobank and( - DemobankConfigPairsTable.configKey eq it.first.name) - }.firstOrNull() - /** - * The DB doesn't contain any column to hold the exchange URL - * or Payto, fail. That should never happen, because the DB row - * are created _after_ the DemobankConfig object that _does_ contain - * such fields. - */ - if (maybeConfigPair == null) { - System.err.println("Config key '${it.first.name}' for demobank '$demobank' not found in DB.") - exitProcess(1) - } - maybeConfigPair.configValue = it.second - } - } - } - } -} - -class Config : CliktCommand("Insert one configuration (a.k.a. demobank) into the database.") { - init { context { helpFormatter = CliktHelpFormatter(showDefaultValues = true) } } - private val nameArgument by argument( - "NAME", help = "Name of this configuration. Currently, only 'default' is admitted." - ) - private val showOption by option( - "--show", - help = "Only show values, other options will be ignored." - ).flag("--no-show", default = false) - // FIXME: This really should not be a global option! - private val captchaUrlOption by option( - "--captcha-url", help = "Needed for browser wallets." - ).default("https://bank.demo.taler.net/") - private val currencyOption by option("--currency").default("EUR") - private val bankDebtLimitOption by option("--bank-debt-limit").int().default(1000000) - private val usersDebtLimitOption by option("--users-debt-limit").int().default(1000) - private val allowRegistrationsOption by option( - "--with-registrations", - help = "(defaults to allow registrations)" /* mentioning here as help message did not. */ - ).flag("--without-registrations", default = true) - private val withSignupBonusOption by option( - "--with-signup-bonus", - help = "Award new customers with 100 units of currency! (defaults to NO bonus)" - ).flag("--without-signup-bonus", default = false) - - override fun run() { - val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) - if (nameArgument != "default") { - System.err.println("This version admits only the 'default' name") - exitProcess(1) - } - execThrowableOrTerminate { - dbCreateTables(dbConnString) - val maybeDemobank = transaction { getDemobank(nameArgument) } - if (showOption) { - if (maybeDemobank != null) { - printConfig(maybeDemobank) - } else { - println("Demobank: $nameArgument not found.") - System.exit(1) - } - return@execThrowableOrTerminate - } - if (bankDebtLimitOption < 0 || usersDebtLimitOption < 0) { - System.err.println("Debt numbers can't be negative.") - exitProcess(1) - } - /* - Warning if the CAPTCHA URL does not include the {wopid} placeholder. - Not a reason to fail because the bank may be run WITHOUT providing Taler. - */ - if (!hasWopidPlaceholder(captchaUrlOption)) - logger.warn("CAPTCHA URL doesn't have the WOPID placeholder." + - " Taler withdrawals decrease usability") - - // The user asks to _set_ values, regardless of overriding or creating. - val config = DemobankConfig( - currency = currencyOption, - bankDebtLimit = bankDebtLimitOption, - usersDebtLimit = usersDebtLimitOption, - allowRegistrations = allowRegistrationsOption, - demobankName = nameArgument, - withSignupBonus = withSignupBonusOption, - captchaUrl = captchaUrlOption - ) - /** - * The demobank didn't exist. Now: - * 1, Store the config values in the database. - * 2, Store the demobank name in the database. - * 3, Create the admin bank account under this demobank. - */ - if (maybeDemobank == null) { - transaction { - insertConfigPairs(config) - val demoBank = DemobankConfigEntity.new { this.name = nameArgument } - BankAccountEntity.new { - iban = getIban() - label = "admin" - owner = "admin" // Not backed by an actual customer object. - // For now, the model assumes always one demobank - this.demoBank = demoBank - } - } - } - // Demobank exists: update its config values in the database. - else transaction { insertConfigPairs(config, override = true) } - } - } +// Generates a new Payto-URI with IBAN scheme. +fun genIbanPaytoUri(): String = "payto://iban/SANDBOXX/${getIban()}" +fun parseTalerAmount(amount: String): TalerAmount { + val amountWithCurrencyRe = "^([A-Z]+):([0-9]+(\\.[0-9][0-9]?)?)$" + val match = Regex(amountWithCurrencyRe).find(amount) ?: + throw badRequest("Invalid amount") + val value = match.destructured.component2() + val fraction = match.destructured.component3().substring(1) + return TalerAmount(value.toLong(), fraction.toInt()) } /** - * This command generates Camt53 statements - for all the bank accounts - - * every time it gets run. The statements are only stored into the database. - * The user should then query either via Ebics or via the JSON interface, - * in order to retrieve their statements. + * Performs the HTTP basic authentication. Returns the + * authenticated customer on success, or null otherwise. */ -class Camt053Tick : CliktCommand( - "Make a new Camt.053 time tick; all the fresh transactions" + - " will be inserted in a new Camt.053 report" -) { - override fun run() { - val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) - execThrowableOrTerminate { dbCreateTables(dbConnString) } - val newStatements = mutableMapOf<String, MutableList<XLibeufinBankTransaction>>() - /** - * For each bank account, extract the latest statement and - * include all the later transactions in a new statement. - * Build empty statement, if the account does not have any - * transaction yet. - */ - transaction { - BankAccountEntity.all().forEach { accountIter -> - // Give this account a entry in the final output. - newStatements.putIfAbsent(accountIter.label, mutableListOf()) - val lastStatement = BankAccountStatementEntity.find { - BankAccountStatementsTable.bankAccount eq accountIter.id.value - }.lastOrNull() - val lastStatementTime = lastStatement?.creationTime ?: 0L - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.date.greater(lastStatementTime) and( - BankAccountTransactionsTable.account eq accountIter.id.value - ) - }.forEach { - newStatements[accountIter.label]?.add( - getHistoryElementFromTransactionRow(it) - ) ?: run { - logger.error("Array operation failed while building statements for account: ${accountIter.label}") - System.err.println("Fatal array error while building the statement, please report.") - exitProcess(1) - } - } - /** - * Resorting the closing (CLBD) balance of the last statement; will - * become the PRCD balance of the _new_ one. - */ - val camtData = buildCamtString( - 53, - accountIter.iban, - newStatements[accountIter.label]!!, - currency = accountIter.demoBank.config.currency - ) - BankAccountStatementEntity.new { - statementId = camtData.messageId - creationTime = getSystemTimeNow().toInstant().epochSecond - xmlMessage = camtData.camtMessage - bankAccount = accountIter - } - } - BankAccountFreshTransactionsTable.deleteAll() - } - } -} - -class MakeTransaction : CliktCommand("Wire-transfer money between Sandbox bank accounts") { - init { - context { helpFormatter = CliktHelpFormatter(showDefaultValues = true) } - } - private val creditAccount by option(help = "Label of the bank account receiving the payment").required() - private val debitAccount by option(help = "Label of the bank account issuing the payment").required() - private val demobankArg by option("--demobank", help = "Which Demobank books this transaction").default("default") - private val amount by argument("AMOUNT", "Amount, in the CUR:X.Y format") - private val subjectArg by argument("SUBJECT", "Payment's subject") - - override fun run() { +fun doBasicAuth(encodedCredentials: String): Customer? { + val plainUserAndPass = String(base64ToBytes(encodedCredentials), Charsets.UTF_8) // :-separated + val userAndPassSplit = plainUserAndPass.split( + ":", /** - * Merely connecting here (and NOT creating any table) because this - * command should only be run after actual bank accounts exist in the - * system, meaning therefore that the database got already set up. + * this parameter allows colons to occur in passwords. + * Without this, passwords that have colons would be split + * and become meaningless. */ - execThrowableOrTerminate { - val pgConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) - connectWithSchema(getJdbcConnectionFromPg(pgConnString)) - } - // Refuse to operate without a default demobank. - val demobank = getDemobank("default") - if (demobank == null) { - System.err.println("Sandbox cannot operate without a 'default' demobank.") - System.err.println("Please make one with the 'libeufin-sandbox config' command.") - exitProcess(1) - } - try { - wireTransfer(debitAccount, creditAccount, demobankArg, subjectArg, amount) - } catch (e: SandboxError) { - System.err.println(e.message) - exitProcess(1) - } catch (e: Exception) { - System.err.println(e.message) - exitProcess(1) - } - } -} - -class ResetTables : CliktCommand("Drop all the tables from the database") { - init { - context { - helpFormatter = CliktHelpFormatter(showDefaultValues = true) - } - } - override fun run() { - val dbConnString = getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME) - execThrowableOrTerminate { - dbDropTables(dbConnString) - dbCreateTables(dbConnString) - } - } -} - -class Serve : CliktCommand("Run sandbox HTTP server") { - init { - context { - helpFormatter = CliktHelpFormatter(showDefaultValues = true) - } - } - private val auth by option( - "--auth", - help = "Disable authentication." - ).flag("--no-auth", default = true) - private val localhostOnly by option( - "--localhost-only", - help = "Bind only to localhost. On all interfaces otherwise" - ).flag("--no-localhost-only", default = true) - private val ipv4Only by option( - "--ipv4-only", - help = "Bind only to ipv4" - ).flag(default = false) - private val logLevel by option( - help = "Set the log level to: 'off', 'error', 'warn', 'info', 'debug', 'trace', 'all'" - ) - private val port by option().int().default(5000) - private val withUnixSocket by option( - help = "Bind the Sandbox to the Unix domain socket at PATH. Overrides" + - " --port, when both are given", metavar = "PATH" + limit = 2 ) - private val smsTan by option(help = "Command to send the TAN via SMS." + - " The command gets the TAN via STDIN and the phone number" + - " as its first parameter" - ) - private val emailTan by option(help = "Command to send the TAN via e-mail." + - " The command gets the TAN via STDIN and the e-mail address as its" + - " first parameter.") - override fun run() { - WITH_AUTH = auth - setLogLevel(logLevel) - if (WITH_AUTH && adminPassword == null) { - System.err.println( - "Error: auth is enabled, but env " + - "LIBEUFIN_SANDBOX_ADMIN_PASSWORD is not." - + " (Option --no-auth exists for tests)" - ) - exitProcess(1) - } - execThrowableOrTerminate { - dbCreateTables(getDbConnFromEnv(SANDBOX_DB_ENV_VAR_NAME)) - } - // Refuse to operate without a 'default' demobank. - val demobank = getDemobank("default") - if (demobank == null) { - System.err.println("Sandbox cannot operate without a 'default' demobank.") - System.err.println("Please make one with the 'libeufin-sandbox config' command.") - exitProcess(1) - } - if (withUnixSocket != null) { - startServer( - withUnixSocket!!, - app = sandboxApp - ) - exitProcess(0) - } - SMS_TAN_CMD = smsTan - EMAIL_TAN_CMD = emailTan - - logger.info("Starting Sandbox on port ${this.port}") - startServerWithIPv4Fallback( - options = StartServerOptions( - ipv4OnlyOpt = this.ipv4Only, - localhostOnlyOpt = this.localhostOnly, - portOpt = this.port - ), - app = sandboxApp - ) - } + if (userAndPassSplit.size != 2) throw badRequest("Malformed Basic auth credentials found in the Authorization header.") + val login = userAndPassSplit[0] + val plainPassword = userAndPassSplit[1] + return db.customerPwAuth(login, CryptoUtil.hashpw(plainPassword)) +} + +/* Performs the bearer-token authentication. Returns the + * authenticated customer on success, null otherwise. */ +fun doTokenAuth( + token: String, + requiredScope: TokenScope, // readonly or readwrite +): Customer? { + val maybeToken: BearerToken = db.bearerTokenGet(token.toByteArray(Charsets.UTF_8)) ?: return null + val isExpired: Boolean = maybeToken.expirationTime - getNow().toMicro() < 0 + if (isExpired || maybeToken.scope != requiredScope) return null // FIXME: mention the reason? + // Getting the related username. + return db.customerGetFromRowId(maybeToken.bankCustomer) + ?: throw internalServerError("Customer not found, despite token mentions it.") } -private fun getJsonFromDemobankConfig(fromDb: DemobankConfigEntity): Demobank { - return Demobank( - currency = fromDb.config.currency, - userDebtLimit = fromDb.config.usersDebtLimit, - bankDebtLimit = fromDb.config.bankDebtLimit, - allowRegistrations = fromDb.config.allowRegistrations, - name = fromDb.name - ) -} -fun findEbicsSubscriber(partnerID: String, userID: String, systemID: String?): EbicsSubscriberEntity? { - return if (systemID == null) { - EbicsSubscriberEntity.find { - (EbicsSubscribersTable.partnerId eq partnerID) and (EbicsSubscribersTable.userId eq userID) - } - } else { - EbicsSubscriberEntity.find { - (EbicsSubscribersTable.partnerId eq partnerID) and - (EbicsSubscribersTable.userId eq userID) and - (EbicsSubscribersTable.systemId eq systemID) - } - }.firstOrNull() -} - -data class SubscriberKeys( - val authenticationPublicKey: RSAPublicKey, - val encryptionPublicKey: RSAPublicKey, - val signaturePublicKey: RSAPublicKey -) - -data class EbicsHostPublicInfo( - val hostID: String, - val encryptionPublicKey: RSAPublicKey, - val authenticationPublicKey: RSAPublicKey -) - -data class BankAccountInfo( - val label: String, - val name: String, - val iban: String, - val bic: String, -) - -inline fun <reified T> Document.toObject(): T { - val jc = JAXBContext.newInstance(T::class.java) - val m = jc.createUnmarshaller() - return m.unmarshal(this, T::class.java).value -} - -fun ensureNonNull(param: String?): String { - return param ?: throw SandboxError( - HttpStatusCode.BadRequest, "Bad ID given: $param" - ) -} - -class SandboxCommand : CliktCommand(invokeWithoutSubcommand = true, printHelpOnEmptyArgs = true) { - init { versionOption(getVersion()) } - override fun run() = Unit -} - -fun main(args: Array<String>) { - SandboxCommand().subcommands( - Serve(), - ResetTables(), - Config(), - MakeTransaction(), - Camt053Tick(), - DefaultExchange() - ).main(args) -} - -fun setJsonHandler(ctx: ObjectMapper) { - ctx.enable(SerializationFeature.INDENT_OUTPUT) - ctx.setDefaultPrettyPrinter(DefaultPrettyPrinter().apply { - indentArraysWith(DefaultPrettyPrinter.FixedSpaceIndenter.instance) - indentObjectsWith(DefaultIndenter(" ", "\n")) - }) - ctx.registerModule( - KotlinModule.Builder() - .withReflectionCacheSize(512) - .configure(KotlinFeature.NullToEmptyCollection, false) - .configure(KotlinFeature.NullToEmptyMap, false) - .configure(KotlinFeature.NullIsSameAsDefault, enabled = true) - .configure(KotlinFeature.SingletonSupport, enabled = false) - .configure(KotlinFeature.StrictNullChecks, false) - .build() - ) -} - -private suspend fun getWithdrawal(call: ApplicationCall) { - val op = getWithdrawalOperation(call.expectUriComponent("withdrawal_id")) - if (!op.selectionDone && op.reservePub != null) throw internalServerError( - "Unselected withdrawal has a reserve public key", - LibeufinErrorCode.LIBEUFIN_EC_INCONSISTENT_STATE - ) - call.respond(object { - val amount = op.amount - val aborted = op.aborted - val confirmation_done = op.confirmationDone - val selection_done = op.selectionDone - val selected_reserve_pub = op.reservePub - val selected_exchange_account = op.selectedExchangePayto - }) -} - -private suspend fun confirmWithdrawal(call: ApplicationCall) { - val withdrawalId = call.expectUriComponent("withdrawal_id") - logger.debug("Maybe confirming withdrawal: $withdrawalId") - transaction { - val wo = getWithdrawalOperation(withdrawalId) - if (wo.aborted) throw SandboxError( - HttpStatusCode.Conflict, - "Cannot confirm an aborted withdrawal." - ) - if (!wo.selectionDone) throw SandboxError( - HttpStatusCode.UnprocessableEntity, - "Cannot confirm a unselected withdrawal: " + - "specify exchange and reserve public key via Integration API first." - ) - /** - * The wallet chose not to select any exchange, use the default. - */ - val demobank = ensureDemobank(call) - if (wo.selectedExchangePayto == null) { - wo.selectedExchangePayto = demobank.config.suggestedExchangePayto - } - val exchangeBankAccount = getBankAccountFromPayto( - wo.selectedExchangePayto ?: throw internalServerError( - "Cannot withdraw without an exchange." - ) - ) - logger.debug("Withdrawal ${wo.wopid} confirmed? ${wo.confirmationDone}") - if (!wo.confirmationDone) { - wireTransfer( - debitAccount = wo.walletBankAccount.label, - creditAccount = exchangeBankAccount.label, - amount = wo.amount, - subject = wo.reservePub ?: throw internalServerError( - "Cannot transfer funds without reserve public key." - ), - // provide the currency. - demobank = ensureDemobank(call).name - ) - wo.confirmationDone = true - } - wo.confirmationDone +/** + * This function tries to authenticate the call according + * to the scheme that is mentioned in the Authorization header. + * The allowed schemes are either 'HTTP basic auth' or 'bearer token'. + * + * requiredScope can be either "readonly" or "readwrite". + * + * Returns the authenticated customer, or null if they failed. + */ +fun ApplicationCall.myAuth(requiredScope: TokenScope): Customer? { + // Extracting the Authorization header. + val header = getAuthorizationRawHeader(this.request) + val authDetails = getAuthorizationDetails(header) + return when (authDetails.scheme) { + "Basic" -> doBasicAuth(authDetails.content) + "Bearer" -> doTokenAuth(authDetails.content, requiredScope) + else -> throw badRequest("Authorization scheme '${authDetails.scheme}' is not supported.") } - call.respond(object {}) } -private suspend fun abortWithdrawal(call: ApplicationCall) { - val withdrawalId = call.expectUriComponent("withdrawal_id") - val operation = getWithdrawalOperation(withdrawalId) - if (operation.confirmationDone) throw conflict("Cannot abort paid withdrawal.") - transaction { operation.aborted = true } - call.respond(object {}) -} -val sandboxApp: Application.() -> Unit = { +val webApp: Application.() -> Unit = { install(CallLogging) { this.level = Level.DEBUG this.logger = tech.libeufin.bank.logger @@ -587,1125 +123,72 @@ val sandboxApp: Application.() -> Unit = { allowCredentials = true } install(IgnoreTrailingSlash) - install(ContentNegotiation) { - register(ContentType.Text.Xml, XMLEbicsConverter()) - /** - * Content type "text" must go to the XML parser - * because Nexus can't set explicitly the Content-Type - * (see https://github.com/ktorio/ktor/issues/1127) to - * "xml" and the request made gets somehow assigned the - * "text/plain" type: */ - register(ContentType.Text.Plain, XMLEbicsConverter()) - jackson(contentType = ContentType.Application.Json) { setJsonHandler(this) } - /** - * Make jackson the default parser. It runs also when - * the Content-Type request header is missing. */ - jackson(contentType = ContentType.Any) { setJsonHandler(this) } - } - install(StatusPages) { - // Bank's fault: it should check the operands. Respond 500 - exception<ArithmeticException> { call, cause -> - logger.error("Exception while handling '${call.request.uri}', ${cause.stackTraceToString()}") - call.respond( - HttpStatusCode.InternalServerError, - SandboxErrorJson( - error = SandboxErrorDetailJson( - type = "sandbox-error", - description = cause.message ?: "Bank's error: arithmetic exception." - ) - ) - ) - } - // Not necessarily the bank's fault. - exception<SandboxError> { call, cause -> - logger.error("Exception while handling '${call.request.uri}', ${cause.reason}") - call.respond( - cause.statusCode, - SandboxErrorJson( - error = SandboxErrorDetailJson( - type = "sandbox-error", - description = cause.reason - ) - ) - ) - } - // Not necessarily the bank's fault. - exception<UtilError> { call, cause -> - logger.error("Exception while handling '${call.request.uri}', ${cause.reason}") - call.respond( - cause.statusCode, - SandboxErrorJson( - error = SandboxErrorDetailJson( - type = "util-error", - description = cause.reason - ) - ) - ) - } - /** - * Happens when a request fails to parse. This branch triggers - * only when a JSON request fails. XML problems are caught within - * the /ebicsweb handler and always ultimately rethrown as "EbicsRequestError", - * hence they do not reach this branch. - */ - exception<BadRequestException> { call, wrapper -> - var rootCause = wrapper.cause - while (rootCause?.cause != null) rootCause = rootCause.cause - val errorMessage: String? = rootCause?.message ?: wrapper.message - if (errorMessage == null) { - logger.error("The bank didn't detect the cause of a bad request, fail.") - logger.error(wrapper.stackTraceToString()) - throw SandboxError( - HttpStatusCode.InternalServerError, - "Did not find bad request details." - ) - } - logger.error(errorMessage) - call.respond( - HttpStatusCode.BadRequest, - SandboxErrorJson( - error = SandboxErrorDetailJson( - type = "sandbox-error", - description = errorMessage - ) - ) - ) - } - // Catch-all error, respond 500 because the bank didn't handle it. - exception<Throwable> { call, cause -> - logger.error("Unhandled exception while handling '${call.request.uri}'\n${cause.stackTraceToString()}") - call.respond( - HttpStatusCode.InternalServerError, - SandboxErrorJson( - error = SandboxErrorDetailJson( - type = "sandbox-error", - description = cause.message ?: "Bank's error: unhandled exception." - ) - ) - ) - } - exception<EbicsRequestError> { call, cause -> - logger.error("Handling EbicsRequestError: ${cause.message}") - respondEbicsTransfer(call, cause.errorText, cause.errorCode) - } - } - intercept(ApplicationCallPipeline.Setup) { - val ac: ApplicationCall = call - ac.attributes.put(WITH_AUTH_ATTRIBUTE_KEY, WITH_AUTH) - if (WITH_AUTH) { - if(adminPassword == null) { - throw internalServerError( - "Sandbox has no admin password defined." + - " Please define LIBEUFIN_SANDBOX_ADMIN_PASSWORD in the environment, " + - "or launch with --no-auth." - - ) - } - ac.attributes.put(ADMIN_PASSWORD_ATTRIBUTE_KEY, adminPassword) - } - return@intercept - } - intercept(ApplicationCallPipeline.Fallback) { - if (this.call.response.status() == null) { - call.respondText( - "Not found (no route matched).\n", - io.ktor.http.ContentType.Text.Plain, - io.ktor.http.HttpStatusCode.NotFound - ) - return@intercept finish() - } - } + install(ContentNegotiation) { jackson {} } routing { - get("/") { - call.respondText( - "Hello, this is the Sandbox\n", - ContentType.Text.Plain - ) - } - // Respond with the last statement of the requesting account. - // Query details in the body. - post("/admin/payments/camt") { - val username = call.request.basicAuth() - val body = call.receive<CamtParams>() - if (body.type != 53) throw SandboxError( - HttpStatusCode.NotFound, - "Only Camt.053 documents can be generated." - ) - if (!allowOwnerOrAdmin(username, body.bankaccount)) - throw unauthorized("User '${username}' has no rights over" + - " bank account '${body.bankaccount}'") - val camtMessage = transaction { - val bankaccount = getBankAccountFromLabel( - body.bankaccount, - getDefaultDemobank() - ) - BankAccountStatementEntity.find { - BankAccountStatementsTable.bankAccount eq bankaccount.id - }.lastOrNull()?.xmlMessage ?: throw SandboxError( - HttpStatusCode.NotFound, - "Could not find any statements; please wait next tick" - ) - } - call.respondText( - camtMessage, ContentType.Text.Xml, HttpStatusCode.OK - ) + post("/accounts") { + // check if only admin. + val maybeOnlyAdmin = db.configGet("only_admin_registrations") + if (maybeOnlyAdmin?.lowercase() == "yes") { + val customer: Customer? = call.myAuth(TokenScope.readwrite) + if (customer == null || customer.login != "admin") + // OK to leak the only-admin policy here? + throw forbidden("Only admin allowed, and it failed to authenticate.") + } + // auth passed, proceed with activity. + val req = call.receive<RegisterAccountRequest>() + // Prohibit reserved usernames: + if (req.username == "admin" || req.username == "bank") + throw conflict("Username '${req.username}' is reserved.") + // Checking imdepotency. + val maybeCustomerExists = db.customerGetFromLogin(req.username) + if (maybeCustomerExists != null) { + val bankingInfo = db.bankAccountGetFromOwnerId(maybeCustomerExists.expectRowId()) + ?: throw internalServerError("Existing customer had no bank account!") + // Checking _all_ the details are the same. + val isIdentic = + maybeCustomerExists.name == req.name && + maybeCustomerExists.email == req.challenge_contact_data.email && + maybeCustomerExists.phone == req.challenge_contact_data.phone && + maybeCustomerExists.cashoutPayto == req.cashout_payto_uri && + maybeCustomerExists.passwordHash == CryptoUtil.hashpw(req.password) && + bankingInfo.isPublic == req.is_public && + bankingInfo.isTalerExchange == req.is_taler_exchange && + bankingInfo.internalPaytoUri == req.internal_payto_uri + if (isIdentic) call.respond(HttpStatusCode.Created) + call.respond(HttpStatusCode.Conflict) + } + // From here: fresh user being added. + val newCustomer = Customer( + login = req.username, + name = req.name, + email = req.challenge_contact_data.email, + phone = req.challenge_contact_data.phone, + cashoutPayto = req.cashout_payto_uri, + // Following could be gone, if included in cashout_payto + cashoutCurrency = db.configGet("cashout_currency"), + passwordHash = CryptoUtil.hashpw(req.password) + ) + val newCustomerRowId = db.customerCreate(newCustomer) + ?: throw internalServerError("New customer INSERT failed despite the previous checks") + /* Crashing here won't break data consistency between customers + * and bank accounts, because of the idempotency. Client will + * just have to retry. */ + val maxDebt = db.configGet("max_debt_ordinary_customers").run { + if (this == null) throw internalServerError("Max debt not configured") + parseTalerAmount(this) + } + val newBankAccount = BankAccount( + hasDebt = false, + internalPaytoUri = req.internal_payto_uri ?: genIbanPaytoUri(), + owningCustomerId = newCustomerRowId, + isPublic = req.is_public, + isTalerExchange = req.is_taler_exchange, + maxDebt = maxDebt + ) + if (!db.bankAccountCreate(newBankAccount)) + throw internalServerError("Could not INSERT bank account despite all the checks.") + call.respond(HttpStatusCode.Created) return@post } - - /** - * Create a new bank account, no EBICS relation. Okay - * to let a user, since having a particular username allocates - * already a bank account with such label. - */ - post("/admin/bank-accounts/{label}") { - val username = call.request.basicAuth() - val body = call.receive<BankAccountInfo>() - if (!allowOwnerOrAdmin(username, body.label)) - throw unauthorized("User '$username' has no rights over" + - " bank account '${body.label}'" - ) - if (body.label == "admin" || body.label == "bank") throw forbidden( - "Requested bank account label '${body.label}' not allowed." - ) - transaction { - val maybeBankAccount = BankAccountEntity.find { - BankAccountsTable.label eq body.label - }.firstOrNull() - if (maybeBankAccount != null) - throw conflict("Bank account '${body.label}' exist already") - // owner username == bank account label - val maybeCustomer = DemobankCustomerEntity.find { - DemobankCustomersTable.username eq body.label - }.firstOrNull() - if (maybeCustomer == null) - throw notFound("Customer '${body.label}' not found," + - " cannot own any bank account.") - BankAccountEntity.new { - iban = body.iban - bic = body.bic - label = body.label - owner = body.label - demoBank = getDefaultDemobank() - } - } - call.respond(object {}) - return@post - } - - // Information about one bank account. - get("/admin/bank-accounts/{label}") { - val username = call.request.basicAuth() - val label = call.expectUriComponent("label") - val ret = transaction { - val demobank = getDefaultDemobank() - val bankAccount = getBankAccountFromLabel(label, demobank) - if (!allowOwnerOrAdmin(username, label)) - throw unauthorized("'${username}' has no rights over '$label'") - val balance = getBalance(bankAccount) - object { - val balance = "${bankAccount.demoBank.config.currency}:${balance}" - val iban = bankAccount.iban - val bic = bankAccount.bic - val label = bankAccount.label - } - } - call.respond(ret) - return@get - } - - // Book one incoming payment for the requesting account. - // The debtor is not required to have a customer account at this Sandbox. - post("/admin/bank-accounts/{label}/simulate-incoming-transaction") { - call.request.basicAuth(onlyAdmin = true) - val body = call.receive<IncomingPaymentInfo>() - val accountLabel = ensureNonNull(call.parameters["label"]) - val reqDebtorBic = body.debtorBic - if (reqDebtorBic != null && !validateBic(reqDebtorBic)) { - throw SandboxError( - HttpStatusCode.BadRequest, - "invalid BIC" - ) - } - val amount = parseAmount(body.amount) - transaction { - val demobank = getDefaultDemobank() - val account = getBankAccountFromLabel( - accountLabel, demobank - ) - val randId = getRandomString(16) - val customer = getCustomer(accountLabel) - BankAccountTransactionEntity.new { - creditorIban = account.iban - creditorBic = account.bic - creditorName = customer.name ?: "Name not given." - debtorIban = body.debtorIban - debtorBic = reqDebtorBic - debtorName = body.debtorName - subject = body.subject - this.amount = amount.amount - date = getSystemTimeNow().toInstant().toEpochMilli() - accountServicerReference = "sandbox-$randId" - this.account = account - direction = "CRDT" - this.demobank = demobank - this.currency = demobank.config.currency - } - } - call.respond(object {}) - } - // Associates a new bank account with an existing Ebics subscriber. - post("/admin/ebics/bank-accounts") { - call.request.basicAuth(onlyAdmin = true) - val body = call.receive<EbicsBankAccountRequest>() - val subscriber = getEbicsSubscriberFromDetails( - body.subscriber.userID, - body.subscriber.partnerID, - body.subscriber.hostID - ) - val res = insertNewAccount( - username = body.label, - /** - * This value makes only happy the account creator helper. - * Logic using this OBSOLETE HTTP handler would NOT expect - * to use this password anyway. The reason is that such obsolete - * tests access their banking data always through the EBICS - * subscriber, needing therefore no HTTP basic password to operate. - */ - password = "not-used", - iban = body.iban - ) - transaction { subscriber.bankAccount = res.bankAccount } - call.respond({}) - return@post - } - - // Information about all the default demobank's bank accounts - get("/admin/bank-accounts") { - call.request.basicAuth(onlyAdmin = true) - val accounts = mutableListOf<BankAccountInfo>() - transaction { - val demobank = getDefaultDemobank() - // Finds all the accounts of this demobank. - BankAccountEntity.find { BankAccountsTable.demoBank eq demobank.id }.forEach { - accounts.add( - BankAccountInfo( - label = it.label, - bic = it.bic, - iban = it.iban, - name = "Bank account owner's name" - ) - ) - } - } - call.respond(accounts) - } - - // Details of all the transactions of one bank account. - get("/admin/bank-accounts/{label}/transactions") { - val username = call.request.basicAuth() - val ret = AccountTransactions() - val accountLabel = ensureNonNull(call.parameters["label"]) - if (!allowOwnerOrAdmin(username, accountLabel)) - throw unauthorized("Requesting user '${username}'" + - " has no rights over bank account '${accountLabel}'" - ) - transaction { - val demobank = getDefaultDemobank() - val account = getBankAccountFromLabel(accountLabel, demobank) - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.account eq account.id - }.forEach { - ret.payments.add( - PaymentInfo( - accountLabel = account.label, - creditorIban = it.creditorIban, - accountServicerReference = it.accountServicerReference, - paymentInformationId = it.pmtInfId, - debtorIban = it.debtorIban, - subject = it.subject, - date = GMTDate(it.date).toHttpDate(), - amount = it.amount, - creditorBic = it.creditorBic, - creditorName = it.creditorName, - debtorBic = it.debtorBic, - debtorName = it.debtorName, - currency = it.currency, - creditDebitIndicator = when (it.direction) { - "CRDT" -> "credit" - "DBIT" -> "debit" - else -> throw Error("invalid direction") - } - ) - ) - } - } - call.respond(ret) - } - /** - * Generate one incoming and one outgoing transactions for - * one bank account. Counterparts do not need to have an account - * at this Sandbox. - */ - post("/admin/bank-accounts/{label}/generate-transactions") { - call.request.basicAuth(onlyAdmin = true) - transaction { - val accountLabel = ensureNonNull(call.parameters["label"]) - val demobank = getDefaultDemobank() - val account = getBankAccountFromLabel(accountLabel, demobank) - val transactionReferenceCrdt = getRandomString(8) - val transactionReferenceDbit = getRandomString(8) - - run { - val amount = kotlin.random.Random.nextLong(5, 25) - BankAccountTransactionEntity.new { - creditorIban = account.iban - creditorBic = account.bic - creditorName = "Creditor Name" - debtorIban = "DE64500105178797276788" - debtorBic = "DEUTDEBB101" - debtorName = "Max Mustermann" - subject = "sample transaction $transactionReferenceCrdt" - this.amount = amount.toString() - date = getSystemTimeNow().toInstant().toEpochMilli() - accountServicerReference = transactionReferenceCrdt - this.account = account - direction = "CRDT" - this.demobank = demobank - currency = demobank.config.currency - } - } - - run { - val amount = kotlin.random.Random.nextLong(5, 25) - - BankAccountTransactionEntity.new { - debtorIban = account.iban - debtorBic = account.bic - debtorName = "Debitor Name" - creditorIban = "DE64500105178797276788" - creditorBic = "DEUTDEBB101" - creditorName = "Max Mustermann" - subject = "sample transaction $transactionReferenceDbit" - this.amount = amount.toString() - date = getSystemTimeNow().toInstant().toEpochMilli() - accountServicerReference = transactionReferenceDbit - this.account = account - direction = "DBIT" - this.demobank = demobank - currency = demobank.config.currency - } - } - } - call.respond(object {}) - } - - /** - * Create a new EBICS subscriber without associating - * a bank account to it. Currently every registered - * user is allowed to call this. - */ - post("/admin/ebics/subscribers") { - call.request.basicAuth(onlyAdmin = true) - val body = call.receive<EbicsSubscriberObsoleteApi>() - transaction { - // Check the host ID exists. - EbicsHostEntity.find { - EbicsHostsTable.hostID eq body.hostID - }.firstOrNull() ?: throw notFound("Host ID ${body.hostID} not found.") - // Check it exists first. - val maybeSubscriber = EbicsSubscriberEntity.find { - EbicsSubscribersTable.userId eq body.userID and ( - EbicsSubscribersTable.partnerId eq body.partnerID - ) and (EbicsSubscribersTable.systemId eq body.systemID) and - (EbicsSubscribersTable.hostId eq body.hostID) - }.firstOrNull() - if (maybeSubscriber != null) throw conflict("EBICS subscriber exists already") - EbicsSubscriberEntity.new { - partnerId = body.partnerID - userId = body.userID - systemId = null - hostId = body.hostID - state = SubscriberState.NEW - nextOrderID = 1 - } - } - call.respondText( - "Subscriber created.", - ContentType.Text.Plain, HttpStatusCode.OK - ) - return@post - } - - // Shows details of all the EBICS subscribers of this Sandbox. - get("/admin/ebics/subscribers") { - call.request.basicAuth(onlyAdmin = true) - val ret = AdminGetSubscribers() - transaction { - EbicsSubscriberEntity.all().forEach { - ret.subscribers.add( - EbicsSubscriberInfo( - userID = it.userId, - partnerID = it.partnerId, - hostID = it.hostId, - demobankAccountLabel = it.bankAccount?.label ?: "not associated yet" - ) - ) - } - } - call.respond(ret) - return@get - } - - // Change keys used in the EBICS communications. - post("/admin/ebics/hosts/{hostID}/rotate-keys") { - call.request.basicAuth(onlyAdmin = true) - val hostID: String = call.parameters["hostID"] ?: throw SandboxError( - io.ktor.http.HttpStatusCode.BadRequest, "host ID missing in URL" - ) - transaction { - val host = EbicsHostEntity.find { - EbicsHostsTable.hostID eq hostID - }.firstOrNull() ?: throw SandboxError( - HttpStatusCode.NotFound, "Host $hostID not found" - ) - val pairA = CryptoUtil.generateRsaKeyPair(2048) - val pairB = CryptoUtil.generateRsaKeyPair(2048) - val pairC = CryptoUtil.generateRsaKeyPair(2048) - host.authenticationPrivateKey = ExposedBlob(pairA.private.encoded) - host.encryptionPrivateKey = ExposedBlob(pairB.private.encoded) - host.signaturePrivateKey = ExposedBlob(pairC.private.encoded) - } - call.respondText( - "Keys of '${hostID}' rotated.", - ContentType.Text.Plain, - HttpStatusCode.OK - ) - return@post - } - - // Create a new EBICS host - post("/admin/ebics/hosts") { - call.request.basicAuth(onlyAdmin = true) - val req = call.receive<EbicsHostCreateRequest>() - val pairA = CryptoUtil.generateRsaKeyPair(2048) - val pairB = CryptoUtil.generateRsaKeyPair(2048) - val pairC = CryptoUtil.generateRsaKeyPair(2048) - transaction { - val maybeHost = EbicsHostEntity.find { - EbicsHostsTable.hostID eq req.hostID - }.firstOrNull() - if (maybeHost != null) { - logger.info("EBICS host '${req.hostID}' exists already, this request conflicts.") - throw conflict("EBICS host '${req.hostID}' exists already") - } - EbicsHostEntity.new { - this.ebicsVersion = req.ebicsVersion - this.hostId = req.hostID - this.authenticationPrivateKey = ExposedBlob(pairA.private.encoded) - this.encryptionPrivateKey = ExposedBlob(pairB.private.encoded) - this.signaturePrivateKey = ExposedBlob(pairC.private.encoded) - } - } - call.respondText( - "Host '${req.hostID}' created.", - ContentType.Text.Plain, - HttpStatusCode.OK - ) - return@post - } - - // Show the names of all the Ebics hosts - get("/admin/ebics/hosts") { - call.request.basicAuth(onlyAdmin = true) - val ebicsHosts = transaction { - EbicsHostEntity.all().map { it.hostId } - } - call.respond(EbicsHostsResponse(ebicsHosts)) - } - // Process one EBICS request - post("/ebicsweb") { - try { call.ebicsweb() } - /** - * The catch blocks try to extract a EBICS error message from the - * exception type being handled. NOT logging under each catch block - * as ultimately the registered exception handler is expected to log. */ - catch (e: UtilError) { - throw EbicsProcessingError("Serving EBICS threw unmanaged UtilError: ${e.reason}") - } - catch (e: SandboxError) { - val errorInfo: String = e.message ?: e.stackTraceToString() - logger.info(errorInfo) - // Should translate to EBICS error code. - when (e.errorCode) { - LibeufinErrorCode.LIBEUFIN_EC_INVALID_STATE -> throw EbicsProcessingError("Invalid bank state.") - LibeufinErrorCode.LIBEUFIN_EC_INCONSISTENT_STATE -> throw EbicsProcessingError("Inconsistent bank state.") - else -> throw EbicsProcessingError("Unknown Libeufin error code: ${e.errorCode}.") - } - } - catch (e: EbicsNoDownloadDataAvailable) { - respondEbicsTransfer(call, e.errorText, e.errorCode) - } - catch (e: EbicsRequestError) { - /** - * Preventing the last catch-all block from handling - * a known error type. Rethrowing here to let the top-level - * handler take action. - */ - throw e - } - catch (e: Exception) { - logger.error(e.stackTraceToString()) - throw EbicsProcessingError(e.message) - } - return@post - } - - /** - * Create a new demobank instance with a particular currency, - * debt limit and possibly other configuration - * (could also be a CLI command for now) - */ - post("/demobanks") { - throw NotImplementedError("Feature only available at the libeufin-sandbox CLI") - } - - get("/demobanks") { - expectAdmin(call.request.basicAuth()) - val ret = object { val demoBanks = mutableListOf<Demobank>() } - transaction { - DemobankConfigEntity.all().forEach { - ret.demoBanks.add(getJsonFromDemobankConfig(it)) - } - } - call.respond(ret) - return@get - } - - get("/demobanks/{demobankid}") { - val demobank = ensureDemobank(call) - expectAdmin(call.request.basicAuth()) - call.respond(getJsonFromDemobankConfig(demobank)) - return@get - } - - route("/demobanks/{demobankid}") { - // NOTE: TWG assumes that username == bank account label. - route("/taler-wire-gateway") { - post("/{exchangeUsername}/admin/add-incoming") { - val username = call.expectUriComponent("exchangeUsername") - val usernameAuth = call.request.basicAuth() - if (username != usernameAuth) - throw forbidden("Bank account name and username differ: $username vs $usernameAuth") - logger.debug("TWG add-incoming passed authentication") - val body = try { call.receive<TWGAdminAddIncoming>() } - catch (e: Exception) { - logger.error("/admin/add-incoming failed at parsing the request body") - throw SandboxError( - HttpStatusCode.BadRequest, - "Invalid request" - ) - } - val singletonTx = transaction { - val demobank = ensureDemobank(call) - val bankAccountCredit = getBankAccountFromLabel(username, demobank) - if (bankAccountCredit.owner != username) throw forbidden( - "User '$username' cannot access bank account with label: $username." - ) - val bankAccountDebit = getBankAccountFromPayto(body.debit_account) - logger.debug("TWG add-incoming about to wire transfer") - val ref = wireTransfer( - bankAccountDebit.label, - bankAccountCredit.label, - demobank.name, - body.reserve_pub, - body.amount - ) - /** - * The remaining part aims at returning an x-libeufin-bank-formatted - * message to Nexus, to let it ingest the (incoming side of the) payment - * information. The format choice makes it more practical for Nexus, - * because it handles this format already for the x-libeufin-bank connection - * type. - */ - val incomingTx = BankAccountTransactionEntity.find { - BankAccountTransactionsTable.accountServicerReference eq ref and ( - BankAccountTransactionsTable.direction eq "CRDT" - ) // closes the 'and'. - }.firstOrNull() - if (incomingTx == null) - throw internalServerError("Just created transaction not found in DB. AcctSvcrRef: $ref") - val incomingHistoryElement = getHistoryElementFromTransactionRow(incomingTx) - logger.debug("TWG add-incoming has wire transferred, AcctSvcrRef: $ref") - incomingHistoryElement - } - val resp = object { - val transactions = listOf(singletonTx) - } - call.respond(resp) - return@post - } - } - // Talk to wallets. - route("/integration-api") { - get("/config") { - val demobank = ensureDemobank(call) - call.respond(SandboxConfig( - name = "taler-bank-integration", - version = PROTOCOL_VERSION_UNIFIED, - currency = demobank.config.currency - )) - return@get - } - post("/withdrawal-operation/{wopid}") { - val arg = ensureNonNull(call.parameters["wopid"]) - val withdrawalUuid = parseUuid(arg) - val body = call.receive<TalerWithdrawalSelection>() - val transferDone = transaction { - val wo = TalerWithdrawalEntity.find { - TalerWithdrawalsTable.wopid eq withdrawalUuid - }.firstOrNull() ?: throw SandboxError( - HttpStatusCode.NotFound, "Withdrawal operation $withdrawalUuid not found." - ) - if (wo.confirmationDone) { - return@transaction true - } - if (wo.selectionDone) { - if (body.reserve_pub != wo.reservePub) throw SandboxError( - HttpStatusCode.Conflict, - "Selecting a different reserve from the one already selected" - ) - if (body.selected_exchange != wo.selectedExchangePayto) throw SandboxError( - HttpStatusCode.Conflict, - "Selecting a different exchange from the one already selected" - ) - return@transaction false - } - // Flow here means never selected, hence must as well never be paid. - if (wo.confirmationDone) throw internalServerError( - "Withdrawal ${wo.wopid} knew NO exchange and reserve pub, " + - "but is marked as paid!" - ) - wo.reservePub = body.reserve_pub - wo.selectedExchangePayto = body.selected_exchange - wo.selectionDone = true - false - } - call.respond(object { - val transfer_done: Boolean = transferDone - }) - return@post - } - get("/withdrawal-operation/{wopid}") { - val arg = ensureNonNull(call.parameters["wopid"]) - val maybeWithdrawalUuid = parseUuid(arg) - val maybeWithdrawalOp = transaction { - TalerWithdrawalEntity.find { - TalerWithdrawalsTable.wopid eq maybeWithdrawalUuid - }.firstOrNull() ?: throw SandboxError( - HttpStatusCode.NotFound, - "Withdrawal operation: $arg not found" - ) - } - val demobank = ensureDemobank(call) - val captchaPage: String? = demobank.config.captchaUrl?.replace("{wopid}",arg) - if (captchaPage == null) - throw internalServerError("demobank ${demobank.name} lacks the CAPTCHA URL from the configuration.") - val ret = TalerWithdrawalStatus( - selection_done = maybeWithdrawalOp.selectionDone, - transfer_done = maybeWithdrawalOp.confirmationDone, - amount = maybeWithdrawalOp.amount, - suggested_exchange = demobank.config.suggestedExchangeBaseUrl, - aborted = maybeWithdrawalOp.aborted, - confirm_transfer_url = captchaPage - ) - call.respond(ret) - return@get - } - } - route("/circuit-api") { - circuitApi(this) - } - // Talk to Web UI. - route("/access-api") { - post("/accounts/{account_name}/transactions") { - val username = call.request.basicAuth() - val demobank = ensureDemobank(call) - val bankAccount = getBankAccountFromLabel( - call.expectUriComponent("account_name"), - demobank - ) - // note: admin has no rights to create transactions on non-admin accounts. - val authGranted: Boolean = !WITH_AUTH - if (!authGranted && username != bankAccount.label) - throw unauthorized("Username '$username' has no rights over bank account ${bankAccount.label}") - val req = call.receive<XLibeufinBankPaytoReq>() - val payto = parsePayto(req.paytoUri) - val amount: String? = payto.amount ?: req.amount - if (amount == null) throw badRequest("Amount is missing") - /** - * The transaction block below lets the 'demoBank' field - * of 'bankAccount' be correctly accessed. */ - transaction { - wireTransfer( - debitAccount = bankAccount.label, - creditAccount = getBankAccountFromIban(payto.iban).label, - demobank = bankAccount.demoBank.name, - subject = payto.message ?: throw badRequest( - "'message' query parameter missing in Payto address" - ), - amount = amount, - pmtInfId = req.pmtInfId - ) - } - call.respond(object {}) - return@post - } - // Information about one withdrawal. - get("/accounts/{account_name}/withdrawals/{withdrawal_id}") { - getWithdrawal(call) - return@get - } - // account-less style: - get("/withdrawals/{withdrawal_id}") { - getWithdrawal(call) - return@get - } - // Create a new withdrawal operation. - post("/accounts/{account_name}/withdrawals") { - var username = call.request.basicAuth() - val demobank = ensureDemobank(call) - /** - * Check here if the user has the right over the claimed bank account. After - * this check, the withdrawal operation will be allowed only by providing its - * UID. */ - val maybeOwnedAccount = getBankAccountFromLabel( - call.expectUriComponent("account_name"), - demobank - ) - val authGranted = !WITH_AUTH // note: admin not allowed on non-admin accounts - if (!authGranted && maybeOwnedAccount.owner != username) - throw unauthorized("Customer '$username' has no rights over bank account '${maybeOwnedAccount.label}'") - val req = call.receive<WithdrawalRequest>() - // Check for currency consistency - val amount = parseAmount(req.amount) - if (amount.currency != demobank.config.currency) - throw badRequest("Currency ${amount.currency} differs from Demobank's: ${demobank.config.currency}") - // Check funds are sufficient. - if ( - maybeDebit( - maybeOwnedAccount.label, - BigDecimal(amount.amount), - transaction { maybeOwnedAccount.demoBank.name } - )) { - logger.error("Account ${maybeOwnedAccount.label} would surpass debit threshold. Not withdrawing") - throw SandboxError(HttpStatusCode.Conflict, "Insufficient funds") - } - val wo: TalerWithdrawalEntity = transaction { - TalerWithdrawalEntity.new { - this.amount = req.amount - walletBankAccount = maybeOwnedAccount - } - } - val baseUrl = URL(call.request.getBaseUrl()) - val withdrawUri = url { - protocol = URLProtocol( - name = "taler".plus(if (baseUrl.protocol.lowercase() == "http") "+http" else ""), - defaultPort = -1 - ) - host = "withdraw" - val pathSegments = mutableListOf( - /** - * encodes the hostname(+port) of the actual - * bank that will serve the withdrawal request. - */ - baseUrl.host.plus( - if (baseUrl.port != -1) - ":${baseUrl.port}" - else "" - ) - ) - /** - * Slashes can only be intermediate and single, - * any other combination results in badly formed URIs. - * The following loop ensure this for the current URI path. - * This might even come from X-Forwarded-Prefix. - */ - baseUrl.path.split("/").forEach { - if (it.isNotEmpty()) pathSegments.add(it) - } - pathSegments.add("demobanks/${demobank.name}/integration-api/${wo.wopid}") - this.appendPathSegments(pathSegments) - } - call.respond(object { - val withdrawal_id = wo.wopid.toString() - val taler_withdraw_uri = withdrawUri - }) - return@post - } - // Confirm a withdrawal: no basic auth, because the ID should be unguessable. - post("/accounts/{account_name}/withdrawals/{withdrawal_id}/confirm") { - confirmWithdrawal(call) - return@post - } - // account-less style: - post("/withdrawals/{withdrawal_id}/confirm") { - confirmWithdrawal(call) - return@post - } - // Aborting withdrawals: - post("/accounts/{account_name}/withdrawals/{withdrawal_id}/abort") { - abortWithdrawal(call) - return@post - } - // account-less style: - post("/withdrawals/{withdrawal_id}/abort") { - abortWithdrawal(call) - return@post - } - // Bank account basic information. - get("/accounts/{account_name}") { - val username = call.request.basicAuth() - val accountAccessed = call.expectUriComponent("account_name") - val demobank = ensureDemobank(call) - val bankAccount = getBankAccountFromLabel(accountAccessed, demobank) - val authGranted = !WITH_AUTH || bankAccount.isPublic || username == "admin" - if (!authGranted && bankAccount.owner != username) - throw forbidden("Customer '$username' cannot access bank account '$accountAccessed'") - val balance = getBalance(bankAccount) - logger.debug("Balance of '$username': ${balance.toPlainString()}") - call.respond(object { - val balance = object { - val amount = "${demobank.config.currency}:${balance.abs().toPlainString()}" - val credit_debit_indicator = if (balance < BigDecimal.ZERO) "debit" else "credit" - } - val paytoUri = buildIbanPaytoUri( - iban = bankAccount.iban, - bic = bankAccount.bic, - // username 'null' should only happen when auth is disabled. - receiverName = getPersonNameFromCustomer(bankAccount.owner) - ) - val iban = bankAccount.iban - // The Elvis operator helps the --no-auth case, - // where username would be empty - val debitThreshold = getMaxDebitForUser( - username = username ?: "admin", - demobankName = demobank.name - ).toString() - }) - return@get - } - get("/accounts/{account_name}/transactions/{tId}") { - val username = call.request.basicAuth() - val demobank = ensureDemobank(call) - val bankAccount = getBankAccountFromLabel( - call.expectUriComponent("account_name"), - demobank - ) - val authGranted: Boolean = bankAccount.isPublic || !WITH_AUTH || username == "admin" - if (!authGranted && username != bankAccount.owner) - throw forbidden("Cannot access bank account ${bankAccount.label}") - val tId = call.parameters["tId"] ?: throw badRequest("URI didn't contain the transaction ID") - val tx: BankAccountTransactionEntity? = transaction { - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.accountServicerReference eq tId - }.firstOrNull() - } - if (tx == null) throw notFound("Transaction $tId wasn't found") - call.respond(getHistoryElementFromTransactionRow(tx)) - return@get - } - get("/accounts/{account_name}/transactions") { - val username = call.request.basicAuth() - val demobank = ensureDemobank(call) - val bankAccount = getBankAccountFromLabel( - call.expectUriComponent("account_name"), - demobank - ) - val authGranted: Boolean = bankAccount.isPublic || !WITH_AUTH || username == "admin" - if (!authGranted && bankAccount.owner != username) - throw forbidden("Cannot access bank account ${bankAccount.label}") - // Paging values. - val page: Int = expectInt(call.request.queryParameters["page"] ?: "1") - if (page < 1) throw badRequest("'page' param is less than 1") - val size: Int = expectInt(call.request.queryParameters["size"] ?: "5") - if (size < 1) throw badRequest("'size' param is less than 1") - // Time range filter values - val fromMs: Long = expectLong(call.request.queryParameters["from_ms"] ?: "0") - if (fromMs < 0) throw badRequest("'from_ms' param is less than 0") - val untilMs: Long = expectLong(call.request.queryParameters["until_ms"] ?: Long.MAX_VALUE.toString()) - if (untilMs < 0) throw badRequest("'until_ms' param is less than 0") - val longPollMs: Long? = call.maybeLong("long_poll_ms") - // LISTEN, if Postgres. - val listenHandle = if (isPostgres() && longPollMs != null) { - val channelName = buildChannelName( - NotificationsChannelDomains.LIBEUFIN_REGIO_TX, - call.expectUriComponent("account_name") - ) - val listenHandle = PostgresListenHandle(channelName) - // Can't LISTEN on the same DB TX that checks for data, as Exposed - // closes that connection and the notification getter would fail. - // Can't invoke the notification getter in the same DB TX either, - // as it would block the DB. - listenHandle.postgresListen() - listenHandle - } else null - val historyParams = HistoryParams( - pageNumber = page, - pageSize = size, - bankAccount = bankAccount, - fromMs = fromMs, - untilMs = untilMs - ) - var ret: List<XLibeufinBankTransaction> = transaction { - extractTxHistory(historyParams) - } - logger.debug("Is payment data empty? ${ret.isEmpty()}") - // Data was found already, UNLISTEN and respond. - if (listenHandle != null && ret.isNotEmpty()) { - logger.debug("No need to wait DB events, payment data found.") - listenHandle.postgresUnlisten() - call.respond(object {val transactions = ret}) - return@get - } - // No data was found, sleep until the timeout or getting woken up. - // Third condition only silences the compiler. - if (listenHandle != null && longPollMs != null) { - logger.debug("Waiting DB event for new payment data.") - val notificationArrived = listenHandle.waitOnIODispatchers(longPollMs) - // Only if the awaited event fired, query again the DB. - if (notificationArrived) - { - ret = transaction { - // Refreshing to update the index to the very last transaction. - historyParams.bankAccount.refresh() - extractTxHistory(historyParams) - } - } - } - call.respond(object {val transactions = ret}) - return@get - } - get("/public-accounts") { - val demobank = ensureDemobank(call) - val ret = object { - val publicAccounts = mutableListOf<PublicAccountInfo>() - } - transaction { - BankAccountEntity.find { - BankAccountsTable.isPublic eq true and( - BankAccountsTable.demoBank eq demobank.id - ) - }.forEach { - val balanceIter = getBalance(it) - ret.publicAccounts.add( - PublicAccountInfo( - balance = "${demobank.config.currency}:$balanceIter", - iban = it.iban, - accountLabel = it.label - ) - ) - } - } - call.respond(ret) - return@get - } - delete("accounts/{account_name}") { - val username = call.request.basicAuth() - val demobank = ensureDemobank(call) - val authGranted = !WITH_AUTH || username == "admin" - val bankAccountLabel = call.expectUriComponent("account_name") - /** - * This helper fails if the demobank that is mentioned in the URI - * is not hosting the account to be deleted. - */ - val bankAccount = getBankAccountFromLabel( - bankAccountLabel, - demobank - ) - if (!authGranted && username != bankAccount.owner) - throw unauthorized("User '$username' has no rights to delete bank account '$bankAccountLabel'") - transaction { - val customerAccount = getCustomer(bankAccount.owner) - bankAccount.delete() - customerAccount.delete() - } - call.respond(object {}) - return@delete - } - // Keeping the prefix "testing" not to break tests. - post("/testing/register") { - // Check demobank was created. - val demobank = ensureDemobank(call) - if (!demobank.config.allowRegistrations) { - throw SandboxError( - HttpStatusCode.UnprocessableEntity, - "The bank doesn't allow new registrations at the moment." - ) - } - val req = call.receive<CustomerRegistration>() - val newAccount = insertNewAccount( - req.username, - req.password, - name = req.name, - iban = req.iban, - demobank = demobank.name, - isPublic = req.isPublic - ) - val balance = getBalance(newAccount.bankAccount) - call.respond(object { - val balance = getBalanceForJson(balance, demobank.config.currency) - val paytoUri = buildIbanPaytoUri( - iban = newAccount.bankAccount.iban, - bic = newAccount.bankAccount.bic, - receiverName = getPersonNameFromCustomer(req.username) - ) - val iban = newAccount.bankAccount.iban - val debitThreshold = getMaxDebitForUser( - req.username, - demobank.name - ).toString() - }) - return@post - } - } - route("/ebics") { - /** - * Associate an existing bank account to one EBICS subscriber. - * If the subscriber is not found, it is created. - */ - post("/subscribers") { - // Only the admin can create Ebics subscribers. - val user = call.request.basicAuth() - if (WITH_AUTH && (user != "admin")) throw forbidden("Only the Administrator can create Ebics subscribers.") - val body = call.receive<EbicsSubscriberInfo>() - // Create or get the Ebics subscriber that is found. - transaction { - // Check that host ID exists - EbicsHostEntity.find { - EbicsHostsTable.hostID eq body.hostID - }.firstOrNull() ?: throw notFound("Host ID ${body.hostID} not found.") - val subscriber: EbicsSubscriberEntity = EbicsSubscriberEntity.find { - (EbicsSubscribersTable.partnerId eq body.partnerID).and( - EbicsSubscribersTable.userId eq body.userID - ).and(EbicsSubscribersTable.hostId eq body.hostID) - }.firstOrNull() ?: EbicsSubscriberEntity.new { - partnerId = body.partnerID - userId = body.userID - systemId = null - hostId = body.hostID - state = SubscriberState.NEW - nextOrderID = 1 - } - val bankAccount = getBankAccountFromLabel( - body.demobankAccountLabel, - ensureDemobank(call) - ) - subscriber.bankAccount = bankAccount - } - call.respond(object {}) - return@post - } - } - } } -} +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/XMLEbicsConverter.kt b/bank/src/main/kotlin/tech/libeufin/bank/XMLEbicsConverter.kt @@ -1,70 +0,0 @@ -package tech.libeufin.bank - -import io.ktor.http.* -import io.ktor.http.content.* -import io.ktor.serialization.* -import io.ktor.util.reflect.* -import io.ktor.utils.io.* -import io.ktor.utils.io.charsets.* -import io.ktor.utils.io.jvm.javaio.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import tech.libeufin.util.XMLUtil - -class XMLEbicsConverter : ContentConverter { - override suspend fun deserialize( - charset: Charset, - typeInfo: TypeInfo, - content: ByteReadChannel - ): Any { - return withContext(Dispatchers.IO) { - try { - receiveEbicsXmlInternal(content.toInputStream().reader().readText()) - } catch (e: Exception) { - throw SandboxError( - HttpStatusCode.BadRequest, - "Document is invalid XML." - ) - } - } - } - - // The following annotation was suggested by Intellij. - @Deprecated( - "Please override and use serializeNullable instead", - replaceWith = ReplaceWith("serializeNullable(charset, typeInfo, contentType, value)"), - level = DeprecationLevel.WARNING - ) - override suspend fun serialize( - contentType: ContentType, - charset: Charset, - typeInfo: TypeInfo, - value: Any - ): OutgoingContent? { - return super.serializeNullable(contentType, charset, typeInfo, value) - } - - override suspend fun serializeNullable( - contentType: ContentType, - charset: Charset, - typeInfo: TypeInfo, - value: Any? - ): OutgoingContent? { - val conv = try { - XMLUtil.convertJaxbToString(value) - } catch (e: Exception) { - /** - * Not always an error: the content negotiation might have - * only checked if this handler could convert the response. - */ - return null - } - return OutputStreamContent({ - val out = this; - withContext(Dispatchers.IO) { - out.write(conv.toByteArray()) - }}, - contentType.withCharset(charset) - ) - } -} -\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/bankAccount.kt b/bank/src/main/kotlin/tech/libeufin/bank/bankAccount.kt @@ -1,276 +0,0 @@ -package tech.libeufin.bank - -import io.ktor.http.* -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.util.* -import java.math.BigDecimal - -/** - * Check whether the given bank account would surpass the - * debit threshold, in case the potential amount gets transferred. - * Returns true when the debit WOULD be surpassed. */ -fun maybeDebit( - accountLabel: String, - requestedAmount: BigDecimal, - demobankName: String = "default" -): Boolean { - val demobank = getDemobank(demobankName) ?: throw notFound( - "Demobank '${demobankName}' not found when trying to check the debit threshold" + - " for user $accountLabel" - ) - val balance = getBalance(accountLabel, demobankName) - val maxDebt = if (accountLabel == "admin") { - demobank.config.bankDebtLimit - } else demobank.config.usersDebtLimit - val balanceCheck = balance - requestedAmount - if (balanceCheck < BigDecimal.ZERO && balanceCheck.abs() > BigDecimal.valueOf(maxDebt.toLong())) { - logger.warn("User '$accountLabel' would surpass the debit" + - " threshold of $maxDebt, given the requested amount of ${requestedAmount.toPlainString()}") - return true - } - return false -} - -fun getMaxDebitForUser( - username: String, - demobankName: String = "default" -): Int { - val bank = getDemobank(demobankName) ?: throw internalServerError( - "demobank $demobankName not found" - ) - if (username == "admin") return bank.config.bankDebtLimit - return bank.config.usersDebtLimit -} - -fun getBalanceForJson(value: BigDecimal, currency: String): BalanceJson { - return BalanceJson( - amount = "${currency}:${value.abs()}", - credit_debit_indicator = if (value < BigDecimal.ZERO) "debit" else "credit" - ) -} - -fun getBalance(bankAccount: BankAccountEntity): BigDecimal { - return BigDecimal(bankAccount.balance) -} - -/** - * This function balances _in bank account statements_. A statement - * witnesses the bank account after a given business time slot. Therefore - * _this_ type of balance is not guaranteed to hold the _actual_ and - * more up-to-date bank account. It'll be used when Sandbox will support - * the issuing of bank statement. - */ -fun getBalanceForStatement( - bankAccount: BankAccountEntity, - withPending: Boolean = true -): BigDecimal { - val lastStatement = transaction { - BankAccountStatementEntity.find { - BankAccountStatementsTable.bankAccount eq bankAccount.id - }.lastOrNull() - } - var lastBalance = if (lastStatement == null) { - BigDecimal.ZERO - } else { BigDecimal(lastStatement.balanceClbd) } - if (!withPending) return lastBalance - /** - * Caller asks to include the pending transactions in the - * balance. The block below gets the transactions happened - * later than the last statement and adds them to the balance - * that was calculated so far. - */ - transaction { - val pendingTransactions = BankAccountTransactionEntity.find { - BankAccountTransactionsTable.account eq bankAccount.id and ( - BankAccountTransactionsTable.date.greater(lastStatement?.creationTime ?: 0L)) - } - pendingTransactions.forEach { tx -> - when (tx.direction) { - "DBIT" -> lastBalance -= parseDecimal(tx.amount) - "CRDT" -> lastBalance += parseDecimal(tx.amount) - else -> { - logger.error("Transaction ${tx.id} is neither debit nor credit.") - throw SandboxError( - HttpStatusCode.InternalServerError, - "Error in transactions state." - ) - } - } - } - } - return lastBalance -} - -// Gets the balance of 'accountLabel', which is hosted at 'demobankName'. -fun getBalance(accountLabel: String, - demobankName: String = "default" -): BigDecimal { - val demobank = getDemobank(demobankName) ?: throw SandboxError( - HttpStatusCode.InternalServerError, - "Demobank '$demobankName' not found" - ) - - /** - * Setting withBankFault to true for the following reason: - * when asking for a balance, the bank should have made sure - * that the user has a bank account (together with a customer profile). - * If that's not the case, it's bank's fault, since it didn't check - * earlier. - */ - val account = getBankAccountFromLabel( - accountLabel, - demobank, - withBankFault = true - ) - return getBalance(account) -} - -/** - * 'debitAccount' and 'creditAccount' are customer usernames - * and ALSO labels of the bank accounts owned by them. They are - * used to both resort a bank account and the legal name owning - * the bank accounts. - */ -fun wireTransfer( - debitAccount: String, - creditAccount: String, - demobank: String = "default", - subject: String, - amount: String, // $currency:x.y - pmtInfId: String? = null, - endToEndId: String? = null -): String { - logger.debug("Maybe wire transfer (endToEndId: $endToEndId): $debitAccount -> $creditAccount, $subject, $amount") - return transaction { - val demobankDb = ensureDemobank(demobank) - val debitAccountDb = getBankAccountFromLabel(debitAccount, demobankDb) - val creditAccountDb = getBankAccountFromLabel(creditAccount, demobankDb) - val parsedAmount = parseAmount(amount) - // Potential amount to transfer. - val amountAsNumber = BigDecimal(parsedAmount.amount) - if (amountAsNumber == BigDecimal.ZERO) - throw badRequest("Wire transfers of zero not possible.") - if (parsedAmount.currency != demobankDb.config.currency) - throw badRequest( - "Won't wire transfer with currency: ${parsedAmount.currency}." + - " Only ${demobankDb.config.currency} allowed." - ) - // Check funds are sufficient. - if ( - maybeDebit( - debitAccountDb.label, - amountAsNumber, - demobankDb.name - )) { - logger.error("Account ${debitAccountDb.label} would surpass debit threshold. Rollback wire transfer") - throw SandboxError(HttpStatusCode.Conflict, "Insufficient funds") - } - val timeStamp = getNowMillis() - val transactionRef = getRandomString(8) - BankAccountTransactionEntity.new { - creditorIban = creditAccountDb.iban - creditorBic = creditAccountDb.bic - this.creditorName = getPersonNameFromCustomer(creditAccountDb.owner) - debtorIban = debitAccountDb.iban - debtorBic = debitAccountDb.bic - debtorName = getPersonNameFromCustomer(debitAccountDb.owner) - this.subject = subject - this.amount = parsedAmount.amount - this.currency = demobankDb.config.currency - date = timeStamp - accountServicerReference = transactionRef - account = creditAccountDb - direction = "CRDT" - this.demobank = demobankDb - this.pmtInfId = pmtInfId - } - BankAccountTransactionEntity.new { - creditorIban = creditAccountDb.iban - creditorBic = creditAccountDb.bic - this.creditorName = getPersonNameFromCustomer(creditAccountDb.owner) - debtorIban = debitAccountDb.iban - debtorBic = debitAccountDb.bic - debtorName = getPersonNameFromCustomer(debitAccountDb.owner) - this.subject = subject - this.amount = parsedAmount.amount - this.currency = demobankDb.config.currency - date = timeStamp - accountServicerReference = transactionRef - account = debitAccountDb - direction = "DBIT" - this.demobank = demobankDb - this.pmtInfId = pmtInfId - this.endToEndId = endToEndId - } - - // Adjusting the balances (acceptable debit conditions checked before). - // Debit: - val newDebitBalance = (BigDecimal(debitAccountDb.balance) - amountAsNumber).roundToTwoDigits() - debitAccountDb.balance = newDebitBalance.toPlainString() - // Credit: - val newCreditBalance = (BigDecimal(creditAccountDb.balance) + amountAsNumber).roundToTwoDigits() - creditAccountDb.balance = newCreditBalance.toPlainString() - - // Signaling this wire transfer's event. - if (this.isPostgres()) { - val creditChannel = buildChannelName( - NotificationsChannelDomains.LIBEUFIN_REGIO_TX, - creditAccountDb.label - ) - this.postgresNotify(creditChannel, "CRDT") - val debitChannel = buildChannelName( - NotificationsChannelDomains.LIBEUFIN_REGIO_TX, - debitAccountDb.label - ) - this.postgresNotify(debitChannel, "DBIT") - } - transactionRef - } -} - -/** - * Helper that constructs a transactions history page - * according to the URI parameters passed to Access API's - * GET /transactions. - */ -data class HistoryParams( - val pageNumber: Int, - val pageSize: Int, - val fromMs: Long, - val untilMs: Long, - val bankAccount: BankAccountEntity -) - -fun extractTxHistory(params: HistoryParams): List<XLibeufinBankTransaction> { - val ret = mutableListOf<XLibeufinBankTransaction>() - - /** - * Helper that gets transactions earlier than the 'firstElementId' - * transaction AND that match the URI parameters. - */ - fun getPage(firstElementId: Long): Iterable<BankAccountTransactionEntity> { - return BankAccountTransactionEntity.find { - (BankAccountTransactionsTable.id lessEq firstElementId) and - (BankAccountTransactionsTable.account eq params.bankAccount.id) and - (BankAccountTransactionsTable.date.between(params.fromMs, params.untilMs)) - }.sortedByDescending { it.id.value }.take(params.pageSize) - } - // Gets a pointer to the last transaction of this bank account. - val lastTransaction: BankAccountTransactionEntity? = params.bankAccount.lastTransaction - if (lastTransaction == null) return ret - var nextPageIdUpperLimit: Long = lastTransaction.id.value - - // This loop fetches (and discards) pages until the desired one is found. - for (i in 1..(params.pageNumber)) { - val pageBuf = getPage(nextPageIdUpperLimit) - logger.debug("pageBuf #$i follows. Request wants #${params.pageNumber}:") - pageBuf.forEach { logger.debug("ID: ${it.id}, subject: ${it.subject}, amount: ${it.currency}:${it.amount}") } - if (pageBuf.none()) return ret - nextPageIdUpperLimit = pageBuf.last().id.value - 1 - if (i == params.pageNumber) pageBuf.forEach { - ret.add(getHistoryElementFromTransactionRow(it)) - } - } - return ret -} -\ No newline at end of file diff --git a/bank/src/main/resources/logback.xml b/bank/src/main/resources/logback.xml @@ -6,7 +6,7 @@ </encoder> </appender> - <logger name="tech.libeufin.sandbox" level="ALL" additivity="false"> + <logger name="tech.libeufin.bank" level="ALL" additivity="false"> <appender-ref ref="STDERR" /> </logger> <logger name="tech.libeufin.util" level="ALL" additivity="false"> diff --git a/bank/src/test/kotlin/BalanceTest.kt b/bank/src/test/kotlin/BalanceTest.kt @@ -1,115 +0,0 @@ -import org.jetbrains.exposed.sql.SchemaUtils -import org.jetbrains.exposed.sql.insert -import org.jetbrains.exposed.sql.transactions.transaction -import org.junit.Test -import tech.libeufin.sandbox.* -import tech.libeufin.util.millis -import tech.libeufin.util.roundToTwoDigits -import java.math.BigDecimal -import java.time.LocalDateTime - -class BalanceTest { - @Test - fun balanceTest() { - val config = DemobankConfig( - currency = "EUR", - bankDebtLimit = 1000000, - usersDebtLimit = 10000, - allowRegistrations = true, - demobankName = "default", - withSignupBonus = false - ) - withTestDatabase { - transaction { - insertConfigPairs(config) - val demobank = DemobankConfigEntity.new { - name = "default" - } - val one = BankAccountEntity.new { - iban = "IBAN 1" - bic = "BIC" - label = "label 1" - owner = "admin" - this.demoBank = demobank - } - val other = BankAccountEntity.new { - iban = "IBAN 2" - bic = "BIC" - label = "label 2" - owner = "admin" - this.demoBank = demobank - } - BankAccountTransactionEntity.new { - account = one - creditorIban = "earns" - creditorBic = "BIC" - creditorName = "Creditor Name" - debtorIban = "spends" - debtorBic = "BIC" - debtorName = "Debitor Name" - subject = "deal" - amount = "1" - date = LocalDateTime.now().millis() - currency = "EUR" - pmtInfId = "0" - direction = "CRDT" - accountServicerReference = "test-account-servicer-reference" - this.demobank = demobank - } - BankAccountTransactionEntity.new { - account = one - creditorIban = "earns" - creditorBic = "BIC" - creditorName = "Creditor Name" - debtorIban = "spends" - debtorBic = "BIC" - debtorName = "Debitor Name" - subject = "deal" - amount = "1" - date = LocalDateTime.now().millis() - currency = "EUR" - pmtInfId = "0" - direction = "CRDT" - accountServicerReference = "test-account-servicer-reference" - this.demobank = demobank - } - BankAccountTransactionEntity.new { - account = one - creditorIban = "earns" - creditorBic = "BIC" - creditorName = "Creditor Name" - debtorIban = "spends" - debtorBic = "BIC" - debtorName = "Debitor Name" - subject = "deal" - amount = "1" - date = LocalDateTime.now().millis() - currency = "EUR" - pmtInfId = "0" - direction = "DBIT" - accountServicerReference = "test-account-servicer-reference" - this.demobank = demobank - } - wireTransfer( - other.label, one.label, demobank.name, "one gets 1", "EUR:1" - ) - wireTransfer( - other.label, one.label, demobank.name, "one gets another 1", "EUR:1" - ) - wireTransfer( - one.label, other.label, demobank.name, "one gives 1", "EUR:1" - ) - val maybeOneBalance: BigDecimal = getBalance(one) - println(maybeOneBalance) - assert(BigDecimal.ONE.roundToTwoDigits() == maybeOneBalance.roundToTwoDigits()) - } - } - } - @Test - fun balanceAbsTest() { - val minus = BigDecimal.ZERO - BigDecimal.ONE - val plus = BigDecimal.ONE - println(minus.abs().toPlainString()) - println(plus.abs().toPlainString()) - } -} diff --git a/bank/src/test/kotlin/DBTest.kt b/bank/src/test/kotlin/DBTest.kt @@ -1,152 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2020 Taler Systems S.A. - * - * LibEuFin is free software; you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation; either version 3, or - * (at your option) any later version. - * - * LibEuFin is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General - * Public License for more details. - * - * You should have received a copy of the GNU Affero General Public - * License along with LibEuFin; see the file COPYING. If not, see - * <http://www.gnu.org/licenses/> - */ - -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.transactions.transaction -import org.junit.Test -import tech.libeufin.sandbox.* -import tech.libeufin.util.connectWithSchema -import tech.libeufin.util.getCurrentUser -import tech.libeufin.util.getJdbcConnectionFromPg -import tech.libeufin.util.millis -import java.io.File -import java.time.LocalDateTime -import kotlin.reflect.KProperty -import kotlin.reflect.typeOf - -/** - * Run a block after connecting to the test database. - * Cleans up the DB file afterwards. - */ -fun withTestDatabase(f: () -> Unit) { - dbDropTables("postgresql:///libeufincheck") - dbCreateTables("postgresql:///libeufincheck") - f() -} - -class DBTest { - private var config = DemobankConfig( - currency = "EUR", - bankDebtLimit = 1000000, - usersDebtLimit = 10000, - allowRegistrations = true, - demobankName = "default", - withSignupBonus = false, - ) - - /** - * This tests the conversion from a Postgres connection - * string to a JDBC one. - */ - @Test - fun connectionStringTest() { - getJdbcConnectionFromPg("postgres://auditor-basedb") - var conv = getJdbcConnectionFromPg("postgresql:///libeufincheck") - connectWithSchema(getJdbcConnectionFromPg("postgres:///libeufincheck")) - connectWithSchema(conv) - conv = getJdbcConnectionFromPg("postgresql://localhost:5432/libeufincheck?user=${System.getProperty("user.name")}") - connectWithSchema(conv) - conv = getJdbcConnectionFromPg("postgresql:///libeufincheck?host=/tmp/libeufin") - var exception: Exception? = null - try { - connectWithSchema(conv) - } catch (e: Exception) { - exception = e - } - assert(exception is UtilError) - } - - /** - * Storing configuration values into the database, - * then extract them and check that they equal the - * configuration model object. - */ - @Test - fun insertPairsTest() { - withTestDatabase { - // Config model. - val config = DemobankConfig( - currency = "EUR", - bankDebtLimit = 1, - usersDebtLimit = 2, - allowRegistrations = true, - demobankName = "default", - withSignupBonus = true - ) - transaction { - DemobankConfigEntity.new { name = "default" } - insertConfigPairs(config) - val db = getDefaultDemobank() - /** - * db.config extracts config values from the database - * and puts them in a fresh config model object. - */ - assert(config.hashCode() == db.config.hashCode()) - } - } - } - - @Test - fun betweenDates() { - withTestDatabase { - transaction { - insertConfigPairs(config) - val demobank = DemobankConfigEntity.new { - name = "default" - } - val bankAccount = BankAccountEntity.new { - iban = "iban" - bic = "bic" - label = "label" - owner = "test" - demoBank = demobank - } - BankAccountTransactionEntity.new { - account = bankAccount - creditorIban = "earns" - creditorBic = "BIC" - creditorName = "Creditor Name" - debtorIban = "spends" - debtorBic = "BIC" - debtorName = "Debitor Name" - subject = "deal" - amount = "EUR:1" - date = LocalDateTime.now().millis() - currency = "EUR" - pmtInfId = "0" - direction = "DBIT" - accountServicerReference = "test-account-servicer-reference" - this.demobank = demobank - } - } - // The block below tests the date range in the database query - transaction { - addLogger(StdOutSqlLogger) - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.date.between( - 0, // 1970-01-01 - LocalDateTime.now().millis() // - ) - }.apply { - assert(this.count() == 1L) - } - } - } - } -} -\ No newline at end of file diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -1,6 +1,9 @@ import org.junit.Test -import tech.libeufin.sandbox.* +import tech.libeufin.bank.* import tech.libeufin.util.execCommand +import tech.libeufin.util.getNow +import tech.libeufin.util.toMicro +import java.util.Random import java.util.UUID class DatabaseTest { @@ -23,20 +26,18 @@ class DatabaseTest { cashoutCurrency = "KUDOS" ) private val bankAccountFoo = BankAccount( - iban = "FOO-IBAN-XYZ", - bic = "FOO-BIC", - bankAccountLabel = "foo", + internalPaytoUri = "FOO-IBAN-XYZ", lastNexusFetchRowId = 1L, owningCustomerId = 1L, - hasDebt = false + hasDebt = false, + maxDebt = TalerAmount(10, 1) ) private val bankAccountBar = BankAccount( - iban = "BAR-IBAN-ABC", - bic = "BAR-BIC", - bankAccountLabel = "bar", + internalPaytoUri = "BAR-IBAN-ABC", lastNexusFetchRowId = 1L, owningCustomerId = 2L, - hasDebt = false + hasDebt = false, + maxDebt = TalerAmount(10, 1) ) fun initDb(): Database { @@ -54,21 +55,40 @@ class DatabaseTest { } @Test + fun bearerTokenTest() { + val db = initDb() + val tokenBytes = ByteArray(32) + Random().nextBytes(tokenBytes) + val token = BearerToken( + bankCustomer = 1L, + content = tokenBytes, + creationTime = getNow().toMicro(), // make .toMicro()? implicit? + expirationTime = getNow().plusDays(1).toMicro(), + scope = TokenScope.readonly + ) + assert(db.bearerTokenGet(token.content) == null) + db.customerCreate(customerBar) // Tokens need owners. + assert(db.bearerTokenCreate(token)) + assert(db.bearerTokenGet(tokenBytes) != null) + } + @Test fun bankTransactionsTest() { val db = initDb() - assert(db.customerCreate(customerFoo)) - assert(db.customerCreate(customerBar)) + val fooId = db.customerCreate(customerFoo) + assert(fooId != null) + val barId = db.customerCreate(customerBar) + assert(barId != null) assert(db.bankAccountCreate(bankAccountFoo)) assert(db.bankAccountCreate(bankAccountBar)) - var fooAccount = db.bankAccountGetFromLabel("foo") + var fooAccount = db.bankAccountGetFromOwnerId(fooId!!) assert(fooAccount?.hasDebt == false) // Foo has NO debit. // Preparing the payment data. db.bankAccountSetMaxDebt( - "foo", + fooId, TalerAmount(100, 0) ) db.bankAccountSetMaxDebt( - "bar", + barId!!, TalerAmount(50, 0) ) val fooPaysBar = BankInternalTransaction( @@ -83,13 +103,13 @@ class DatabaseTest { ) val firstSpending = db.bankTransactionCreate(fooPaysBar) // Foo pays Bar and goes debit. assert(firstSpending == Database.BankTransactionResult.SUCCESS) - fooAccount = db.bankAccountGetFromLabel("foo") + fooAccount = db.bankAccountGetFromOwnerId(fooId) // Foo: credit -> debit assert(fooAccount?.hasDebt == true) // Asserting Foo's debit. // Now checking that more spending doesn't get Foo out of debit. val secondSpending = db.bankTransactionCreate(fooPaysBar) assert(secondSpending == Database.BankTransactionResult.SUCCESS) - fooAccount = db.bankAccountGetFromLabel("foo") + fooAccount = db.bankAccountGetFromOwnerId(fooId) // Checking that Foo's debit is two times the paid amount // Foo: debit -> debit assert(fooAccount?.balance?.value == 20L @@ -97,7 +117,7 @@ class DatabaseTest { && fooAccount.hasDebt ) // Asserting Bar has a positive balance and what Foo paid so far. - var barAccount = db.bankAccountGetFromLabel("bar") + var barAccount = db.bankAccountGetFromOwnerId(barId) val barBalance: TalerAmount? = barAccount?.balance assert( barAccount?.hasDebt == false @@ -116,7 +136,7 @@ class DatabaseTest { ) val barPays = db.bankTransactionCreate(barPaysFoo) assert(barPays == Database.BankTransactionResult.SUCCESS) - barAccount = db.bankAccountGetFromLabel("bar") + barAccount = db.bankAccountGetFromOwnerId(barId) val barBalanceTen: TalerAmount? = barAccount?.balance // Bar: credit -> credit assert(barAccount?.hasDebt == false && barBalanceTen?.value == 10L && barBalanceTen.frac == 0) @@ -124,8 +144,8 @@ class DatabaseTest { val barPaysAgain = db.bankTransactionCreate(barPaysFoo) assert(barPaysAgain == Database.BankTransactionResult.SUCCESS) // Refreshing the two accounts. - barAccount = db.bankAccountGetFromLabel("bar") - fooAccount = db.bankAccountGetFromLabel("foo") + barAccount = db.bankAccountGetFromOwnerId(barId) + fooAccount = db.bankAccountGetFromOwnerId(fooId) // Foo should have returned to zero and no debt, same for Bar. // Foo: debit -> credit assert(fooAccount?.hasDebt == false && barAccount?.hasDebt == false) @@ -134,8 +154,8 @@ class DatabaseTest { // Bringing Bar to debit. val barPaysMore = db.bankTransactionCreate(barPaysFoo) assert(barPaysAgain == Database.BankTransactionResult.SUCCESS) - barAccount = db.bankAccountGetFromLabel("bar") - fooAccount = db.bankAccountGetFromLabel("foo") + barAccount = db.bankAccountGetFromOwnerId(barId) + fooAccount = db.bankAccountGetFromOwnerId(fooId) // Bar: credit -> debit assert(fooAccount?.hasDebt == false && barAccount?.hasDebt == true) assert(fooAccount?.balance?.equals(TalerAmount(10, 0)) == true) @@ -148,7 +168,7 @@ class DatabaseTest { db.customerCreate(customerFoo) assert(db.customerGetFromLogin("foo")?.name == "Foo") // Trigger conflict. - assert(!db.customerCreate(customerFoo)) + assert(db.customerCreate(customerFoo) == null) } @Test fun configTest() { @@ -161,19 +181,18 @@ class DatabaseTest { @Test fun bankAccountTest() { val db = initDb() - assert(db.bankAccountGetFromLabel("foo") == null) - assert(db.customerCreate(customerFoo)) + assert(db.bankAccountGetFromOwnerId(1L) == null) + assert(db.customerCreate(customerFoo) != null) assert(db.bankAccountCreate(bankAccountFoo)) assert(!db.bankAccountCreate(bankAccountFoo)) // Triggers conflict. - assert(db.bankAccountGetFromLabel("foo")?.bankAccountLabel == "foo") - assert(db.bankAccountGetFromLabel("foo")?.balance?.equals(TalerAmount(0, 0)) == true) + assert(db.bankAccountGetFromOwnerId(1L)?.balance?.equals(TalerAmount(0, 0)) == true) } @Test fun withdrawalTest() { val db = initDb() val uuid = UUID.randomUUID() - assert(db.customerCreate(customerFoo)) + assert(db.customerCreate(customerFoo) != null) assert(db.bankAccountCreate(bankAccountFoo)) // insert new. assert(db.talerWithdrawalCreate( @@ -220,16 +239,17 @@ class DatabaseTest { buyInFee = TalerAmount(0, 22), sellAtRatio = 2, sellOutFee = TalerAmount(0, 44), - cashoutAddress = "IBAN", + credit_payto_uri = "IBAN", cashoutCurrency = "KUDOS", creationTime = 3L, subject = "31st", tanChannel = TanChannel.sms, tanCode = "secret", ) - assert(db.customerCreate(customerFoo)) + val fooId = db.customerCreate(customerFoo) + assert(fooId != null) assert(db.bankAccountCreate(bankAccountFoo)) - assert(db.customerCreate(customerBar)) + assert(db.customerCreate(customerBar) != null) assert(db.bankAccountCreate(bankAccountBar)) assert(db.cashoutCreate(op)) val fromDb = db.cashoutGetFromUuid(op.cashoutUuid) @@ -237,10 +257,11 @@ class DatabaseTest { assert(db.cashoutDelete(op.cashoutUuid) == Database.CashoutDeleteResult.SUCCESS) assert(db.cashoutCreate(op)) db.bankAccountSetMaxDebt( - "foo", + fooId!!, TalerAmount(100, 0) ) - assert(db.bankTransactionCreate(BankInternalTransaction( + assert(db.bankTransactionCreate( + BankInternalTransaction( creditorAccountId = 2, debtorAccountId = 1, subject = "backing the cash-out", @@ -249,7 +270,8 @@ class DatabaseTest { endToEndId = "end-to-end-id", paymentInformationId = "pmtinfid", transactionDate = 100000L - )) == Database.BankTransactionResult.SUCCESS) + ) + ) == Database.BankTransactionResult.SUCCESS) // Confirming the cash-out assert(db.cashoutConfirm(op.cashoutUuid, 1L, 1L)) // Checking the confirmation took place. diff --git a/bank/src/test/kotlin/EbicsErrorTest.kt b/bank/src/test/kotlin/EbicsErrorTest.kt @@ -1,24 +0,0 @@ -import org.apache.xml.security.binding.xmldsig.SignatureType -import org.junit.Test -import tech.libeufin.util.CryptoUtil -import tech.libeufin.util.XMLUtil -import tech.libeufin.util.ebics_h004.EbicsResponse -import tech.libeufin.util.ebics_h004.EbicsTypes - -class EbicsErrorTest { - - @Test - fun makeEbicsErrorResponse() { - val pair = CryptoUtil.generateRsaKeyPair(2048) - val resp = EbicsResponse.createForUploadWithError( - "[EBICS_ERROR] abc", - "012345", - EbicsTypes.TransactionPhaseType.INITIALISATION - ) - val signedResp = XMLUtil.signEbicsResponse(resp, pair.private) - XMLUtil.validateFromString(signedResp) - assert(resp.header.mutable.reportText == "[EBICS_ERROR] abc") - assert(resp.header.mutable.returnCode == "012345") - assert(resp.body.returnCode.value == "012345") - } -} -\ No newline at end of file diff --git a/bank/src/test/kotlin/LibeuFinApiTest.kt b/bank/src/test/kotlin/LibeuFinApiTest.kt @@ -0,0 +1,26 @@ +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.server.testing.* +import org.junit.Test +import tech.libeufin.bank.Database +import tech.libeufin.bank.webApp + +class LibeuFinApiTest { + @Test + fun createAccountTest() { + testApplication { + System.setProperty( + "BANK_DB_CONNECTION_STRING", + "jdbc:postgresql:///libeufincheck" + ) + val db = Database("jdbc:postgresql:///libeufincheck") + db.configSet("max_debt_ordinary_customers", "KUDOS:11") + application(webApp) + client.post("/test-json") { + expectSuccess = true + contentType(ContentType.Application.Json) + } + } + } +} +\ No newline at end of file diff --git a/bank/src/test/kotlin/StringsTest.kt b/bank/src/test/kotlin/StringsTest.kt @@ -1,37 +0,0 @@ -import org.junit.Test -import tech.libeufin.util.hasWopidPlaceholder -import tech.libeufin.util.validateBic - -class StringsTest { - - @Test - fun hasWopidTest() { - assert(hasWopidPlaceholder("http://example.com/#/{wopid}")) - assert(!hasWopidPlaceholder("http://example.com")) - assert(hasWopidPlaceholder("http://example.com/#/{WOPID}")) - assert(!hasWopidPlaceholder("{ W O P I D }")) - } - - @Test - fun replaceWopidPlaceholderTest() { - assert( - "http://example.com/#/operation/{wopid}".replace("{wopid}", "987") - == "http://example.com/#/operation/987" - ) - assert("http://example.com".replace("{wopid}", "not-replaced") - == "http://example.com" - ) - } - - @Test - fun bicTest() { - assert(validateBic("GENODEM1GLS")) - assert(validateBic("AUTOATW1XXX")) - } - - @Test - fun booleanToString() { - assert(true.toString() == "true") - assert(false.toString() == "false") - } -} -\ No newline at end of file diff --git a/database-versioning/new/libeufin-bank-0001.sql b/database-versioning/new/libeufin-bank-0001.sql @@ -32,6 +32,9 @@ COMMENT ON TYPE taler_amount CREATE TYPE direction_enum AS ENUM ('credit', 'debit'); +CREATE TYPE token_scope_enum + AS ENUM ('readonly', 'readwrite'); + CREATE TYPE tan_enum AS ENUM ('sms', 'email', 'file'); -- file is for testing purposes. @@ -44,7 +47,6 @@ CREATE TYPE subscriber_key_state_enum CREATE TYPE subscriber_state_enum AS ENUM ('new', 'confirmed'); - -- FIXME: comments on types (see exchange for example)! -- start of: bank config tables. FIXME: eventually replaced by the INI file. @@ -65,30 +67,44 @@ CREATE TABLE IF NOT EXISTS customers ,name TEXT ,email TEXT ,phone TEXT - ,cashout_payto TEXT + ,cashout_payto TEXT -- here because has no business meaning inside libeufin-bank ,cashout_currency TEXT ); COMMENT ON COLUMN customers.cashout_payto IS 'RFC 8905 payto URI to collect fiat payments that come from the conversion of regional currency cash-out operations.'; - COMMENT ON COLUMN customers.name IS 'Full name of the customer.'; +CREATE TABLE IF NOT EXISTS bearer_tokens + (bearer_token_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE + ,content BYTEA NOT NULL UNIQUE CHECK (LENGTH(content)=32) + ,creation_time INT8 + ,expiration_time INT8 + ,scope token_scope_enum + ,bank_customer BIGINT NOT NULL REFERENCES customers(customer_id) ON DELETE CASCADE +); + +COMMENT ON TABLE bearer_tokens + IS 'Login tokens associated with one bank customer. There is currently' + ' no garbage collector that deletes the expired tokens from the table'; + +COMMENT ON COLUMN bearer_tokens.bank_customer + IS 'The customer that directly created this token, or the customer that' + ' created the very first token that originated all the refreshes until' + ' this token was created.'; CREATE TABLE IF NOT EXISTS bank_accounts (bank_account_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE - ,iban TEXT NOT NULL UNIQUE - ,bic TEXT NOT NULL - ,bank_account_label TEXT NOT NULL - ,owning_customer_id BIGINT NOT NULL + ,internal_payto_uri TEXT NOT NULL UNIQUE + ,owning_customer_id BIGINT NOT NULL UNIQUE -- UNIQUE enforces 1-1 map with customers REFERENCES customers(customer_id) ,is_public BOOLEAN DEFAULT FALSE NOT NULL -- privacy by default + ,is_taler_exchange BOOLEAN DEFAULT FALSE NOT NULL ,last_nexus_fetch_row_id BIGINT ,balance taler_amount DEFAULT (0, 0) ,max_debt taler_amount DEFAULT (0, 0) ,has_debt BOOLEAN NOT NULL DEFAULT FALSE - ,UNIQUE (owning_customer_id, bank_account_label) ); COMMENT ON TABLE bank_accounts @@ -110,9 +126,6 @@ COMMENT ON COLUMN bank_accounts.is_public IS 'Indicates whether the bank account history can be publicly shared'; -COMMENT ON COLUMN bank_accounts.bank_account_label - IS 'Label of the bank account'; - COMMENT ON COLUMN bank_accounts.owning_customer_id IS 'Login that owns the bank account'; @@ -122,11 +135,9 @@ COMMENT ON COLUMN bank_accounts.owning_customer_id CREATE TABLE IF NOT EXISTS bank_account_transactions (bank_transaction_id BIGINT GENERATED BY DEFAULT AS IDENTITY UNIQUE - ,creditor_iban TEXT NOT NULL - ,creditor_bic TEXT NULL + ,creditor_payto_uri TEXT NOT NULL ,creditor_name TEXT NOT NULL - ,debtor_iban TEXT NOT NULL - ,debtor_bic TEXT NULL + ,debtor_payto_uri TEXT NOT NULL ,debtor_name TEXT NOT NULL ,subject TEXT NOT NULL ,amount taler_amount NOT NULL @@ -175,16 +186,14 @@ CREATE TABLE IF NOT EXISTS cashout_operations REFERENCES bank_accounts(bank_account_id) ON DELETE CASCADE ON UPDATE RESTRICT - ,cashout_address TEXT NOT NULL -- FIXME: clarify payto, if it's a payto use it in the name - ,cashout_currency TEXT NOT NULL + ,credit_payto_uri TEXT NOT NULL + ,cashout_currency TEXT NOT NULL -- need, or include in credit_payto_uri? ); -- FIXME: table comment missing COMMENT ON COLUMN cashout_operations.tan_confirmation_time IS 'Timestamp when the customer confirmed the cash-out operation via TAN'; -COMMENT ON COLUMN cashout_operations.cashout_address - IS 'IBAN that ultimately gets the fiat payment'; COMMENT ON COLUMN cashout_operations.tan_code IS 'text that the customer must send to confirm the cash-out operation'; diff --git a/database-versioning/new/procedures.sql b/database-versioning/new/procedures.sql @@ -104,11 +104,9 @@ AS $$ DECLARE debtor_has_debt BOOLEAN; debtor_balance taler_amount; -debtor_iban TEXT; -debtor_bic TEXT; +debtor_payto_uri TEXT; debtor_name TEXT; -creditor_iban TEXT; -creditor_bic TEXT; +creditor_payto_uri TEXT; creditor_name TEXT; debtor_max_debt taler_amount; creditor_has_debt BOOLEAN; @@ -128,12 +126,12 @@ SELECT has_debt, (balance).val, (balance).frac, (max_debt).val, (max_debt).frac, - iban, bic, customers.name + internal_payto_uri, customers.name INTO debtor_has_debt, debtor_balance.val, debtor_balance.frac, debtor_max_debt.val, debtor_max_debt.frac, - debtor_iban, debtor_bic, debtor_name + debtor_payto_uri, debtor_name FROM bank_accounts JOIN customers ON (bank_accounts.owning_customer_id = customers.customer_id) WHERE bank_account_id=in_debtor_account_id; @@ -148,11 +146,11 @@ out_nx_debtor=FALSE; SELECT has_debt, (balance).val, (balance).frac, - iban, bic, customers.name + internal_payto_uri, customers.name INTO creditor_has_debt, creditor_balance.val, creditor_balance.frac, - creditor_iban, creditor_bic, creditor_name + creditor_payto_uri, creditor_name FROM bank_accounts JOIN customers ON (bank_accounts.owning_customer_id = customers.customer_id) WHERE bank_account_id=in_creditor_account_id; @@ -251,11 +249,9 @@ out_balance_insufficient=FALSE; -- now actually create the bank transaction. -- debtor side: INSERT INTO bank_account_transactions ( - creditor_iban - ,creditor_bic + creditor_payto_uri ,creditor_name - ,debtor_iban - ,debtor_bic + ,debtor_payto_uri ,debtor_name ,subject ,amount @@ -267,11 +263,9 @@ INSERT INTO bank_account_transactions ( ,bank_account_id ) VALUES ( - creditor_iban, - creditor_bic, + creditor_payto_uri, creditor_name, - debtor_iban, - debtor_bic, + debtor_payto_uri, debtor_name, in_subject, in_amount, @@ -285,11 +279,9 @@ VALUES ( -- debtor side: INSERT INTO bank_account_transactions ( - creditor_iban - ,creditor_bic + creditor_payto_uri ,creditor_name - ,debtor_iban - ,debtor_bic + ,debtor_payto_uri ,debtor_name ,subject ,amount @@ -301,11 +293,9 @@ INSERT INTO bank_account_transactions ( ,bank_account_id ) VALUES ( - creditor_iban, - creditor_bic, + creditor_payto_uri, creditor_name, - debtor_iban, - debtor_bic, + debtor_payto_uri, debtor_name, in_subject, in_amount, diff --git a/nexus/build.gradle b/nexus/build.gradle @@ -102,14 +102,13 @@ dependencies { testImplementation 'org.jetbrains.kotlin:kotlin-test-junit:1.5.21' testImplementation 'io.ktor:ktor-client-mock:2.2.4' testImplementation 'com.kohlschutter.junixsocket:junixsocket-core:2.6.2' - testImplementation project(":bank") } test { useJUnit() failFast = true testLogging.showStandardStreams = false - environment.put("LIBEUFIN_SANDBOX_ADMIN_PASSWORD", "foo") + environment.put("LIBEUFIN_BANK_ADMIN_PASSWORD", "foo") environment.put("LIBEUFIN_CASHOUT_TEST_TAN", "foo") } @@ -132,4 +131,4 @@ run { task pofi(type: JavaExec) { classpath = sourceSets.test.runtimeClasspath mainClass = "PostFinanceKt" -} -\ No newline at end of file +} diff --git a/nexus/src/test/kotlin/ConversionServiceTest.kt b/nexus/src/test/kotlin/ConversionServiceTest.kt @@ -1,395 +0,0 @@ -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.ktor.client.* -import io.ktor.client.engine.cio.* -import io.ktor.client.engine.mock.* -import io.ktor.client.request.* -import io.ktor.http.* -import io.ktor.server.testing.* -import kotlinx.coroutines.* -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.transactions.transaction -import org.junit.Test -import tech.libeufin.nexus.server.nexusApp -import tech.libeufin.sandbox.* -import tech.libeufin.util.parseAmount -import java.math.BigDecimal - -class ConversionServiceTest { - // Tests the helper that fetches the new cash-out's to POST to Nexus. - @Test - fun testCashoutFetcher() { - withTestDatabase { - prepSandboxDb() - // making a transaction that means cash-out for bar, but not for foo. - // That lets test the singleton and empty result sets. - wireTransfer( - debitAccount = "foo", - creditAccount = "bar", - subject = "a cash-out for bar", - amount = "TESTKUDOS:3" - ) - // Expecting the fetcher to return an empty set. - val expectEmpty = getUnsubmittedTransactions("foo") - assert(expectEmpty.isEmpty()) - // Expecting the fetcher to return a one-element set. - val expectOne = getUnsubmittedTransactions("bar") - assert(expectOne.size == 1) - // Generating a bunch of cash-out operations for "foo" - for (i in 1..5) - wireTransfer( - debitAccount = "bar", - creditAccount = "foo", - subject = "foo #$i", - amount = "TESTKUDOS:3" - ) - // Expecting 5 entries for foo. - val expectFive = getUnsubmittedTransactions("foo") - assert(expectFive.size == 5) - /* Checking the order. The order should ensure that - * later payments get higher indexes. */ - assert(expectFive[0].subject == "foo #1") - assert(expectFive[4].subject == "foo #5") - } - } - // Tests the helper that applies buy-in ratio and fees - @Test - fun buyinRatioTest() { - val highFees = RatioAndFees( - buy_at_ratio = 1F, - buy_in_fee = 10F - ) - // Checks that negatives aren't let through. - assertException<UtilError>({ - applyBuyinRatioAndFees( - BigDecimal.ONE, - highFees) - }) - // Checks successful case. - val fees = RatioAndFees( - buy_at_ratio = 3.5F, - buy_in_fee = 0.33F - ) - assert(applyBuyinRatioAndFees(BigDecimal.valueOf(3), fees) == BigDecimal("10.17")) - } - private fun CoroutineScope.launchBuyinMonitor(httpClient: HttpClient): Job { - val job = launch { - /** - * The runInterruptible wrapper lets code without suspension - * points be cancel()'d. Without it, such code would ignore - * any call to cancel() and the test never return. - */ - runInterruptible { - buyinMonitor( - demobankName = "default", - accountToCredit = "exchange-0", - client = httpClient - ) - } - } - return job - } - /** - * Testing the buy-in monitor in all the HTTP scenarios, - * successful case, client's and server's error cases. - */ - @Test - fun buyinTest() { - // 1, testing the successful case. - /* First create an incoming fiat payment _at Nexus_. - This payment is addressed to the Nexus user whose - (Nexus) credentials will be used by Sandbox to fetch - new incoming fiat payments. */ - withTestDatabase { - prepSandboxDb(currency = "REGIO") - prepNexusDb() - // Credits 22 TESTKUDOS to "foo". This information comes - // normally from the fiat bank that Nexus is connected to. - val reservePub = "GX5H5RME193FDRCM1HZKERXXQ2K21KH7788CKQM8X6MYKYRBP8F0" - newNexusBankTransaction( - currency = "TESTKUDOS", - value = "22", - /** - * If the subject does NOT have the format of a public key, - * the conversion service does NOT wire any regio amount to the - * exchange, just ignores it. - */ - subject = reservePub - ) - // Start Nexus, to let it serve the fiat transaction. - testApplication { - val client = this.createClient { - followRedirects = false - } - application(nexusApp) - // Start the buy-in monitor to let it download the fiat transaction. - runBlocking { - val job = launchBuyinMonitor(client) - delay(1000L) // Lets the DB persist. - job.cancelAndJoin() - } - } - // Checking that exchange got the converted amount. - transaction { - /** - * Asserting that the exchange has only one incoming transaction. - * - * The Sandbox DB has two entries where the exchange IBAN shows - * as the 'creditorIban': one DBIT related to the "admin" account, - * and one CRDT related to the "exchange-0" account. Thus filtering - * the direction is also required. - */ - assert( - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.creditorIban eq "AT561936082973364859" and ( - BankAccountTransactionsTable.direction eq "CRDT" - ) - }.count() == 1L - ) - val boughtIn = BankAccountTransactionEntity.find { - BankAccountTransactionsTable.creditorIban eq "AT561936082973364859" - }.first() - // Asserting that the one incoming transaction has the wired reserve public key - // and the regional currency. - assert(boughtIn.subject == reservePub && boughtIn.currency == "REGIO") - } - // 2, testing the client side error case. - assertException<BuyinClientError>( - { - runBlocking { - /** - * As soon as the buy-in monitor requests again the history - * to Nexus, it'll get 400 from the mock client. - */ - launchBuyinMonitor(getMockedClient { respondBadRequest() }) - } - } - ) - /** - * 3, testing the server side error case. Here the monitor should - * NOT throw any error and instead keep operating normally. This allows - * Sandbox to tolerate server errors and retry the requests. - */ - runBlocking { - /** - * As soon as the buy-in monitor requests again the history - * to Nexus, it'll get 500 from the mock client. - */ - val job = launchBuyinMonitor(getMockedClient { respondError(HttpStatusCode.InternalServerError) }) - delay(1000L) - // Getting here means no exceptions. Can now cancel the service. - job.cancelAndJoin() - } - /** - * 4, testing the unhandled error case. This case is treated - * as a client error, to signal the calling logic to intervene. - */ - assertException<BuyinClientError>( - { - runBlocking { - /** - * As soon as the buy-in monitor requests again the history - * to Nexus, it'll get 307 from the mock client. - */ - launchBuyinMonitor(getMockedClient { respondRedirect() }) - } - } - ) - } - } - private fun CoroutineScope.launchCashoutMonitor(httpClient: HttpClient): Job { - val job = launch { - /** - * The runInterruptible wrapper lets code without suspension - * points be cancel()'d. Without it, such code would ignore - * any call to cancel() and the test never return. - */ - runInterruptible { - /** - * Without the runBlocking wrapper, cashoutMonitor doesn't - * compile. That's because it is a 'suspend' function and - * it needs a coroutine environment to execute; runInterruptible - * does NOT provide one. Furthermore, replacing runBlocking - * with "launch {}" would nullify runInterruptible, due to other - * jobs that cashoutMonitor internally launches and would escape - * the interruptible policy. - */ - runBlocking { cashoutMonitor(httpClient) } - } - } - return job - } - - // This function mocks a 500 response to a cash-out request. - private fun MockRequestHandleScope.mock500Response(): HttpResponseData { - return respondError(HttpStatusCode.InternalServerError) - } - // This function implements a mock server that checks the currency in the cash-out request. - private suspend fun MockRequestHandleScope.inspectCashoutCurrency(request: HttpRequestData): HttpResponseData { - // Asserting that the currency is indeed the FIAT. - return if (request.url.encodedPath == "/bank-accounts/foo/payment-initiations" && request.method == HttpMethod.Post) { - val body = jacksonObjectMapper().readTree(request.body.toByteArray()) - val postedAmount = body.get("amount").asText() - assert(parseAmount(postedAmount).currency == "FIAT") - respondOk("cash-out-nonce") - } else { - println("Cash-out monitor wrongly requested to: ${request.url}") - // This is a minimal Web server that support only the above endpoint. - respondError(status = HttpStatusCode.NotImplemented) - } - } - - /** - * Checks that the cash-out monitor reacts after - * a CRDT transaction arrives at the designated account. - */ - @Test - fun cashoutTest() { - withTestDatabase { - prepSandboxDb( - currency = "REGIO", - cashoutCurrency = "FIAT" - ) - prepNexusDb() - testApplication { - val client = this.createClient { - followRedirects = false - } - application(nexusApp) - // Mock server to intercept and inspect the cash-out request. - val checkCurrencyClient = HttpClient(MockEngine) { - followRedirects = false - engine { - addHandler { - request -> inspectCashoutCurrency(request) - } - } - } - // Starting the cash-out monitor with the mocked client. - runBlocking { - var job = launchCashoutMonitor(checkCurrencyClient) - // Following are various cases of a cash-out scenario. - - /** - * 1, Ordinary/successful case. We test that the conversion - * service sent indeed one request to Nexus and that the currency - * is correct. - */ - wireTransfer( - debitAccount = "foo", - creditAccount = "admin", - subject = "fiat #0", - amount = "REGIO:3" - ) - delay(1000L) // Lets DB persist the information. - // Checking now the Sandbox side, and namely that one - // cash-out operation got carried out. - transaction { - assert(CashoutSubmissionEntity.all().count() == 1L) - val op = CashoutSubmissionEntity.all().first() - /** - * The next assert witnesses that the mock client's - * currency assert succeeded. - */ - assert(op.maybeNexusResposnse == "cash-out-nonce") - } - /* 2, Internal server error case. We test that after requesting - * to a failing Nexus, the last accounted cash-out did NOT increase. - */ - job.cancelAndJoin() - val error500Client = HttpClient(MockEngine) { - followRedirects = false - engine { - addHandler { - request -> mock500Response() - } - } - } - job = launchCashoutMonitor(error500Client) - // Sending a new payment to trigger the conversion service. - wireTransfer( - debitAccount = "foo", - creditAccount = "admin", - subject = "fiat #1", - amount = "REGIO:2" - ) - delay(1000L) // Lets the reaction complete. - job.cancelAndJoin() - transaction { - val bankaccount = getBankAccountFromLabel("admin") - // Checks that the counter did NOT increase. - assert(bankaccount.lastFiatSubmission?.id?.value == 1L) - } - /* Removing now the mocked 500 response and checking that - * the problematic cash-out get then sent. */ - job = launchCashoutMonitor(client) // Should find the non cashed-out wire transfer and react. - delay(1000L) // Lets the reaction complete. - job.cancelAndJoin() - transaction { - val bankaccount = getBankAccountFromLabel("admin") - // Checks that the once failing cash-out did go through. - assert(bankaccount.lastFiatSubmission?.subject == "fiat #1") - } - /** - * 3, testing the client error case, where - * the conversion service is supposed to throw exception. - */ - assertException<CashoutClientError>({ - runBlocking { - launchCashoutMonitor( - httpClient = getMockedClient { - tech.libeufin.sandbox.logger.debug("MOCK 400") - /** - * This causes the cash-out request sent to Nexus to - * respond with 400. - */ - respondBadRequest() - } - ) - // Triggering now a cash-out operation via a new wire transfer to admin. - wireTransfer( - debitAccount = "foo", - creditAccount = "admin", - subject = "fiat #2", - amount = "REGIO:22" - ) - }}) - /** - * 4, checking a redirect response. Because this is an unhandled - * error case, it is treated as a client error. No need to wire a - * new cash-out to trigger a cash-out request, since the last failed - * one will be retried. - */ - assertException<CashoutClientError>({ - runBlocking { - launchCashoutMonitor( - getMockedClient { - /** - * This causes the cash-out request sent to Nexus to - * respond with 307 Temporary Redirect. - */ - respondRedirect() - } - ) - } - }) - /* 5, Mocking a network error. The previous failed cash-out - will again trigger the service to POST to Nexus. Here the - monitor tolerates the failure, as it's not due to its state - and should be temporary. - */ - var requestMade = false - job = launchCashoutMonitor( - getMockedClient { - requestMade = true - throw Exception("Network Issue.") - } - ) - delay(2000L) // Lets the reaction complete. - // asserting that the service is still running after the failed request. - assert(requestMade && job.isActive) - job.cancelAndJoin() - } - } - } - } -} -\ No newline at end of file diff --git a/nexus/src/test/kotlin/DbEventTest.kt b/nexus/src/test/kotlin/DbEventTest.kt @@ -1,71 +0,0 @@ -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import org.jetbrains.exposed.sql.transactions.transaction -import org.junit.Test -import tech.libeufin.util.NotificationsChannelDomains -import tech.libeufin.util.PostgresListenHandle -import tech.libeufin.util.buildChannelName -import tech.libeufin.util.postgresNotify - - -class DbEventTest { - /** - * LISTENs to one DB channel but only wakes up - * if the payload is how expected. - */ - @Test - fun payloadTest() { - withTestDatabase { - val listenHandle = PostgresListenHandle("X") - transaction { listenHandle.postgresListen() } - runBlocking { - launch { - val isArrived = listenHandle.waitOnIoDispatchersForPayload( - timeoutMs = 1000L, - expectedPayload = "Y" - ) - assert(isArrived) - } - launch { - delay(500L); // Ensures the wait helper runs first. - transaction { this.postgresNotify("X", "Y") } - } - } - } - } - - /** - * This function tests the NOTIFY sent by a Exposed's - * "new {}" overridden static method. - */ - @Test - fun automaticNotifyTest() { - withTestDatabase { - prepNexusDb() - val nexusTxChannel = buildChannelName( - NotificationsChannelDomains.LIBEUFIN_NEXUS_TX, - "foo" // bank account label. - ) - val listenHandle = PostgresListenHandle(nexusTxChannel) - transaction { listenHandle.postgresListen() } - runBlocking { - launch { - val isArrived = listenHandle.waitOnIODispatchers(timeoutMs = 1000L) - assert(isArrived) - } - launch { - delay(500L); // Ensures the wait helper runs first. - transaction { - newNexusBankTransaction( - "TESTKUDOS", - "2", - "unblocking event" - ) - } - } - } - } - } -} -\ No newline at end of file diff --git a/nexus/src/test/kotlin/EbicsTest.kt b/nexus/src/test/kotlin/EbicsTest.kt @@ -1,383 +0,0 @@ -import io.ktor.server.application.* -import io.ktor.http.* -import io.ktor.server.plugins.contentnegotiation.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import io.ktor.server.testing.* -import kotlinx.coroutines.runBlocking -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.transactions.transaction -import org.junit.Test -import org.w3c.dom.Document -import tech.libeufin.nexus.* -import tech.libeufin.nexus.bankaccount.addPaymentInitiation -import tech.libeufin.nexus.bankaccount.fetchBankAccountTransactions -import tech.libeufin.nexus.bankaccount.submitAllPaymentInitiations -import tech.libeufin.nexus.ebics.* -import tech.libeufin.nexus.iso20022.NexusPaymentInitiationData -import tech.libeufin.nexus.iso20022.createPain001document -import tech.libeufin.nexus.server.* -import tech.libeufin.sandbox.* -import tech.libeufin.util.* -import tech.libeufin.util.ebics_h004.EbicsRequest -import tech.libeufin.util.ebics_h004.EbicsResponse -import tech.libeufin.util.ebics_h004.EbicsTypes -import tech.libeufin.util.ebics_h005.Ebics3Request -import java.time.LocalDate -import java.time.ZonedDateTime - -/** - * These test cases run EBICS CCT and C52, mixing ordinary operations - * and some error cases. - */ - -/** - * Data to make the test server return for EBICS - * phases. Currently only init is supported. - */ -data class EbicsResponses( - val init: String, - val download: String? = null, - val receipt: String? = null -) - -/** - * Minimal server responding always the 'init' field of a EbicsResponses - * object to a download EBICS message. Suitable to set arbitrary data - * in said response. Signs the response assuming the client is the one - * created in MakeEnv.kt. - */ -fun getCustomEbicsServer(r: EbicsResponses, endpoint: String = "/ebicsweb"): Application.() -> Unit { - val ret: Application.() -> Unit = { - install(ContentNegotiation) { - register(ContentType.Text.Xml, XMLEbicsConverter()) - register(ContentType.Text.Plain, XMLEbicsConverter()) - } - routing { - post(endpoint) { - val requestDocument = this.call.receive<Document>() - val req = requestDocument.toObject<EbicsRequest>() - val clientKey = CryptoUtil.loadRsaPublicKey(userKeys.enc.public.encoded) - val msgId = EbicsOrderUtil.generateTransactionId() - val resp: EbicsResponse = if ( - req.header.mutable.transactionPhase == EbicsTypes.TransactionPhaseType.INITIALISATION - ) { - val payload = prepareEbicsPayload(r.init, clientKey) - EbicsResponse.createForDownloadInitializationPhase( - msgId, - 1, - 4096, - payload.second, // for key material - payload.first // actual payload - ) - } else { - // msgId doesn't have to match the one used for the init phase. - EbicsResponse.createForDownloadReceiptPhase(msgId, true) - } - val sigEbics = XMLUtil.signEbicsResponse( - resp, - CryptoUtil.loadRsaPrivateKey(bankKeys.auth.private.encoded) - ) - call.respond(sigEbics) - } - } - } - return ret -} - -class DownloadAndSubmit { - // Downloads a C52 report from the bank. - @Test - fun download() { - withNexusAndSandboxUser { - wireTransfer( - "admin", - "foo", - "default", - "Show up in logging!", - "TESTKUDOS:1" - ) - wireTransfer( - "admin", - "foo", - "default", - "Exist in logging!", - "TESTKUDOS:5" - ) - - testApplication { - application(sandboxApp) - runBlocking { - fetchBankAccountTransactions( - client, - fetchSpec = FetchSpecTimeRangeJson( - level = FetchLevel.REPORT, - start = "2020-10-10", - end = "3000-10-10", - bankConnection = "foo" - ), - accountId = "foo" - ) - } - transaction { - // FIXME: assert on the subject. - assert( - NexusBankTransactionEntity[1].amount == "1" && - NexusBankTransactionEntity[2].amount == "5" - ) - } - } - } - } - - // Uploads one payment instruction to the bank. - @Test - fun upload() { - withNexusAndSandboxUser { - testApplication { - application(sandboxApp) - val conn = EbicsBankConnectionProtocol() - runBlocking { - // Create Pain.001 to be submitted. - addPaymentInitiation( - Pain001Data( - creditorIban = BAR_USER_IBAN, - creditorBic = "SANDBOXX", - creditorName = "Tester", - subject = "test payment", - sum = "1", - currency = "TESTKUDOS" - ), - transaction { - NexusBankAccountEntity.findByName( - "foo" - ) ?: throw Exception("Test failed") - } - ) - conn.submitPaymentInitiation( - client, - 1L - ) - } - transaction { - val howMany = BankAccountTransactionEntity.find { - BankAccountTransactionsTable.debtorIban eq FOO_USER_IBAN and ( - BankAccountTransactionsTable.subject eq "test payment" - ) and (BankAccountTransactionsTable.direction eq "DBIT") - }.count() - assert(howMany == 1L) - } - } - } - } - - /** - * Upload one payment instruction charging one IBAN - * that does not belong to the requesting EBICS subscriber. - */ - @Test - fun unallowedDebtorIban() { - withNexusAndSandboxUser { - testApplication { - application(sandboxApp) - runBlocking { - val bar = transaction { NexusBankAccountEntity.findByName("bar") } - val painMessage = createPain001document( - NexusPaymentInitiationData( - debtorIban = bar!!.iban, - debtorBic = bar.bankCode, - debtorName = bar.accountHolder, - currency = "TESTKUDOS", - amount = "1", - creditorIban = getIban(), - creditorName = "Get", - creditorBic = "SANDBOXX", - paymentInformationId = "entropy-0", - preparationTimestamp = 1970L, - subject = "Unallowed", - messageId = "entropy-1", - endToEndId = null, - instructionId = null - ) - ) - val unallowedSubscriber = transaction { getEbicsSubscriberDetails("foo") } - var thrown = false - try { - doEbicsUploadTransaction( - client, - unallowedSubscriber, - EbicsUploadSpec( - orderType = "CCT", - isEbics3 = false, - orderParams = EbicsStandardOrderParams() - ), - painMessage.toByteArray(Charsets.UTF_8) - ) - } catch (e: EbicsProtocolError) { - if (e.ebicsTechnicalCode == - EbicsReturnCode.EBICS_ACCOUNT_AUTHORISATION_FAILED - ) - thrown = true - } - assert(thrown) - } - } - } - } - - /** - * Submits one pain.001 document with the wrong currency and checks - * that the bank responded with EBICS_PROCESSING_ERROR. - */ - @Test - fun unsupportedCurrency() { - withNexusAndSandboxUser { - testApplication { - application(sandboxApp) - runBlocking { - // Create Pain.001 to be submitted. - addPaymentInitiation( - Pain001Data( - creditorIban = getIban(), - creditorBic = "SANDBOXX", - creditorName = "Tester", - subject = "test payment", - sum = "1", - currency = "EUR" // EUR not supported. - ), - transaction { - NexusBankAccountEntity.findByName("foo") ?: throw Exception("Test failed") - } - ) - var thrown = false - try { - submitAllPaymentInitiations(client, "foo") - } catch (e: EbicsProtocolError) { - if (e.ebicsTechnicalCode == EbicsReturnCode.EBICS_PROCESSING_ERROR) - thrown = true - } - assert(thrown) - } - } - } - } - - /** - * Test that pain.001 amounts ALSO have max 2 fractional digits, like Taler's. - * That makes Sandbox however NOT completely compatible with the pain.001 standard, - * since this allows up to 5 fractional digits. */ - @Test - fun testFractionalDigits() { - withNexusAndSandboxUser { - testApplication { - application(sandboxApp) - runBlocking { - // Create Pain.001 with excessive amount. - addPaymentInitiation( - Pain001Data( - creditorIban = getIban(), - creditorBic = "SANDBOXX", - creditorName = "Tester", - subject = "test payment", - sum = "1.001", // wrong 3 fractional digits. - currency = "TESTKUDOS" - ), - "foo" - ) - assertException<EbicsProtocolError>({ submitAllPaymentInitiations(client, "foo") }) - } - } - } - } - - // Test the EBICS error message in case of debt threshold being surpassed - @Test - fun testDebit() { - withNexusAndSandboxUser { - testApplication { - application(sandboxApp) - runBlocking { - // Create Pain.001 with excessive amount. - addPaymentInitiation( - Pain001Data( - creditorIban = getIban(), - creditorBic = "SANDBOXX", - creditorName = "Tester", - subject = "test payment", - sum = "1000000", - currency = "TESTKUDOS" - ), - "foo" - ) - assertException<EbicsProtocolError>( - { submitAllPaymentInitiations(client, "foo") }, - { - val nexusEbicsException = it as EbicsProtocolError - assert( - EbicsReturnCode.EBICS_AMOUNT_CHECK_FAILED.errorCode == - nexusEbicsException.ebicsTechnicalCode?.errorCode - ) - } - ) - } - } - } - } -} - -class EbicsTest { - - @Test - fun genEbics3Upload() { - withTestDatabase { - prepNexusDb() - val foo = transaction { getEbicsSubscriberDetails("foo") } - val uploadDoc = createEbicsRequestForUploadInitialization( - subscriberDetails = foo, - ebics3OrderService = Ebics3Request.OrderDetails.Service().apply { - serviceName = "OTH" - scope = "BIL" - serviceOption = "CH002LMF" - messageName = Ebics3Request.OrderDetails.Service.MessageName().apply { - value = "csv" - } - }, - null, - prepareUploadPayload( - foo, - "foo".toByteArray(), - isEbics3 = true - ) - ) - assert(XMLUtil.validateFromString(uploadDoc)) - } - } - - /** - * Tests the validity of EBICS 3.0 messages. - */ - @Test - fun genEbics3Download() { - withTestDatabase { - prepNexusDb() - val foo = transaction { getEbicsSubscriberDetails("foo") } - val downloadDoc = createEbicsRequestForDownloadInitialization( - subscriberDetails = foo, - ebics3OrderService = Ebics3Request.OrderDetails.Service().apply { - messageName = Ebics3Request.OrderDetails.Service.MessageName().apply { - value = "camt.054" - version = "04" - } - scope = "CH" - serviceName = "REP" - container = Ebics3Request.OrderDetails.Service.Container().apply { - containerType = "ZIP" - } - }, - orderParams = EbicsStandardOrderParams() - ) - assert(XMLUtil.validateFromString(downloadDoc)) - } - } -} -\ No newline at end of file diff --git a/nexus/src/test/kotlin/Iso20022Test.kt b/nexus/src/test/kotlin/Iso20022Test.kt @@ -1,204 +0,0 @@ -package tech.libeufin.nexus -import CamtBankAccountEntry -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.http.* -import io.ktor.server.testing.* -import org.jetbrains.exposed.sql.transactions.transaction -import org.junit.Ignore -import org.junit.Test -import org.w3c.dom.Document -import poFiCamt054_2019_incoming -import poFiCamt054_2019_outgoing -import prepNexusDb -import tech.libeufin.nexus.iso20022.* -import tech.libeufin.nexus.server.EbicsDialects -import tech.libeufin.nexus.server.FetchLevel -import tech.libeufin.util.DestructionError -import tech.libeufin.util.XMLUtil -import tech.libeufin.util.destructXml -import tech.libeufin.util.getNow -import withTestDatabase -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -fun loadXmlResource(name: String): Document { - val classLoader = ClassLoader.getSystemClassLoader() - val res = classLoader.getResource(name) - if (res == null) { - throw Exception("resource $name not found"); - } - return XMLUtil.parseStringIntoDom(res.readText()) -} - -class Iso20022Test { - @Test(expected = DestructionError::class) - fun testUniqueChild() { - val xml = """ - <a> - <b/> - <b/> - </a> - """.trimIndent() - // when XML is invalid, DestructionError is thrown. - val doc = XMLUtil.parseStringIntoDom(xml) - destructXml(doc) { - requireRootElement("a") { - requireOnlyChild { } - } - } - } - - /** - * This test is currently ignored because the Camt sample being parsed - * contains a money movement which is not a singleton. This is not in - * line with the current parsing logic (that expects the style used by GLS) - */ - @Ignore - fun testTransactionsImport() { - val camt53 = loadXmlResource("iso20022-samples/camt.053/de.camt.053.001.02.xml") - val r = parseCamtMessage(camt53) - assertEquals("msg-001", r.messageId) - assertEquals("2020-07-03T12:44:40+05:30", r.creationDateTime) - assertEquals(CashManagementResponseType.Statement, r.messageType) - assertEquals(1, r.reports.size) - - // First Entry - assertTrue("100" == r.reports[0].entries[0].amount.value) - assertEquals("EUR", r.reports[0].entries[0].amount.currency) - assertEquals(CreditDebitIndicator.CRDT, r.reports[0].entries[0].creditDebitIndicator) - assertEquals(EntryStatus.BOOK, r.reports[0].entries[0].status) - assertEquals(null, r.reports[0].entries[0].entryRef) - assertEquals("acctsvcrref-001", r.reports[0].entries[0].accountServicerRef) - assertEquals("PMNT-RCDT-ESCT", r.reports[0].entries[0].bankTransactionCode) - assertNotNull(r.reports[0].entries[0].batches?.get(0)) - assertEquals( - "unstructured info one", - r.reports[0].entries[0].batches?.get(0)?.batchTransactions?.get(0)?.details?.unstructuredRemittanceInformation - ) - - // Second Entry - assertEquals( - "unstructured info across lines", - r.reports[0].entries[1].batches?.get(0)?.batchTransactions?.get(0)?.details?.unstructuredRemittanceInformation - ) - - // Third Entry - // Make sure that round-tripping of entry CamtBankAccountEntry JSON works - for (entry in r.reports.flatMap { it.entries }) { - val txStr = jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(entry) - val tx2 = jacksonObjectMapper().readValue(txStr, CamtBankAccountEntry::class.java) - val tx2Str = jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(tx2) - assertEquals(jacksonObjectMapper().readTree(txStr), jacksonObjectMapper().readTree(tx2Str)) - } - - println(jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(r)) - } - - /** - * PoFi timestamps aren't zoned, therefore the usual ZonedDateTime - * doesn't cover it. They must switch to (java.time.)LocalDateTime. - */ - @Test - fun parsePostFinanceDate() { - // 2011-12-03T10:15:30 from Java Doc as ISO_LOCAL_DATE_TIME. - // 2023-05-09T11:04:09 from PoFi - - getTimestampInMillis( - "2011-12-03T10:15:30", - EbicsDialects.POSTFINANCE.dialectName - ) - getTimestampInMillis( - "2011-12-03T10:15:30Z" // ! with timezone - ) - } - - @Test - fun parsePoFiCamt054() { - val doc = XMLUtil.parseStringIntoDom(poFiCamt054_2019_incoming) - parseCamtMessage(doc, dialect = "pf") - } - - /** - * Testing how outgoing payments get ingested and how their - * deduplication logic reacts, given that sometimes camt.054 - * was seen without the AcctSvcrRef. - */ - @Test - fun ingestPoFiCamt054_outgoing() { - val doc = XMLUtil.parseStringIntoDom(poFiCamt054_2019_outgoing) - withTestDatabase { - prepNexusDb() - transaction { assert(NexusBankTransactionEntity.all().count() == 0L) } - ingestCamtMessageIntoAccount( - "foo", - doc, - FetchLevel.NOTIFICATION, - dialect = "pf" - ) - transaction { assert(NexusBankTransactionEntity.all().count() == 1L) } - // Checking that the payment doesn't get stored twice. - ingestCamtMessageIntoAccount( - "foo", - doc, - FetchLevel.NOTIFICATION, - dialect = "pf" - ) - transaction { assert(NexusBankTransactionEntity.all().count() == 1L) } - } - } - - @Test - fun ingestPoFiCamt054() { - val doc = XMLUtil.parseStringIntoDom(poFiCamt054_2019_incoming) - withTestDatabase { - prepNexusDb() - // Checking that no transactions exist already in the database. - transaction { assert(NexusBankTransactionEntity.all().count() == 0L) } - ingestCamtMessageIntoAccount( - "foo", - doc, - FetchLevel.NOTIFICATION, - dialect = "pf" - ) - // Checking that now ONE transaction exist in the database. - transaction { assert(NexusBankTransactionEntity.all().count() == 1L) } - // Checking now that the same payment doesn't get ingested twice. - ingestCamtMessageIntoAccount( - "foo", - doc, - FetchLevel.NOTIFICATION, - dialect = "pf" - ) - // The count should have stayed the same. - transaction { assert(NexusBankTransactionEntity.all().count() == 1L) } - } - } - // Checks that the 2019 pain.001 version validates. - @Test - fun validatePain001() { - val pain001 = createPain001document( - NexusPaymentInitiationData( - debtorIban = "CH0889144371988976754", - debtorBic = "POFICHBEXXX", - debtorName = "Sample Debtor Name", - currency = "CHF", - amount = "5.00", - creditorIban = "CH9789144829733648596", - creditorName = "Sample Creditor Name", - creditorBic = "POFICHBEXXX", - paymentInformationId = "8aae7a2ded2f", - preparationTimestamp = getNow().toInstant().toEpochMilli(), - subject = "Unstructured remittance information", - instructionId = "InstructionId", - endToEndId = "71cfbdaf901f", - messageId = "2a16b35ed69c" - ), - dialect = "pf" - ) - val doc = XMLUtil.parseStringIntoDom(pain001) - assert(XMLUtil.validateFromDom(doc)) - } -} diff --git a/nexus/src/test/kotlin/JsonTest.kt b/nexus/src/test/kotlin/JsonTest.kt @@ -1,109 +0,0 @@ -import org.junit.Test -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.server.testing.* -import io.ktor.utils.io.jvm.javaio.* -import org.junit.Ignore -import tech.libeufin.nexus.server.CreateBankConnectionFromBackupRequestJson -import tech.libeufin.nexus.server.CreateBankConnectionFromNewRequestJson -import tech.libeufin.sandbox.NexusTransactions -import tech.libeufin.sandbox.sandboxApp - -enum class EnumTest { TEST } -data class EnumWrapper(val enum_test: EnumTest) - -class JsonTest { - - @Test - fun testJackson() { - val mapper = jacksonObjectMapper() - val backupObj = CreateBankConnectionFromBackupRequestJson( - name = "backup", passphrase = "secret", data = mapper.readTree("{}") - ) - val roundTrip = mapper.readValue<CreateBankConnectionFromBackupRequestJson>(mapper.writeValueAsString(backupObj)) - assert(roundTrip.data.toString() == "{}" && roundTrip.passphrase == "secret" && roundTrip.name == "backup") - val newConnectionObj = CreateBankConnectionFromNewRequestJson( - name = "new-connection", type = "ebics", data = mapper.readTree("{}") - ) - val roundTripNew = mapper.readValue<CreateBankConnectionFromNewRequestJson>(mapper.writeValueAsString(newConnectionObj)) - assert(roundTripNew.data.toString() == "{}" && roundTripNew.type == "ebics" && roundTripNew.name == "new-connection") - } - - // Tests how Jackson+Kotlin handle enum types. Fails if an exception is thrown - @Test - fun enumTest() { - val m = jacksonObjectMapper() - m.readValue<EnumWrapper>("{\"enum_test\":\"TEST\"}") - m.readValue<EnumTest>("\"TEST\"") - } - - /** - * Ignored because this test was only used to check - * the logs, as opposed to assert over values. Consider - * to remove the Ignore - */ - @Ignore - @Test - fun testSandboxJsonParsing() { - testApplication { - application(sandboxApp) - client.post("/admin/ebics/subscribers") { - basicAuth("admin", "foo") - contentType(ContentType.Application.Json) - setBody("{}") - } - } - } - - data class CamtEntryWrapper( - val unusedValue: String, - val camtData: CamtBankAccountEntry - ) - - // Testing whether generating and parsing a CaMt JSON mapping works. - @Test - fun testCamtRoundTrip() { - val obj = genNexusIncomingCamt( - CurrencyAmount(value = "2", currency = "EUR"), - subject = "round trip test" - ) - val str = jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(obj) - val map = jacksonObjectMapper().readValue(str, CamtBankAccountEntry::class.java) - assert(str == jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(map)) - } - - @Test - fun parseRawJson() { - val camtModel = """ - { - "amount" : "TESTKUDOS:22", - "creditDebitIndicator" : "CRDT", - "status" : "BOOK", - "bankTransactionCode" : "mock", - "batches" : [ { - "batchTransactions" : [ { - "amount" : "TESTKUDOS:22", - "creditDebitIndicator" : "CRDT", - "details" : { - "debtor" : { - "name" : "Mock Payer" - }, - "debtorAccount" : { - "iban" : "MOCK-IBAN" - }, - "debtorAgent" : { - "bic" : "MOCK-BIC" - }, - "unstructuredRemittanceInformation" : "raw" - } - } ] - } ] - } - """.trimIndent() - val obj = jacksonObjectMapper().readValue(camtModel, CamtBankAccountEntry::class.java) - assert(obj.getSingletonSubject() == "raw") - } -} -\ No newline at end of file diff --git a/nexus/src/test/kotlin/LetterFormatTest.kt b/nexus/src/test/kotlin/LetterFormatTest.kt @@ -1,25 +0,0 @@ -package tech.libeufin.nexus - -import org.junit.Test -import tech.libeufin.util.chunkString -import tech.libeufin.util.toHexString -import java.security.SecureRandom - -/** - * @param size in bits - */ -private fun getNonce(size: Int): ByteArray { - val sr = SecureRandom() - val ret = ByteArray(size / 8) - sr.nextBytes(ret) - return ret -} - -class LetterFormatTest { - - @Test - fun chunkerTest() { - val blob = getNonce(1024) - println(chunkString(blob.toHexString())) - } -} -\ No newline at end of file diff --git a/nexus/src/test/kotlin/MakeEnv.kt b/nexus/src/test/kotlin/MakeEnv.kt @@ -1,772 +0,0 @@ -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.ktor.client.* -import io.ktor.client.engine.mock.* -import io.ktor.client.request.* -import org.jetbrains.exposed.sql.Database -import org.jetbrains.exposed.sql.statements.api.ExposedBlob -import org.jetbrains.exposed.sql.transactions.TransactionManager -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.nexus.* -import tech.libeufin.nexus.dbCreateTables -import tech.libeufin.nexus.dbDropTables -import tech.libeufin.nexus.server.BankConnectionType -import tech.libeufin.nexus.server.FetchLevel -import tech.libeufin.nexus.server.FetchSpecAllJson -import tech.libeufin.sandbox.* -import tech.libeufin.util.* - -data class EbicsKeys( - val auth: CryptoUtil.RsaCrtKeyPair, - val enc: CryptoUtil.RsaCrtKeyPair, - val sig: CryptoUtil.RsaCrtKeyPair -) -// Convenience DB connection to switch to Postgresql: -val currentUser = System.getProperty("user.name") - -val BANK_IBAN = getIban() -val FOO_USER_IBAN = getIban() -val BAR_USER_IBAN = getIban() -val TCP_POSTGRES_CONN="jdbc:postgresql://localhost:5432/libeufincheck?user=$currentUser" -val UNIX_SOCKET_CONN= "postgresql:///libeufincheck" -val TEST_DB_CONN = UNIX_SOCKET_CONN - -val bankKeys = EbicsKeys( - auth = CryptoUtil.generateRsaKeyPair(2048), - enc = CryptoUtil.generateRsaKeyPair(2048), - sig = CryptoUtil.generateRsaKeyPair(2048) -) -val userKeys = EbicsKeys( - auth = CryptoUtil.generateRsaKeyPair(2048), - enc = CryptoUtil.generateRsaKeyPair(2048), - sig = CryptoUtil.generateRsaKeyPair(2048) -) - -fun assertWithPrint(cond: Boolean, msg: String) { - try { - assert(cond) - } catch (e: AssertionError) { - System.err.println(msg) - throw e - } -} - -// New versions of JUnit provide this! -inline fun <reified ExceptionType> assertException( - block: () -> Unit, - assertBlock: (Throwable) -> Unit = {} -) { - try { - block() - } catch (e: Throwable) { - assert(e.javaClass == ExceptionType::class.java) - // Expected type, try more custom asserts on it - assertBlock(e) - return - } - return assert(false) -} - -/** - * Run a block after connecting to the test database. - * Cleans up the DB file afterwards. - */ -fun withTestDatabase(keepData: Boolean = false, f: () -> Unit) { - if (!keepData) { - dbDropTables(TEST_DB_CONN) - tech.libeufin.sandbox.dbDropTables(TEST_DB_CONN) - } - f() -} - -val reportSpec: String = jacksonObjectMapper(). -writerWithDefaultPrettyPrinter(). -writeValueAsString( - FetchSpecAllJson( - level = FetchLevel.REPORT, - "foo" - ) -) - -fun prepNexusDb() { - dbCreateTables(TEST_DB_CONN) - transaction { - val u = NexusUserEntity.new { - username = "foo" - passwordHash = CryptoUtil.hashpw("foo") - superuser = true - } - val b = NexusUserEntity.new { - username = "bar" - passwordHash = CryptoUtil.hashpw("bar") - superuser = true - } - val c = NexusBankConnectionEntity.new { - connectionId = "bar" - owner = b - type = "x-libeufin-bank" - } - val d = NexusBankConnectionEntity.new { - connectionId = "foo" - owner = b - type = "ebics" - } - XLibeufinBankUserEntity.new { - username = "bar" - password = "bar" - // Only addressing mild cases where ONE slash ends the base URL. - baseUrl = "http://localhost/demobanks/default/access-api" - nexusBankConnection = c - } - tech.libeufin.nexus.EbicsSubscriberEntity.new { - // ebicsURL = "http://localhost:5000/ebicsweb" - ebicsURL = "http://localhost/ebicsweb" - hostID = "eufinSandbox" - partnerID = "foo" - userID = "foo" - systemID = "foo" - signaturePrivateKey = ExposedBlob(userKeys.sig.private.encoded) - encryptionPrivateKey = ExposedBlob(userKeys.enc.private.encoded) - authenticationPrivateKey = ExposedBlob(userKeys.auth.private.encoded) - nexusBankConnection = d - ebicsIniState = EbicsInitState.NOT_SENT - ebicsHiaState = EbicsInitState.NOT_SENT - bankEncryptionPublicKey = ExposedBlob(bankKeys.enc.public.encoded) - bankAuthenticationPublicKey = ExposedBlob(bankKeys.auth.public.encoded) - } - NexusBankAccountEntity.new { - bankAccountName = "foo" - iban = FOO_USER_IBAN - bankCode = "SANDBOXX" - defaultBankConnection = d - highestSeenBankMessageSerialId = 0 - accountHolder = "foo" - } - NexusBankAccountEntity.new { - bankAccountName = "bar" - iban = BAR_USER_IBAN - bankCode = "SANDBOXX" - defaultBankConnection = c - highestSeenBankMessageSerialId = 0 - accountHolder = "bar" - } - NexusScheduledTaskEntity.new { - resourceType = "bank-account" - resourceId = "foo" - this.taskCronspec = "* * *" // Every second. - this.taskName = "read-report" - this.taskType = "fetch" - this.taskParams = reportSpec - } - NexusScheduledTaskEntity.new { - resourceType = "bank-account" - resourceId = "foo" - this.taskCronspec = "* * *" // Every second. - this.taskName = "send-payment" - this.taskType = "submit" - this.taskParams = "{}" - } - // Giving 'foo' a Taler facade. - val f = FacadeEntity.new { - facadeName = "foo-facade" - type = "taler-wire-gateway" - creator = u - } - FacadeStateEntity.new { - bankAccount = "foo" - bankConnection = "foo" - currency = "TESTKUDOS" - reserveTransferLevel = "report" - facade = f - highestSeenMessageSerialId = 0 - } - // Giving 'bar' a Taler facade - val g = FacadeEntity.new { - facadeName = "bar-facade" - type = "taler-wire-gateway" - creator = b - } - FacadeStateEntity.new { - bankAccount = "bar" - bankConnection = "bar" // uses x-libeufin-bank connection. - currency = "TESTKUDOS" - reserveTransferLevel = "report" - facade = g - highestSeenMessageSerialId = 0 - } - } -} - -fun prepSandboxDb( - usersDebtLimit: Int = 1000, - currency: String = "TESTKUDOS", - cashoutCurrency: String = "EUR" -) { - tech.libeufin.sandbox.dbCreateTables(TEST_DB_CONN) - transaction { - val config = DemobankConfig( - currency = currency, - cashoutCurrency = cashoutCurrency, - bankDebtLimit = 10000, - usersDebtLimit = usersDebtLimit, - allowRegistrations = true, - demobankName = "default", - withSignupBonus = false, - captchaUrl = "http://example.com/", - suggestedExchangePayto = "payto://iban/${BAR_USER_IBAN}", - nexusBaseUrl = "http://localhost/", - usernameAtNexus = "foo", - passwordAtNexus = "foo", - enableConversionService = true - ) - insertConfigPairs(config) - val demoBank = DemobankConfigEntity.new { name = "default" } - BankAccountEntity.new { - iban = BANK_IBAN - label = "admin" // used by the wire helper - owner = "admin" // used by the person name finder - // For now, the model assumes always one demobank - this.demoBank = demoBank - } - EbicsHostEntity.new { - this.ebicsVersion = "3.0" - this.hostId = "eufinSandbox" - this.authenticationPrivateKey = ExposedBlob(bankKeys.auth.private.encoded) - this.encryptionPrivateKey = ExposedBlob(bankKeys.enc.private.encoded) - this.signaturePrivateKey = ExposedBlob(bankKeys.sig.private.encoded) - } - val bankAccount = BankAccountEntity.new { - iban = FOO_USER_IBAN - /** - * For now, keep same semantics of Pybank: a username - * is AS WELL a bank account label. In other words, it - * identifies a customer AND a bank account. - */ - label = "foo" - owner = "foo" - this.demoBank = demoBank - isPublic = false - } - BankAccountEntity.new { - iban = BAR_USER_IBAN - /** - * For now, keep same semantics of Pybank: a username - * is AS WELL a bank account label. In other words, it - * identifies a customer AND a bank account. - */ - label = "bar" - owner = "bar" - this.demoBank = demoBank - isPublic = false - } - tech.libeufin.sandbox.EbicsSubscriberEntity.new { - hostId = "eufinSandbox" - partnerId = "foo" - userId = "foo" - systemId = "foo" - signatureKey = EbicsSubscriberPublicKeyEntity.new { - rsaPublicKey = ExposedBlob(userKeys.sig.public.encoded) - state = KeyState.RELEASED - } - encryptionKey = EbicsSubscriberPublicKeyEntity.new { - rsaPublicKey = ExposedBlob(userKeys.enc.public.encoded) - state = KeyState.RELEASED - } - authenticationKey = EbicsSubscriberPublicKeyEntity.new { - rsaPublicKey = ExposedBlob(userKeys.auth.public.encoded) - state = KeyState.RELEASED - } - state = SubscriberState.INITIALIZED - nextOrderID = 1 - this.bankAccount = bankAccount - } - DemobankCustomerEntity.new { - username = "foo" - passwordHash = CryptoUtil.hashpw("foo") - name = "Foo" - cashout_address = "payto://iban/OUTSIDE" - } - DemobankCustomerEntity.new { - username = "bar" - passwordHash = CryptoUtil.hashpw("bar") - name = "Bar" - cashout_address = "payto://iban/FIAT" - } - // Note: exchange doesn't have the cash-out address. - DemobankCustomerEntity.new { - username = "exchange-0" - passwordHash = CryptoUtil.hashpw("foo") - name = "Exchange" - } - BankAccountEntity.new { - iban = "AT561936082973364859" - /** - * For now, keep same semantics of Pybank: a username - * is AS WELL a bank account label. In other words, it - * identifies a customer AND a bank account. - */ - label = "exchange-0" - owner = "exchange-0" - this.demoBank = demoBank - isPublic = false - } - } -} - -fun withNexusAndSandboxUser(f: () -> Unit) { - withTestDatabase { - prepNexusDb() - prepSandboxDb() - f() - } -} - -// Creates tables, the default demobank, and admin's bank account. -fun withSandboxTestDatabase(f: () -> Unit) { - withTestDatabase { - tech.libeufin.sandbox.dbCreateTables(TEST_DB_CONN) - transaction { - val config = DemobankConfig( - currency = "TESTKUDOS", - cashoutCurrency = "NOTUSED", - bankDebtLimit = 10000, - usersDebtLimit = 1000, - allowRegistrations = true, - demobankName = "default", - withSignupBonus = false, - captchaUrl = "http://example.com/" // unused - ) - insertConfigPairs(config) - val d = DemobankConfigEntity.new { name = "default" } - // admin's bank account. - BankAccountEntity.new { - iban = BANK_IBAN - label = "admin" // used by the wire helper - owner = "admin" // used by the person name finder - // For now, the model assumes always one demobank - this.demoBank = d - } - } - f() - } -} - -fun newNexusBankTransaction( - currency: String, - value: String, - subject: String, - creditorAcct: String = "foo", - connType: BankConnectionType = BankConnectionType.EBICS -) { - val jDetails: String = when(connType) { - BankConnectionType.EBICS -> { - jacksonObjectMapper( - ).writerWithDefaultPrettyPrinter( - ).writeValueAsString( - genNexusIncomingCamt( - amount = CurrencyAmount(currency,value), - subject = subject - ) - ) - } - /** - * Note: x-libeufin-bank ALSO stores the transactions in the - * CaMt representation, hence this branch should be removed. - */ - BankConnectionType.X_LIBEUFIN_BANK -> { - jacksonObjectMapper( - ).writerWithDefaultPrettyPrinter( - ).writeValueAsString(genNexusIncomingCamt( - amount = CurrencyAmount(currency, value), - subject = subject - )) - } - else -> throw Exception("Unsupported connection type: ${connType.typeName}") - } - transaction { - NexusBankTransactionEntity.new { - bankAccount = NexusBankAccountEntity.findByName(creditorAcct)!! - accountTransactionId = "mock" - creditDebitIndicator = "CRDT" - this.currency = currency - this.amount = value - status = EntryStatus.BOOK - transactionJson = jDetails - } - } -} - -/** - * This function generates the Nexus JSON model of one transaction - * as if it got downloaded via one x-libeufin-bank connection. The - * non given values are either resorted from other sources by Nexus, - * or actually not useful so far. - */ -private fun genNexusIncomingXLibeufinBank( - amount: CurrencyAmount, - subject: String -): XLibeufinBankTransaction = - XLibeufinBankTransaction( - creditorIban = "NOTUSED", - creditorBic = null, - creditorName = "Not Used", - debtorIban = "NOTUSED", - debtorBic = null, - debtorName = "Not Used", - amount = amount.value, - currency = amount.currency, - subject = subject, - date = "0", - uid = "not-used", - direction = XLibeufinBankDirection.CREDIT - ) -/** - * This function generates the Nexus JSON model of one transaction - * as if it got downloaded via one Ebics connection. The non given - * values are either resorted from other sources by Nexus, or actually - * not useful so far. - */ -fun genNexusIncomingCamt( - amount: CurrencyAmount, - subject: String, -): CamtBankAccountEntry = - CamtBankAccountEntry( - amount = amount, - creditDebitIndicator = CreditDebitIndicator.CRDT, - status = EntryStatus.BOOK, - bankTransactionCode = "mock", - valueDate = null, - bookingDate = null, - accountServicerRef = null, - entryRef = null, - currencyExchange = null, - counterValueAmount = null, - instructedAmount = null, - batches = listOf( - Batch( - paymentInformationId = null, - messageId = null, - batchTransactions = listOf( - BatchTransaction( - amount = amount, - creditDebitIndicator = CreditDebitIndicator.CRDT, - details = TransactionDetails( - unstructuredRemittanceInformation = subject, - debtor = PartyIdentification( - name = "Mock Payer", - countryOfResidence = null, - privateId = null, - organizationId = null, - postalAddress = null, - otherId = null - ), - debtorAccount = CashAccount( - iban = "MOCK-IBAN", - name = null, - currency = null, - otherId = null - ), - debtorAgent = AgentIdentification( - bic = "MOCK-BIC", - lei = null, - clearingSystemMemberId = null, - clearingSystemCode = null, - proprietaryClearingSystemCode = null, - postalAddress = null, - otherId = null, - name = null - ), - creditor = null, - creditorAccount = null, - creditorAgent = null, - ultimateCreditor = null, - ultimateDebtor = null, - purpose = null, - proprietaryPurpose = null, - currencyExchange = null, - instructedAmount = null, - counterValueAmount = null, - interBankSettlementAmount = null, - returnInfo = null - ) - ) - ) - ) - ) - ) - -val poFiCamt054_2019_outgoing: String = """ - <?xml version="1.0" encoding="UTF-8"?> - <Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.054.001.08" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="urn:iso:std:iso:20022:tech:xsd:camt.054.001.08 file:///C:/Users/burkhalterl/Documents/Musterfiles%20ISOV19/Schemen/camt.054.001.08.xsd"> - <BkToCstmrDbtCdtNtfctn> - <GrpHdr> - <MsgId>20200618375204295372463</MsgId> - <CreDtTm>2022-03-10T23:40:14</CreDtTm> - <MsgPgntn> - <PgNb>1</PgNb> - <LastPgInd>true</LastPgInd> - </MsgPgntn> - <AddtlInf>SPS/2.0/PROD</AddtlInf> - </GrpHdr> - <Ntfctn> - <Id>20200618375204295372465</Id> - <CreDtTm>2022-03-10T23:40:14</CreDtTm> - <FrToDt> - <FrDtTm>2022-03-10T00:00:00</FrDtTm> - <ToDtTm>2022-03-10T23:59:59</ToDtTm> - </FrToDt> - <Acct> - <Id> - <IBAN>${FOO_USER_IBAN}</IBAN> - </Id> - <Ccy>CHF</Ccy> - <Ownr> - <Nm>Robert Schneider SA Grands magasins Biel/Bienne</Nm> - </Ownr> - </Acct> - <Ntry> - <NtryRef>CH2909000000250094239</NtryRef> - <Amt Ccy="CHF">522.10</Amt> - <CdtDbtInd>DBIT</CdtDbtInd> - <RvslInd>false</RvslInd> - <Sts> - <Cd>BOOK</Cd> - </Sts> - <BookgDt> - <Dt>2022-03-10</Dt> - </BookgDt> - <ValDt> - <Dt>2022-03-10</Dt> - </ValDt> - <AcctSvcrRef>1000000000000000</AcctSvcrRef> - <BkTxCd> - <Domn> - <Cd>PMNT</Cd> - <Fmly> - <Cd>RCDT</Cd> - <SubFmlyCd>ATXN</SubFmlyCd> - </Fmly> - </Domn> - </BkTxCd> - <NtryDtls> - <Btch> - <NbOfTxs>1</NbOfTxs> - </Btch> - <TxDtls> - <Refs> - <InstrId>1006265-25bbb3b1a</InstrId> - <EndToEndId>client-generated</EndToEndId> - <UETR>b009c997-97b3-4a9c-803c-d645a7276bf0</UETR> - <Prtry> - <Tp>00</Tp> - <Ref>00000000000000000000020</Ref> - </Prtry> - </Refs> - <Amt Ccy="CHF">522.10</Amt> - <CdtDbtInd>DBIT</CdtDbtInd> - <BkTxCd> - <Domn> - <Cd>PMNT</Cd> - <Fmly> - <Cd>RCDT</Cd> - <SubFmlyCd>ATXN</SubFmlyCd> - </Fmly> - </Domn> - </BkTxCd> - <RltdPties> - <Dbtr> - <Pty> - <Nm>Bernasconi Maria</Nm> - <PstlAdr> - <AdrLine>Place de la Gare 12</AdrLine> - <AdrLine>2502 Biel/Bienne</AdrLine> - </PstlAdr> - </Pty> - </Dbtr> - <DbtrAcct> - <Id> - <IBAN>CH5109000000250092291</IBAN> - </Id> - </DbtrAcct> - <CdtrAcct> - <Id> - <IBAN>CH2909000000250094239</IBAN> - </Id> - </CdtrAcct> - </RltdPties> - <RltdAgts> - <DbtrAgt> - <FinInstnId> - <BICFI>POFICHBEXXX</BICFI> - <Nm>POSTFINANCE AG</Nm> - <PstlAdr> - <AdrLine>MINGERSTRASSE 20</AdrLine> - <AdrLine>3030 BERNE</AdrLine> - </PstlAdr> - </FinInstnId> - </DbtrAgt> - </RltdAgts> - <RmtInf> - <Strd> - <AddtlRmtInf>?REJECT?0</AddtlRmtInf> - <AddtlRmtInf>?ERROR?000</AddtlRmtInf> - </Strd> - <Ustrd>Reserve pub.</Ustrd> - </RmtInf> - <RltdDts> - <AccptncDtTm>2022-03-10T20:00:00</AccptncDtTm> - </RltdDts> - </TxDtls> - </NtryDtls> - <AddtlNtryInf>GUTSCHRIFT AUFTRAGGEBER: Bernasconi Maria Place de la Gare 12 2502 Biel/Bienne REFERENZEN: NOTPROVIDED 1006265-25bbb3b1a 2000000000000000</AddtlNtryInf> - </Ntry> - </Ntfctn> - </BkToCstmrDbtCdtNtfctn> - </Document> -""".trimIndent() - -// Comes from a "mit Sammelbuchung" sample. -// "mit Einzelbuchung" sample didn't have the "Ustrd" -// See: https://www.postfinance.ch/de/support/services/dokumente/musterfiles-fuer-geschaeftskunden.html -val poFiCamt054_2019_incoming: String = """ -<?xml version="1.0" encoding="UTF-8"?> -<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.054.001.08" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="urn:iso:std:iso:20022:tech:xsd:camt.054.001.08 file:///C:/Users/burkhalterl/Documents/Musterfiles%20ISOV19/Schemen/camt.054.001.08.xsd"> - <BkToCstmrDbtCdtNtfctn> - <GrpHdr> - <MsgId>20200618375204295372463</MsgId> - <CreDtTm>2022-03-08T23:31:31</CreDtTm> - <MsgPgntn> - <PgNb>1</PgNb> - <LastPgInd>true</LastPgInd> - </MsgPgntn> - <AddtlInf>SPS/2.0/PROD</AddtlInf> - </GrpHdr> - <Ntfctn> - <Id>20200618375204295372465</Id> - <CreDtTm>2022-03-08T23:31:31</CreDtTm> - <FrToDt> - <FrDtTm>2022-03-08T00:00:00</FrDtTm> - <ToDtTm>2022-03-08T23:59:59</ToDtTm> - </FrToDt> - <Acct> - <Id> - <IBAN>${FOO_USER_IBAN}</IBAN> - </Id> - <Ccy>CHF</Ccy> - <Ownr> - <Nm>Robert Schneider SA Grands magasins Biel/Bienne</Nm> - </Ownr> - </Acct> - <Ntry> - <NtryRef>CH2909000000250094239</NtryRef> - <Amt Ccy="CHF">501.05</Amt> - <CdtDbtInd>CRDT</CdtDbtInd> - <RvslInd>false</RvslInd> - <Sts> - <Cd>BOOK</Cd> - </Sts> - <BookgDt> - <Dt>2022-03-08</Dt> - </BookgDt> - <ValDt> - <Dt>2022-03-08</Dt> - </ValDt> - <AcctSvcrRef>1000000000000000</AcctSvcrRef> - <BkTxCd> - <Domn> - <Cd>PMNT</Cd> - <Fmly> - <Cd>RCDT</Cd> - <SubFmlyCd>AUTT</SubFmlyCd> - </Fmly> - </Domn> - </BkTxCd> - <NtryDtls> - <Btch> - <NbOfTxs>1</NbOfTxs> - </Btch> - <TxDtls> - <Refs> - <AcctSvcrRef>2000000000000000</AcctSvcrRef> - <InstrId>1006265-25bbb3b1a</InstrId> - <EndToEndId>NOTPROVIDED</EndToEndId> - <UETR>b009c997-97b3-4a9c-803c-d645a7276b0</UETR> - <Prtry> - <Tp>00</Tp> - <Ref>00000000000000000000020</Ref> - </Prtry> - </Refs> - <Amt Ccy="CHF">501.05</Amt> - <CdtDbtInd>CRDT</CdtDbtInd> - <BkTxCd> - <Domn> - <Cd>PMNT</Cd> - <Fmly> - <Cd>RCDT</Cd> - <SubFmlyCd>AUTT</SubFmlyCd> - </Fmly> - </Domn> - </BkTxCd> - <RltdPties> - <Dbtr> - <Pty> - <Nm>Bernasconi Maria</Nm> - <PstlAdr> - <AdrLine>Place de la Gare 12</AdrLine> - <AdrLine>2502 Biel/Bienne</AdrLine> - </PstlAdr> - </Pty> - </Dbtr> - <DbtrAcct> - <Id> - <IBAN>CH5109000000250092291</IBAN> - </Id> - </DbtrAcct> - <CdtrAcct> - <Id> - <IBAN>CH2909000000250094239</IBAN> - </Id> - </CdtrAcct> - </RltdPties> - <RltdAgts> - <DbtrAgt> - <FinInstnId> - <BICFI>POFICHBEXXX</BICFI> - <Nm>POSTFINANCE AG</Nm> - <PstlAdr> - <AdrLine>MINGERSTRASSE , 20</AdrLine> - <AdrLine>3030 BERN</AdrLine> - </PstlAdr> - </FinInstnId> - </DbtrAgt> - </RltdAgts> - <RmtInf> - <Ustrd>Muster</Ustrd> - <Ustrd> Musterfile</Ustrd> - <Strd> - <AddtlRmtInf>?REJECT?0</AddtlRmtInf> - <AddtlRmtInf>?ERROR?000</AddtlRmtInf> - </Strd> - </RmtInf> - <RltdDts> - <AccptncDtTm>2022-03-08T20:00:00</AccptncDtTm> - </RltdDts> - </TxDtls> - </NtryDtls> - <AddtlNtryInf>SAMMELGUTSCHRIFT FÜR KONTO: CH2909000000250094239 VERARBEITUNG VOM 08.03.2022 PAKET ID: 200000000000XXX</AddtlNtryInf> - </Ntry> - </Ntfctn> - </BkToCstmrDbtCdtNtfctn> -</Document> -""".trimIndent() - -// Abstracts the mock handler installation. -fun getMockedClient(handler: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData): HttpClient { - return HttpClient(MockEngine) { - followRedirects = false - engine { - addHandler { - request -> handler(request) - } - } - } -} -\ No newline at end of file diff --git a/nexus/src/test/kotlin/NexusApiTest.kt b/nexus/src/test/kotlin/NexusApiTest.kt @@ -1,272 +0,0 @@ -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.ktor.client.engine.mock.* -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.config.* -import io.ktor.server.testing.* -import io.netty.handler.codec.http.HttpResponseStatus -import kotlinx.coroutines.async -import kotlinx.coroutines.delay -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.runBlocking -import org.jetbrains.exposed.sql.transactions.transaction -import org.junit.Test -import tech.libeufin.nexus.PaymentInitiationEntity -import tech.libeufin.nexus.bankaccount.ingestBankMessagesIntoAccount -import tech.libeufin.nexus.getConnectionPlugin -import tech.libeufin.nexus.iso20022.ingestCamtMessageIntoAccount -import tech.libeufin.nexus.server.* -import tech.libeufin.sandbox.BankAccountTransactionEntity -import tech.libeufin.sandbox.BankAccountTransactionsTable -import tech.libeufin.sandbox.sandboxApp -import tech.libeufin.sandbox.wireTransfer - -/** - * This class tests the API offered by Nexus, - * documented here: https://docs.taler.net/libeufin/api-nexus.html - */ -class NexusApiTest { - private val jMapper = ObjectMapper() - // Testing long-polling on GET /transactions - @Test - fun getTransactions() { - withTestDatabase { - prepNexusDb() - testApplication { - application(nexusApp) - /** - * Requesting /transactions with long polling, and assert that - * the response arrives _after_ the unblocking INSERT into the - * database. - */ - val longPollMs = 5000 - runBlocking { - val requestJob = async { - client.get("/bank-accounts/foo/transactions?long_poll_ms=$longPollMs") { - basicAuth("foo", "foo") - contentType(ContentType.Application.Json) - } - } - /** - * The following delay ensures that the payment below - * gets inserted after the client has issued the long - * polled request above (and it is therefore waiting) - */ - delay(2000) - // Ensures that the request is active _before_ the - // upcoming payment. This ensures that the request - // didn't find already another payment in the database. - requestJob.ensureActive() - newNexusBankTransaction( - currency = "TESTKUDOS", - value = "2", - subject = "first" - ) - val R = requestJob.await() - // Ensures that the request did NOT wait all the timeout - assert((R.responseTime.timestamp - R.requestTime.timestamp) < longPollMs) - val body = jacksonObjectMapper().readTree(R.bodyAsText()) - // Ensures that the unblocking payment exists in the response. - val tx = body.get("transactions") - assert(tx.isArray && tx.size() == 1) - } - } - } - } - @Test - fun facadeIdempotence() { - val facadeData = """{ - "name": "foo-facade", - "type": "taler-wire-gateway", - "config": { - "bankAccount": "foo", - "bankConnection": "foo", - "reserveTransferLevel": "report", - "currency": "TESTKUDOS" - } - }""".trimIndent() - withTestDatabase { - prepNexusDb() - testApplication { - application(nexusApp) - client.post("/facades") { - expectSuccess = true - basicAuth("foo", "foo") - contentType(ContentType.Application.Json) - setBody(facadeData) - } - // Changing one detail, and expecting 409 Conflict. - var resp = client.post("/facades") { - expectSuccess = false - basicAuth("foo", "foo") - contentType(ContentType.Application.Json) - setBody(facadeData.replace( - "taler-wire-gateway", - "anastasis" - )) - } - assert(resp.status.value == HttpStatusCode.Conflict.value) - // Changing a value deeper in the request object. - resp = client.post("/facades") { - expectSuccess = false - basicAuth("foo", "foo") - contentType(ContentType.Application.Json) - setBody(facadeData.replace( - "report", - "statement" - )) - } - assert(resp.status.value == HttpStatusCode.Conflict.value) - } - } - } - // Testing basic operations on facades. - @Test - fun facades() { - // Deletes the facade (created previously by MakeEnv.kt) - withTestDatabase { - prepNexusDb() - testApplication { - application(nexusApp) - client.delete("/facades/foo-facade") { - basicAuth("foo", "foo") - expectSuccess = true - } - } - } - } - - // Testing the creation of scheduled tasks. - @Test - fun schedule() { - withTestDatabase { - prepNexusDb() - testApplication { - application(nexusApp) - // POSTing omitted 'params', to test whether Nexus - // expects it as 'null' for a 'submit' task. - client.post("/bank-accounts/foo/schedule") { - contentType(ContentType.Application.Json) - expectSuccess = true - basicAuth("foo", "foo") - setBody("""{ - "name": "send-payments", - "cronspec": "* * *", - "type": "submit", - "params": null - }""".trimIndent()) - } - } - } - } - /** - * Testing the idempotence of payment submissions. That - * helps Sandbox not to create multiple payment initiations - * in case it fails at keeping track of what it submitted - * already. - */ - @Test - fun paymentInitIdempotence() { - withTestDatabase { - prepNexusDb() - testApplication { - application(nexusApp) - // Check no pay. ini. exist. - transaction { PaymentInitiationEntity.all().count() == 0L } - // Create one. - fun f(futureThis: HttpRequestBuilder, subject: String = "idempotence pay. init. test") { - futureThis.basicAuth("foo", "foo") - futureThis.expectSuccess = true - futureThis.contentType(ContentType.Application.Json) - futureThis.setBody(""" - {"iban": "TESTIBAN", - "bic": "SANDBOXX", - "name": "TEST NAME", - "amount": "TESTKUDOS:3", - "subject": "$subject", - "uid": "salt" - } - """.trimIndent()) - } - val R = client.post("/bank-accounts/foo/payment-initiations") { f(this) } - println(jMapper.readTree(R.bodyAsText()).get("uuid")) - // Submit again - client.post("/bank-accounts/foo/payment-initiations") { f(this) } - // Checking that Nexus serves it. - client.get("/bank-accounts/foo/payment-initiations/1") { - basicAuth("foo", "foo") - expectSuccess = true - } - // Checking that the database has only one, despite the double submission. - transaction { - assert(PaymentInitiationEntity.all().count() == 1L) - } - /** - * Causing a conflict by changing one payment detail - * (the subject in this case) but not the "uid". - */ - val maybeConflict = client.post("/bank-accounts/foo/payment-initiations") { - f(this, "different-subject") - expectSuccess = false - } - assert(maybeConflict.status.value == HttpStatusCode.Conflict.value) - } - } - } - @Test - fun timeRangeFetch() { - withTestDatabase { - prepSandboxDb() - prepNexusDb() - val ref = wireTransfer( - "admin", - "foo", - subject = "past payment", - amount = "TESTKUDOS:30" - ) - transaction { - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.accountServicerReference eq ref - }.first().date = 1577833200000L // Jan, 1st, 2020 - } - testApplication { - application(sandboxApp) - val conn = getConnectionPlugin("ebics") - - // Asking a time range where the one payment is expected to exist - conn.fetchTransactions( - fetchSpec = FetchSpecTimeRangeJson( - FetchLevel.REPORT, - start = "2019-12-31", - end = "2020-01-02", - bankConnection = null - ), - accountId = "foo", - bankConnectionId = "foo", - client = client - ) - val res = ingestBankMessagesIntoAccount("foo", "foo") - assert(res.newTransactions == 1) - // Asking a time range where the one payment is NOT expected to exist - conn.fetchTransactions( - fetchSpec = FetchSpecTimeRangeJson( - FetchLevel.REPORT, - start = "2019-10-31", - end = "2019-11-30", - bankConnection = null - ), - accountId = "foo", - bankConnectionId = "foo", - client = client - ) - val resNoData = ingestBankMessagesIntoAccount("foo", "foo") - assert(resNoData.downloadedTransactions == 0) - assert(resNoData.newTransactions == 0) - } - } - } -} -\ No newline at end of file diff --git a/nexus/src/test/kotlin/PainTest.kt b/nexus/src/test/kotlin/PainTest.kt @@ -1,33 +0,0 @@ -import ch.qos.logback.core.joran.spi.XMLUtil -import org.junit.Test -import tech.libeufin.nexus.iso20022.NexusPaymentInitiationData -import tech.libeufin.nexus.iso20022.createPain001document -import kotlin.test.assertTrue - -class PainTest { - - @Test - fun validationTest() { - val xml = createPain001document( - NexusPaymentInitiationData( - debtorIban = "GB33BUKB20201222222222", - debtorBic = "BUKBGB33", - debtorName = "Oliver Smith", - currency = "EUR", - amount = "1", - creditorIban = "GB33BUKB20201222222222", - creditorName = "Oliver Smith", - messageId = "message id", - paymentInformationId = "payment information id", - preparationTimestamp = 0, - subject = "subject", - instructionId = "instruction id", - endToEndId = "end to end id", - creditorBic = "BUKBGB33" - ) - ) - assertTrue { - tech.libeufin.util.XMLUtil.validateFromString(xml) - } - } -} -\ No newline at end of file diff --git a/nexus/src/test/kotlin/PostFinance.kt b/nexus/src/test/kotlin/PostFinance.kt @@ -1,158 +0,0 @@ -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.github.ajalt.clikt.core.* -import com.github.ajalt.clikt.parameters.options.default -import com.github.ajalt.clikt.parameters.options.option -import io.ktor.client.* -import kotlinx.coroutines.runBlocking -import org.jetbrains.exposed.sql.transactions.transaction -import tech.libeufin.nexus.bankaccount.addPaymentInitiation -import tech.libeufin.nexus.bankaccount.fetchBankAccountTransactions -import tech.libeufin.nexus.ebics.EbicsUploadSpec -import tech.libeufin.nexus.ebics.doEbicsUploadTransaction -import tech.libeufin.nexus.ebics.getEbicsSubscriberDetails -import tech.libeufin.nexus.getBankAccount -import tech.libeufin.nexus.getBankConnection -import tech.libeufin.nexus.getConnectionPlugin -import tech.libeufin.nexus.getNexusUser -import tech.libeufin.nexus.server.* -import tech.libeufin.util.ebics_h005.Ebics3Request -import java.io.BufferedReader -import java.io.File -import kotlin.system.exitProcess - -// Asks a camt.054 to the bank. -private fun downloadPayments() { - val httpClient = HttpClient() - runBlocking { - fetchBankAccountTransactions( - client = httpClient, - fetchSpec = FetchSpecLatestJson( - level = FetchLevel.NOTIFICATION, - bankConnection = null - ), - accountId = "foo" - ) - } -} - -/* Simulates one incoming payment for the 'payee' argument. - * It pays the test platform's bank account if none is found. - * The QRR format is NOT used in Taler, it is just convenient. - * */ -private fun uploadQrrPayment(maybePayee: String? = null) { - val payee = if (maybePayee == null) { - val localAccount = getBankAccount("foo") - localAccount.iban - } else maybePayee - val httpClient = HttpClient() - val qrr = """ - Product;Channel;Account;Currency;Amount;Reference;Name;Street;Number;Postcode;City;Country;DebtorAddressLine;DebtorAddressLine;DebtorAccount;ReferenceType;UltimateDebtorName;UltimateDebtorStreet;UltimateDebtorNumber;UltimateDebtorPostcode;UltimateDebtorTownName;UltimateDebtorCountry;UltimateDebtorAddressLine;UltimateDebtorAddressLine;RemittanceInformationText - QRR;PO;$payee;CHF;33;;D009;Musterstrasse;1;1111;Musterstadt;CH;;;;NON;D009;Musterstrasse;1;1111;Musterstadt;CH;;;Taler-Demo - """.trimIndent() - runBlocking { - doEbicsUploadTransaction( - httpClient, - getEbicsSubscriberDetails("postfinance"), - EbicsUploadSpec( - ebics3Service = Ebics3Request.OrderDetails.Service().apply { - serviceName = "OTH" - scope = "BIL" - serviceOption = "CH002LMF" - messageName = Ebics3Request.OrderDetails.Service.MessageName().apply { - value = "csv" - } - }, - isEbics3 = true - ), - qrr.toByteArray(Charsets.UTF_8) - ) - } -} - -/** - * Submits a pain.001 version 2019 message to the bank. - * - * Causes one DBIT payment to show up in the camt.054. This one - * however lacks the AcctSvcrRef, so other ways to pin it are needed. - * Notably, EndToEndId is mandatory in pain.001 _and_ is controlled - * by the sender. Hence, the sender can itself ensure the EndToEndId - * uniqueness. - */ -private fun uploadPain001Payment( - subject: String, - creditorIban: String = "CH9300762011623852957" // random creditor -) { - transaction { - addPaymentInitiation( - Pain001Data( - creditorIban = creditorIban, - creditorBic = "POFICHBEXXX", - creditorName = "Muster Frau", - sum = "2", - currency = "CHF", - subject = subject, - endToEndId = "Zufall" - ), - getBankAccount("foo").bankAccountName - ) - } - val ebicsConn = getConnectionPlugin("ebics") - val httpClient = HttpClient() - runBlocking { ebicsConn.submitPaymentInitiation(httpClient, 1L) } -} - -class PostFinanceCommand : CliktCommand() { - private val myIban by option( - help = "IBAN as assigned by the PostFinance test platform." - ).default("CH9789144829733648596") - override fun run() { prepare(myIban) } -} -class Download : CliktCommand("Download the latest camt.054 from the bank") { - // Ask 'notification' to the bank. - override fun run() { - // uploadPain001Payment("auto") - downloadPayments() - } -} - -class Upload : CliktCommand("Upload a pain.001 to the bank") { - private val subject by option(help = "Payment subject").default("Muster Zahlung") - override fun run() { uploadPain001Payment(subject) } -} - -class GenIncoming : CliktCommand("Uploads a CSV document to create one incoming payment") { - override fun run() { - val bankAccount = getBankAccount("foo") - uploadQrrPayment(bankAccount.iban) - } -} - -private fun prepare(iban: String) { - // Loads EBICS subscriber's keys from disk. - // The keys should be found under libeufin-internal.git/convenience/ - val bufferedReader: BufferedReader = File("/tmp/pofi.json").bufferedReader() - val accessDataTxt = bufferedReader.use { it.readText() } - val ebicsConn = getConnectionPlugin("ebics") - val accessDataJson = jacksonObjectMapper().readTree(accessDataTxt) - - // Creates a connection handle to the bank, using the loaded keys. - withTestDatabase { - prepNexusDb() - transaction { - ebicsConn.createConnectionFromBackup( - connId = "postfinance", - user = getNexusUser("foo"), - passphrase = "secret", - accessDataJson - ) - val fooBankAccount = getBankAccount("foo") - // Hooks the PoFi details to the local bank account. - // No need to run the canonical steps (creating account, downloading bank accounts, ..) - fooBankAccount.defaultBankConnection = getBankConnection("postfinance") - fooBankAccount.iban = iban - } - } -} -fun main(args: Array<String>) { - PostFinanceCommand().subcommands(Download(), Upload(), GenIncoming()).main(args) -} -\ No newline at end of file diff --git a/nexus/src/test/kotlin/SandboxAccessApiTest.kt b/nexus/src/test/kotlin/SandboxAccessApiTest.kt @@ -1,491 +0,0 @@ -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.ObjectMapper -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.server.testing.* -import io.ktor.util.* -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.time.delay -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.transactions.transaction -import org.junit.Ignore -import org.junit.Test -import tech.libeufin.sandbox.* -import tech.libeufin.util.getDatabaseName -import java.util.* -import kotlin.concurrent.schedule - -class SandboxAccessApiTest { - val mapper = ObjectMapper() - private fun getTxs(respJson: String): JsonNode { - val mapper = ObjectMapper() - return mapper.readTree(respJson).get("transactions") - } - - /** - * Testing that ..access-api/withdrawals/{wopid} and - * ..access-api/accounts/{account_name}/withdrawals/{wopid} - * are handled in the same way. - */ - @Test - fun doubleUriStyle() { - // Creating one withdrawal operation. - withTestDatabase { - prepSandboxDb() - val wo: TalerWithdrawalEntity = transaction { - TalerWithdrawalEntity.new { - this.amount = "TESTKUDOS:3.3" - walletBankAccount = getBankAccountFromLabel("foo") - selectedExchangePayto = "payto://iban/SANDBOXX/${BAR_USER_IBAN}" - reservePub = "not used" - selectionDone = true - } - } - testApplication { - application(sandboxApp) - // Showing withdrawal info. - val get_with_account = client.get("/demobanks/default/access-api/accounts/foo/withdrawals/${wo.wopid}") { - expectSuccess = true - } - val get_without_account = client.get("/demobanks/default/access-api/withdrawals/${wo.wopid}") { - expectSuccess = true - } - assert(get_without_account.bodyAsText() == get_with_account.bodyAsText()) - assert(get_with_account.bodyAsText() == get_without_account.bodyAsText()) - // Confirming a withdrawal. - val confirm_with_account = client.post("/demobanks/default/access-api/accounts/foo/withdrawals/${wo.wopid}/confirm") { - expectSuccess = true - } - val confirm_without_account = client.post("/demobanks/default/access-api/withdrawals/${wo.wopid}/confirm") { - expectSuccess = true - } - assert(confirm_with_account.status.value == confirm_without_account.status.value) - assert(confirm_with_account.bodyAsText() == confirm_without_account.bodyAsText()) - // Aborting one withdrawal. - var wo_to_abort = transaction { - TalerWithdrawalEntity.new { - this.amount = "TESTKUDOS:3.3" - walletBankAccount = getBankAccountFromLabel("foo") - selectedExchangePayto = "payto://iban/SANDBOXX/${BAR_USER_IBAN}" - reservePub = "not used" - selectionDone = true - } - } - val abort_with_account = client.post("/demobanks/default/access-api/accounts/foo/withdrawals/${wo_to_abort.wopid}/abort") { - expectSuccess = true - } - wo_to_abort = transaction { - TalerWithdrawalEntity.new { - this.amount = "TESTKUDOS:3.3" - walletBankAccount = getBankAccountFromLabel("foo") - selectedExchangePayto = "payto://iban/SANDBOXX/${BAR_USER_IBAN}" - reservePub = "not used" - selectionDone = true - } - } - val abort_without_account = client.post("/demobanks/default/access-api/withdrawals/${wo_to_abort.wopid}/abort") { - expectSuccess = true - } - assert(abort_with_account.status.value == abort_without_account.status.value) - // Not checking the content as they abort two different operations. - } - } - } - - // Move funds between accounts. - @Test - fun wireTransfer() { - withTestDatabase { - prepSandboxDb() - testApplication { - application(sandboxApp) - runBlocking { - // Foo gives 20 to Bar - client.post("/demobanks/default/access-api/accounts/foo/transactions") { - expectSuccess = true - contentType(ContentType.Application.Json) - basicAuth("foo", "foo") - setBody("""{ - "paytoUri": "payto://iban/${BAR_USER_IBAN}?message=test", - "amount": "TESTKUDOS:20" - }""".trimIndent() - ) - } - // Foo checks its balance: -20 - var R = client.get("/demobanks/default/access-api/accounts/foo") { - basicAuth("foo", "foo") - } - val mapper = ObjectMapper() - var j = mapper.readTree(R.readBytes()) - val expectDebitOf20 = j.get("balance").get("amount").asText() - println("Expect debit of 20: $expectDebitOf20") - val testkudos20regex = "^TESTKUDOS:20(.00)?$".toRegex() - assert(testkudos20regex.matches(expectDebitOf20)) - assert(j.get("balance").get("credit_debit_indicator").asText().lowercase() == "debit") - // Bar checks its balance: 20 - R = client.get("/demobanks/default/access-api/accounts/bar") { - basicAuth("bar", "bar") - } - j = mapper.readTree(R.readBytes()) - assert(testkudos20regex.matches(j.get("balance").get("amount").asText())) - assert(j.get("balance").get("credit_debit_indicator").asText().lowercase() == "credit") - // Foo tries with an invalid amount - R = client.post("/demobanks/default/access-api/accounts/foo/transactions") { - contentType(ContentType.Application.Json) - basicAuth("foo", "foo") - setBody("""{ - "paytoUri": "payto://iban/${BAR_USER_IBAN}?message=test", - "amount": "TESTKUDOS:20.001" - }""".trimIndent() - ) - } - assert(R.status.value == HttpStatusCode.BadRequest.value) - } - } - } - } - - // Tests the time range filter of Access API's GET /transactions - @Test - fun timeRangedTransactions() { - withTestDatabase { - prepSandboxDb() - testApplication { - application(sandboxApp) - var R = client.get("/demobanks/default/access-api/accounts/foo/transactions") { - expectSuccess = true - basicAuth("foo", "foo") - } - assert(getTxs(R.bodyAsText()).size() == 0) // Checking that no transactions exist. - wireTransfer( - "admin", - "foo", - "default", - "#0", - "TESTKUDOS:2" - ) - R = client.get("/demobanks/default/access-api/accounts/foo/transactions") { - expectSuccess = true - basicAuth("foo", "foo") - } - assert(getTxs(R.bodyAsText()).size() == 1) // Checking that #0 shows up. - // Asking up to a point in the past, where no txs should exist. - R = client.get("/demobanks/default/access-api/accounts/foo/transactions?until_ms=3000") { - expectSuccess = true - basicAuth("foo", "foo") - } - assert(getTxs(R.bodyAsText()).size() == 0) // Not expecting any transaction. - // Moving the transaction back in the past - transaction { - val tx_0 = BankAccountTransactionEntity.find { - BankAccountTransactionsTable.subject eq "#0" and - (BankAccountTransactionsTable.direction eq "CRDT") - }.first() - tx_0.date = 10000 - } - // Picking the past transaction from one including time range, - // therefore expecting one entry in the result - R = client.get("/demobanks/default/access-api/accounts/foo/transactions?from_ms=9000&until_ms=11000") { - expectSuccess = true - basicAuth("foo", "foo") - } - assert(getTxs(R.bodyAsText()).size() == 1) - // Not enough txs to fill the second page, expecting no txs therefore. - R = client.get("/demobanks/default/access-api/accounts/foo/transactions?page=2&size=1") { - expectSuccess = true - basicAuth("foo", "foo") - } - assert(getTxs(R.bodyAsText()).size() == 0) - // Creating one more tx and asking the second 1-sized page, expecting therefore one result. - wireTransfer( - "admin", - "foo", - "default", - "#1", - "TESTKUDOS:2" - ) - R = client.get("/demobanks/default/access-api/accounts/foo/transactions?page=2&size=1") { - expectSuccess = true - basicAuth("foo", "foo") - } - assert(getTxs(R.bodyAsText()).size() == 1) - } - } - } - - // Tests for #7482 - @Test - fun highAmountWithdraw() { - withTestDatabase { - prepSandboxDb(usersDebtLimit = 900000000) - testApplication { - application(sandboxApp) - // Create the operation. - val r = client.post("/demobanks/default/access-api/accounts/foo/withdrawals") { - expectSuccess = true - setBody("{\"amount\": \"TESTKUDOS:500000000\"}") - contentType(ContentType.Application.Json) - basicAuth("foo", "foo") - } - println(r.bodyAsText()) - val j = mapper.readTree(r.readBytes()) - val op = j.get("withdrawal_id").asText() - // Select exchange and specify a reserve pub. - client.post("/demobanks/default/integration-api/withdrawal-operation/$op") { - expectSuccess = true - contentType(ContentType.Application.Json) - setBody("""{ - "selected_exchange":"payto://iban/${BAR_USER_IBAN}", - "reserve_pub": "not-used" - }""".trimIndent()) - } - // Confirm the operation. - client.post("/demobanks/default/access-api/accounts/foo/withdrawals/$op/confirm") { - expectSuccess = true - basicAuth("foo", "foo") - } - // Check the withdrawal amount in the unique transaction. - val t = client.get("/demobanks/default/access-api/accounts/foo/transactions") { - basicAuth("foo", "foo") - expectSuccess = true - } - println(t.bodyAsText()) - val amount = mapper.readTree(t.readBytes()).get("transactions").get(0).get("amount").asText() - assert(amount == "500000000") - } - } - } - @Test - fun withdrawWithHighBalance() { - withTestDatabase { - prepSandboxDb() - /** - * A problem appeared (Sandbox responding "insufficient funds") - * when B - A > T, where B is the balance, A the potential amount - * to withdraw and T is the debit threshold for the user. T is - * 1000 here, therefore setting B as 2000 and A as 1 should get - * this case tested. - */ - wireTransfer( - "admin", - "foo", - "default", - "bring balance to high amount", - "TESTKUDOS:2000" - ) - testApplication { - this.application(sandboxApp) - runBlocking { - client.post("/demobanks/default/access-api/accounts/foo/withdrawals") { - expectSuccess = true - setBody("{\"amount\": \"TESTKUDOS:1\"}") - contentType(ContentType.Application.Json) - basicAuth("foo", "foo") - } - } - } - } - } - // Check successful and failing case due to insufficient funds. - @Test - fun debitWithdraw() { - withTestDatabase { - prepSandboxDb() - testApplication { - this.application(sandboxApp) - runBlocking { - // Normal, successful withdrawal. - client.post("/demobanks/default/access-api/accounts/foo/withdrawals") { - expectSuccess = true - setBody("{\"amount\": \"TESTKUDOS:1\"}") - contentType(ContentType.Application.Json) - basicAuth("foo", "foo") - } - // Withdrawal over the debit threshold. - val r: HttpResponse = client.post("/demobanks/default/access-api/accounts/foo/withdrawals") { - expectSuccess = false - contentType(ContentType.Application.Json) - basicAuth("foo", "foo") - setBody("{\"amount\": \"TESTKUDOS:99999999999\"}") - } - assert(HttpStatusCode.Conflict.value == r.status.value) - } - } - } - } - - /** - * Tests that 'admin' and 'bank' are not possible to register - * and that after 'admin' logs in it gets access to the bank's - * main account. - */ // FIXME: avoid giving Content-Type at every request. - @Test - fun adminRegisterAndLoginTest() { - withTestDatabase { - prepSandboxDb() - testApplication { - application(sandboxApp) - runBlocking { - val registerAdmin = mapper.writeValueAsString(object { - val username = "admin" - val password = "y" - }) - val registerBank = mapper.writeValueAsString(object { - val username = "bank" - val password = "y" - }) - for (b in mutableListOf<String>(registerAdmin, registerBank)) { - val r = client.post("/demobanks/default/access-api/testing/register") { - setBody(b) - contentType(ContentType.Application.Json) - expectSuccess = false - } - assert(r.status.value == HttpStatusCode.Forbidden.value) - } - // Set arbitrary balance to the bank. - wireTransfer( - "foo", - "admin", - "default", - "setting the balance", - "TESTKUDOS:99" - ) - // Get admin's balance. Not asserting; it - // fails on != 200 responses. - val r = client.get("/demobanks/default/access-api/accounts/admin") { - expectSuccess = true - basicAuth("admin", "foo") - } - println(r) - } - } - } - } - - // Checks that the debit threshold belongs to the balance response. - @Test - fun debitInfoCheck() { - withTestDatabase { - prepSandboxDb() - testApplication { - application(sandboxApp) - var r = client.get("/demobanks/default/access-api/accounts/foo") { - expectSuccess = true - basicAuth("foo", "foo") - } - // Checking that the response holds the debit threshold. - val mapper = ObjectMapper() - var respJson = mapper.readTree(r.bodyAsText()) - var debitThreshold = respJson.get("debitThreshold").asText() - assert(debitThreshold == "1000") - r = client.get("/demobanks/default/access-api/accounts/admin") { - expectSuccess = true - basicAuth("admin", "foo") - } - respJson = mapper.readTree(r.bodyAsText()) - debitThreshold = respJson.get("debitThreshold").asText() - assert(debitThreshold == "10000") - } - } - } - - @Test - fun registerTest() { - // Test IBAN conflict detection. - withSandboxTestDatabase { - testApplication { - application(sandboxApp) - runBlocking { - val bodyFoo = mapper.writeValueAsString(object { - val username = "x" - val password = "y" - val iban = FOO_USER_IBAN - }) - val bodyBar = mapper.writeValueAsString(object { - val username = "y" - val password = "y" - val iban = FOO_USER_IBAN // conflicts - }) - val bodyBaz = mapper.writeValueAsString(object { - val username = "y" - val password = "y" - val iban = BAR_USER_IBAN - }) - // Succeeds. - client.post("/demobanks/default/access-api/testing/register") { - setBody(bodyFoo) - contentType(ContentType.Application.Json) - expectSuccess = true - } - // Hits conflict, because of the same IBAN. - val r = client.post("/demobanks/default/access-api/testing/register") { - setBody(bodyBar) - expectSuccess = false - contentType(ContentType.Application.Json) - } - assert(r.status.value == HttpStatusCode.Conflict.value) - // Succeeds, because of a new IBAN. - client.post("/demobanks/default/access-api/testing/register") { - setBody(bodyBaz) - expectSuccess = true - contentType(ContentType.Application.Json) - } - } - } - - } - } - - /** - * This test checks that the bank hangs before responding with the list - * of transactions, in case there is none to return. The timing checks - * that the server hangs for as long as the unblocking payment takes place - * but NOT as long as the long_poll_ms parameter would suggest. This last - * check ensures that the response can only contain the artificial unblocking - * payment (that happens after a certain timeout). - */ - @Test - fun longPolledTransactions() { - val unblockingTxTimer = Timer() - val testStartTime = System.currentTimeMillis() - withTestDatabase { - prepSandboxDb() - testApplication { - application(sandboxApp) - runBlocking { - launch { - // long polls at most 50 seconds. - val R = client.get("/demobanks/default/access-api/accounts/foo/transactions?long_poll_ms=50000") { - expectSuccess = true - basicAuth("foo", "foo") - } - assert(getTxs(R.bodyAsText()).size() == 1) - val testEndTime = System.currentTimeMillis() - val timeDiff = (testEndTime - testStartTime) / 1000L - /** - * Now checking that the server responded after the unblocking tx - * took place and before the long poll timeout would occur. - */ - println(timeDiff) - assert(timeDiff in 4 .. 39) - } - unblockingTxTimer.schedule( - delay = 4000L, // unblocks the server in (at least) 4 seconds. - action = { - wireTransfer( - "admin", - "foo", - "default", - "#9", - "TESTKUDOS:2" - ) - } - ) - } - } - } - } -} -\ No newline at end of file diff --git a/nexus/src/test/kotlin/SandboxBankAccountTest.kt b/nexus/src/test/kotlin/SandboxBankAccountTest.kt @@ -1,73 +0,0 @@ -import io.ktor.http.* -import org.junit.Test -import tech.libeufin.sandbox.SandboxError -import tech.libeufin.sandbox.getBalance -import tech.libeufin.sandbox.sandboxApp -import tech.libeufin.sandbox.wireTransfer -import tech.libeufin.util.buildBasicAuthLine -import tech.libeufin.util.parseDecimal -import tech.libeufin.util.roundToTwoDigits - -class SandboxBankAccountTest { - // Check if the balance shows debit. - @Test - fun debitBalance() { - withTestDatabase { - prepSandboxDb() - wireTransfer( - "admin", - "foo", - "default", - "Show up in logging!", - "TESTKUDOS:1" - ) - /** - * Bank gave 1 to foo, should be -1 debit now. Because - * the payment is still pending (= not booked), the pending - * transactions must be included in the calculation. - */ - var bankBalance = getBalance("admin") - assert(bankBalance.roundToTwoDigits() == parseDecimal("-1").roundToTwoDigits()) - wireTransfer( - "foo", - "admin", - "default", - "Show up in logging!", - "TESTKUDOS:5" - ) - bankBalance = getBalance("admin") - assert(bankBalance.roundToTwoDigits() == parseDecimal("4").roundToTwoDigits()) - // Trigger Insufficient funds case for users. - try { - wireTransfer( - "foo", - "admin", - "default", - "Show up in logging!", - "TESTKUDOS:5000" - ) - } catch (e: SandboxError) { - // Future versions may wrap this case into a dedicated exception type. - assert(e.statusCode == HttpStatusCode.Conflict) - } - // Trigger Insufficient funds case for the bank. - try { - wireTransfer( - "admin", - "foo", - "default", - "Show up in logging!", - "TESTKUDOS:5000000" - ) - } catch (e: SandboxError) { - // Future versions may wrap this case into a dedicated exception type. - assert(e.statusCode == HttpStatusCode.Conflict) - } - // Check balance didn't change for both parties. - bankBalance = getBalance("admin") - assert(bankBalance.roundToTwoDigits() == parseDecimal("4").roundToTwoDigits()) - val fooBalance = getBalance("foo") - assert(fooBalance.roundToTwoDigits() == parseDecimal("-4").roundToTwoDigits()) - } - } -} -\ No newline at end of file diff --git a/nexus/src/test/kotlin/SandboxCircuitApiTest.kt b/nexus/src/test/kotlin/SandboxCircuitApiTest.kt @@ -1,662 +0,0 @@ -import com.fasterxml.jackson.databind.ObjectMapper -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.server.testing.* -import kotlinx.coroutines.runBlocking -import org.jetbrains.exposed.sql.and -import org.jetbrains.exposed.sql.lowerCase -import org.jetbrains.exposed.sql.transactions.transaction -import org.junit.Ignore -import org.junit.Test -import tech.libeufin.sandbox.* -import tech.libeufin.util.getIban -import tech.libeufin.util.parseAmount -import tech.libeufin.util.roundToTwoDigits -import java.io.File -import java.math.BigDecimal -import java.util.* - -class SandboxCircuitApiTest { - - /** - * Testing that the admin is able to conduct ordinary - * account operations even on non-circuit accounts. Recall: - * such accounts are just those without the cash-out address. - */ - @Test - fun opOnNonCircuitAccounts() { - withTestDatabase { - testApplication { - prepSandboxDb() - testApplication { - application(sandboxApp) - // Only testing that this doesn't except. - client.get("/demobanks/default/circuit-api/accounts") { - expectSuccess = true - basicAuth("admin", "foo") - } - // Trying to PATCH non circuit account - client.patch("/demobanks/default/circuit-api/accounts/exchange-0") { - expectSuccess = true - basicAuth("admin", "foo") - contentType(ContentType.Application.Json) - setBody(""" - {"name": "Exchange 0", - "contact_data": {}, - "cashout_address": "payto://iban/SANDBOXX/${getIban()}" - } - """.trimIndent()) - } - // PATCH it again passing a null name and cashout-address. - client.patch("/demobanks/default/circuit-api/accounts/exchange-0") { - expectSuccess = true - basicAuth("admin", "foo") - contentType(ContentType.Application.Json) - setBody("{ \"contact_data\": {} }") - } - // PATCH the password. - client.patch("/demobanks/default/circuit-api/accounts/exchange-0/auth") { - expectSuccess = true - basicAuth("admin", "foo") - contentType(ContentType.Application.Json) - setBody("{ \"new_password\": \"secret\" }") - } - // Check that PATCHing worked. - client.get("/demobanks/default/access-api/accounts/exchange-0") { - expectSuccess = true - basicAuth("exchange-0", "secret") - contentType(ContentType.Application.Json) - } - // Deleting the account. - client.delete("/demobanks/default/circuit-api/accounts/exchange-0") { - expectSuccess = true - basicAuth("admin", "foo") - } - // Checking actual deletion. - val R = client.get("/demobanks/default/circuit-api/accounts/exchange-0") { - expectSuccess = false - basicAuth("admin", "foo") - } - assert(R.status.value == HttpStatusCode.NotFound.value) - } - } - } - } - // Get /config, fails if != 200. - @Test - fun config() { - withSandboxTestDatabase { - testApplication { - application(sandboxApp) - runBlocking { - val r= client.get("/demobanks/default/circuit-api/config") - println(r.bodyAsText()) - } - } - } - } - - // Tests the application of cash-out ratio and fee. - @Test - fun estimationTest() { - withTestDatabase { - prepSandboxDb() - testApplication { - application(sandboxApp) - var R = client.get( - "/demobanks/default/circuit-api/cashouts/estimates?amount_debit=TESTKUDOS:2" - ) { - expectSuccess = true - basicAuth("foo", "foo") - } - val mapper = ObjectMapper() - var respJson = mapper.readTree(R.bodyAsText()) - val creditAmount = respJson.get("amount_credit").asText() - // sell ratio and fee are the following constants: 0.95 and 0. - // expected credit amount = 2 * 0.95 - 0 = 1.90 - assert("CHF:1.90" == creditAmount || "CHF:1.9" == creditAmount) - R = client.get( - "/demobanks/default/circuit-api/cashouts/estimates?amount_credit=CHF:1.9" - ) { - expectSuccess = true - basicAuth("foo", "foo") - } - respJson = mapper.readTree(R.bodyAsText()) - val debitAmount = respJson.get("amount_debit").asText() - assertWithPrint( - "TESTKUDOS:2.00" == debitAmount, - "'debit_amount' was $debitAmount for a 'credit_amount' of CHF:1.9" - ) - R = client.get( - "/demobanks/default/circuit-api/cashouts/estimates?amount_credit=CHF:1&amount_debit=TESTKUDOS=1" - ) { - expectSuccess = false - basicAuth("foo", "foo") - } - assertWithPrint( - R.status.value == HttpStatusCode.BadRequest.value, - "Expected status code was 400, but got '${R.status.value}' instead." - ) - } - } - } - - /** - * Checking that the ordinary user foo doesn't get to access bar's - * data, but admin does. - */ - @Test - fun accessAccountsTest() { - withTestDatabase { - prepSandboxDb() - testApplication { - application(sandboxApp) - var R = client.get("/demobanks/default/circuit-api/accounts/bar") { - basicAuth("foo", "foo") - expectSuccess = false - } - assert(R.status.value == HttpStatusCode.Forbidden.value) - client.get("/demobanks/default/circuit-api/accounts/bar") { - basicAuth("admin", "foo") - expectSuccess = true - } - } - } - } - // Only tests that the calls get a 2xx status code. - @Test - fun listAccountsTest() { - withTestDatabase { - prepSandboxDb() - testApplication { - application(sandboxApp) - var R = client.get("/demobanks/default/circuit-api/accounts") { - basicAuth("admin", "foo") - } - println(R.bodyAsText()) - client.get("/demobanks/default/circuit-api/accounts/baz") { - basicAuth("admin", "foo") - } - } - } - } - @Test - fun badUuidTest() { - withTestDatabase { - prepSandboxDb() - testApplication { - application(sandboxApp) - val R = client.post("/demobanks/default/circuit-api/cashouts/---invalid_UUID---/confirm") { - expectSuccess = false - basicAuth("foo", "foo") - contentType(ContentType.Application.Json) - setBody("{\"tan\":\"foo\"}") - } - assert(R.status.value == HttpStatusCode.BadRequest.value) - } - } - } - @Test - fun contactDataValidation() { - // Phone number. - assert(checkPhoneNumber("+987")) - assert(!checkPhoneNumber("987")) - assert(!checkPhoneNumber("foo")) - assert(!checkPhoneNumber("")) - assert(!checkPhoneNumber("+00")) - assert(checkPhoneNumber("+4900")) - // E-mail address - assert(checkEmailAddress("test@example.com")) - assert(!checkEmailAddress("foo.bar")) - assert(checkEmailAddress("foo.bar@example.com")) - assert(!checkEmailAddress("foo+bar@example.com")) - assert(checkEmailAddress("admin@example.info")) - assert(checkEmailAddress("AdMiN@COM.example.INFO")) - } - - @Test - fun listCashouts() { - withTestDatabase { - prepSandboxDb() - testApplication { - application(sandboxApp) - var R = client.get("/demobanks/default/circuit-api/cashouts") { - expectSuccess = true - basicAuth("admin", "foo") - } - assert(R.status.value == HttpStatusCode.NoContent.value) - transaction { - CashoutOperationEntity.new { - tan = "unused" - uuid = UUID.randomUUID() - amountDebit = "unused" - amountCredit = "unused" - subject = "unused" - creationTime = 0L - tanChannel = SupportedTanChannels.FILE // change type to enum? - account = "foo" - status = CashoutOperationStatus.PENDING - cashoutAddress = "not used" - buyAtRatio = "1" - buyInFee = "1" - sellAtRatio = "1" - sellOutFee = "1" - } - } - R = client.get("/demobanks/default/circuit-api/cashouts") { - expectSuccess = true - basicAuth("admin", "foo") - } - assert(R.status.value == HttpStatusCode.OK.value) - // Extract the UUID and check it. - val mapper = ObjectMapper() - var respJson = mapper.readTree(R.bodyAsText()) - val uuid = respJson.get("cashouts").get(0).asText() - R = client.get("/demobanks/default/circuit-api/cashouts/$uuid") { - expectSuccess = true - basicAuth("admin", "foo") - } - assert(R.status.value == HttpStatusCode.OK.value) - respJson = mapper.readTree(R.bodyAsText()) - val status = respJson.get("status").asText() - assert(status.uppercase() == "PENDING") - println(R.bodyAsText()) - // Check that bar doesn't get foo's cash-out - R = client.get("/demobanks/default/circuit-api/cashouts?account=foo") { - expectSuccess = false - basicAuth("bar", "bar") - } - assert(R.status.value == HttpStatusCode.Forbidden.value) - // Check that foo can get its own - R = client.get("/demobanks/default/circuit-api/cashouts?account=foo") { - expectSuccess = false - basicAuth("foo", "foo") - } - assert(R.status.value == HttpStatusCode.OK.value) - } - } - } - - // Testing that only the admin can change an account legal name. - @Test - fun patchPerm() { - withTestDatabase { - prepSandboxDb() - testApplication { - application(sandboxApp) - val R =client.patch("/demobanks/default/circuit-api/accounts/foo") { - contentType(ContentType.Application.Json) - basicAuth("foo", "foo") - expectSuccess = false - setBody(""" - { - "name": "new name", - "contact_data": {}, - "cashout_address": "payto://iban/OUTSIDE" - } - """.trimIndent()) - } - assert(R.status.value == HttpStatusCode.Forbidden.value) - client.patch("/demobanks/default/circuit-api/accounts/foo") { - contentType(ContentType.Application.Json) - basicAuth("admin", "foo") - expectSuccess = true - setBody(""" - { - "name": "new name", - "contact_data": {}, - "cashout_address": "payto://iban/OUTSIDE" - } - """.trimIndent()) - } - } - } - } - // Tests the creation and confirmation of a cash-out operation. - @Test - fun cashout() { - withTestDatabase { - prepSandboxDb() - testApplication { - application(sandboxApp) - // Register a new account. - client.post("/demobanks/default/circuit-api/accounts") { - expectSuccess = true - contentType(ContentType.Application.Json) - basicAuth("admin", "foo") - setBody(""" - {"username":"shop", - "password": "secret", - "contact_data": {}, - "name": "Test", - "cashout_address": "payto://iban/SAMPLE" - } - """.trimIndent()) - } - // Give initial balance to the new account. - // Forcing different debt limit: - transaction { - val configRaw = DemobankConfigPairEntity.find { - DemobankConfigPairsTable.demobankName eq "default" and( - DemobankConfigPairsTable.configKey eq "usersDebtLimit" - ) - }.first() - configRaw.configValue = 0.toString() - } - val initialBalance = "TESTKUDOS:50.00" - val balanceAfterCashout = "TESTKUDOS:30.00" - wireTransfer( - debitAccount = "admin", - creditAccount = "shop", - subject = "cash-out", - amount = initialBalance - ) - // Check the balance before cashing out. - var R = client.get("/demobanks/default/access-api/accounts/shop") { - basicAuth("shop", "secret") - } - val mapper = ObjectMapper() - var respJson = mapper.readTree(R.bodyAsText()) - assert(respJson.get("balance").get("amount").asText() == initialBalance) - // Configure the user phone number, before the cash-out. - R = client.patch("/demobanks/default/circuit-api/accounts/shop") { - contentType(ContentType.Application.Json) - basicAuth("shop", "secret") - setBody(""" - { - "contact_data": { - "phone": "+98765" - }, - "cashout_address": "payto://iban/SAMPLE" - } - """.trimIndent()) - } - assert(R.status.value == HttpStatusCode.NoContent.value) - /** - * Cash-out a portion. Ordering a cash-out of 20 TESTKUDOS - * should result in the following final amount, that the user - * will see as incoming in the fiat bank account: 19 = 20 * 0.95 - 0.00. - * Note: ratios and fees are currently hard-coded. - */ - R = client.post("/demobanks/default/circuit-api/cashouts") { - contentType(ContentType.Application.Json) - basicAuth("shop", "secret") - setBody("""{ - "amount_debit": "TESTKUDOS:20", - "amount_credit": "CHF:19", - "tan_channel": "file" - }""".trimIndent()) - } - assert(R.status.value == HttpStatusCode.Accepted.value) - val operationUuid = mapper.readTree(R.readBytes()).get("uuid").asText() - // Check that the operation is found by the bank. - R = client.get("/demobanks/default/circuit-api/cashouts/${operationUuid}") { - // Asking as the Admin but for the 'shop' account. - basicAuth("admin", "foo") - } - // Check that the status is pending. - assert(mapper.readTree(R.readBytes()).get("status").asText() == "PENDING") - // Now confirm the operation. - client.post("/demobanks/default/circuit-api/cashouts/${operationUuid}/confirm") { - basicAuth("shop", "secret") - contentType(ContentType.Application.Json) - setBody("{\"tan\":\"foo\"}") - expectSuccess = true - } - // Check that the operation is found by the bank and set to 'confirmed'. - R = client.get("/demobanks/default/circuit-api/cashouts/${operationUuid}") { - // Asking as the Admin but for the 'shop' account. - basicAuth("foo", "foo") - } - assert(mapper.readTree(R.readBytes()).get("status").asText() == "CONFIRMED") - // Check that the amount got deducted by the account. - R = client.get("/demobanks/default/access-api/accounts/shop") { - basicAuth("shop", "secret") - } - respJson = mapper.readTree(R.bodyAsText()) - assert(respJson.get("balance").get("amount").asText() == balanceAfterCashout) - // Attempt to cash-out with wrong regional currency. - R = client.post("/demobanks/default/circuit-api/cashouts") { - contentType(ContentType.Application.Json) - basicAuth("shop", "secret") - setBody("""{ - "amount_debit": "NOTFOUND:20", - "amount_credit": "CHF:19", - "tan_channel": "file" - }""".trimIndent()) - expectSuccess = false - } - assert(R.status.value == HttpStatusCode.BadRequest.value) - // Attempt to cash-out with wrong fiat currency. - R = client.post("/demobanks/default/circuit-api/cashouts") { - contentType(ContentType.Application.Json) - basicAuth("shop", "secret") - setBody("""{ - "amount_debit": "TESTKUDOS:20", - "amount_credit": "NOTFOUND:19", - "tan_channel": "file" - }""".trimIndent()) - expectSuccess = false - } - assert(R.status.value == HttpStatusCode.BadRequest.value) - // Create a new cash-out and delete it. - R = client.post("/demobanks/default/circuit-api/cashouts") { - contentType(ContentType.Application.Json) - basicAuth("shop", "secret") - setBody("""{ - "amount_debit": "TESTKUDOS:20", - "amount_credit": "CHF:19", - "tan_channel": "file" - }""".trimIndent()) - } - assert(R.status.value == HttpStatusCode.Accepted.value) - val toAbort = mapper.readTree(R.readBytes()).get("uuid").asText() - // Check it exists. - R = client.get("/demobanks/default/circuit-api/cashouts/${toAbort}") { - // Asking as the Admin but for the 'shop' account. - basicAuth("foo", "foo") - } - assert(R.status.value == HttpStatusCode.OK.value) - // Ask to delete the operation. - R = client.post("/demobanks/default/circuit-api/cashouts/${toAbort}/abort") { - basicAuth("admin", "foo") - } - assert(R.status.value == HttpStatusCode.NoContent.value) - // Check actual disappearance. - R = client.get("/demobanks/default/circuit-api/cashouts/${toAbort}") { - // Asking as the Admin but for the 'shop' account. - basicAuth("foo", "foo") - } - assert(R.status.value == HttpStatusCode.NotFound.value) - // Ask to delete a confirmed operation. - R = client.post("/demobanks/default/circuit-api/cashouts/${operationUuid}/abort") { - basicAuth("admin", "foo") - } - assert(R.status.value == HttpStatusCode.PreconditionFailed.value) - } - } - } - - // Test user registration and deletion. - @Test - fun registration() { - withSandboxTestDatabase { - testApplication { - application(sandboxApp) - runBlocking { - // Successful registration. - var R = client.post("/demobanks/default/circuit-api/accounts") { - expectSuccess = true - contentType(ContentType.Application.Json) - basicAuth("admin", "foo") - setBody(""" - {"username":"shop", - "password": "secret", - "contact_data": {}, - "name": "Test", - "cashout_address": "payto://iban/SAMPLE" - } - """.trimIndent()) - } - assert(R.status.value == HttpStatusCode.NoContent.value) - // Check accounts list. - R = client.get("/demobanks/default/circuit-api/accounts") { - basicAuth("admin", "foo") - expectSuccess = true - } - println(R.bodyAsText()) - // Update contact data. - R = client.patch("/demobanks/default/circuit-api/accounts/shop") { - contentType(ContentType.Application.Json) - basicAuth("shop", "secret") - setBody(""" - {"contact_data": {"email": "user@example.com"}, - "cashout_address": "payto://iban/SAMPLE" - } - """.trimIndent()) - } - assert(R.status.value == HttpStatusCode.NoContent.value) - // Get user data via the Access API. - R = client.get("/demobanks/default/access-api/accounts/shop") { - basicAuth("shop", "secret") - } - assert(R.status.value == HttpStatusCode.OK.value) - // Get Circuit data via the Circuit API. - R = client.get("/demobanks/default/circuit-api/accounts/shop") { - basicAuth("shop", "secret") - } - println(R.bodyAsText()) - assert(R.status.value == HttpStatusCode.OK.value) - // Change password. - R = client.patch("/demobanks/default/circuit-api/accounts/shop/auth") { - basicAuth("shop", "secret") - setBody("{\"new_password\":\"new_secret\"}") - contentType(ContentType.Application.Json) - } - assert(R.status.value == HttpStatusCode.NoContent.value) - // Check that the password changed: expect 401 with previous password. - R = client.get("/demobanks/default/access-api/accounts/shop") { - basicAuth("shop", "secret") - } - assert(R.status.value == HttpStatusCode.Unauthorized.value) - // Check that the password changed: expect 200 with current password. - R = client.get("/demobanks/default/access-api/accounts/shop") { - basicAuth("shop", "new_secret") - } - assert(R.status.value == HttpStatusCode.OK.value) - // Change user balance. - transaction { - val account = BankAccountEntity.find { - BankAccountsTable.label eq "shop" - }.firstOrNull() ?: throw Exception("Circuit test account not found in the database!") - account.bonus("TESTKUDOS:30") - account - } - // Delete account. Fails because the balance is not zero. - R = client.delete("/demobanks/default/circuit-api/accounts/shop") { - basicAuth("admin", "foo") - } - assert(R.status.value == HttpStatusCode.PreconditionFailed.value) - // Bring the balance again to zero - transaction { - wireTransfer( - "shop", - "admin", - "default", - "deletion condition", - "TESTKUDOS:30" - ) - } - // Now delete the account successfully. - R = client.delete("/demobanks/default/circuit-api/accounts/shop") { - basicAuth("admin", "foo") - } - assert(R.status.value == HttpStatusCode.NoContent.value) - // Check actual deletion. - R = client.get("/demobanks/default/access-api/accounts/shop") { - basicAuth("shop", "secret") - } - assert(R.status.value == HttpStatusCode.NotFound.value) - } - } - } - } - - // Tests the database RegEx filter on customer names. - @Ignore // Since no assert takes place. - @Test - fun customerFilter() { - withTestDatabase { - prepSandboxDb() - testApplication { - application(sandboxApp) - val R = client.get("/demobanks/default/circuit-api/accounts?filter=b") { - basicAuth("admin", "foo") - expectSuccess = true - } - println(R.bodyAsText()) - } - } - } - - /** - * Testing that deleting a user doesn't cause a _different_ user - * to lose data. - */ - @Test - fun deletionIsolation() { - withTestDatabase { - prepSandboxDb() - transaction { - // Admin makes sure foo has balance 100. - wireTransfer( - "admin", - "foo", - subject = "set to 100", - amount = "TESTKUDOS:100" - ) - val fooBalance = getBalance("foo") - assert(fooBalance.roundToTwoDigits() == BigDecimal("100").roundToTwoDigits()) - // Foo pays 3 to bar. - wireTransfer( - "foo", - "bar", - subject = "donation", - amount = "TESTKUDOS:3" - ) - val barBalance = getBalance("bar") - assert(barBalance.roundToTwoDigits() == BigDecimal("3").roundToTwoDigits()) - // Deleting foo from the system. - transaction { - val uBankAccount = getBankAccountFromLabel("foo") - val uCustomerProfile = getCustomer("foo") - uBankAccount.delete() - uCustomerProfile.delete() - } - val barBalanceUpdate = getBalance("bar") - assert(barBalanceUpdate.roundToTwoDigits() == BigDecimal("3").roundToTwoDigits()) - } - } - } - - @Test - fun tanCommandTest() { - /** - * 'tee' allows to test the SMS/e-mail command execution - * because it relates to STDIN and the first command line argument - * in the same way the SMS/e-mail command is expected to. - */ - val tanLocation = File("/tmp/libeufin-tan-cmd-test.txt") - val tanContent = "libeufin" - if (tanLocation.exists()) tanLocation.delete() - runTanCommand( - command = "tee", - address = tanLocation.path, - message = tanContent - ) - val maybeTan = tanLocation.readText() - assert(maybeTan == tanContent) - } -} -\ No newline at end of file diff --git a/nexus/src/test/kotlin/SandboxLegacyApiTest.kt b/nexus/src/test/kotlin/SandboxLegacyApiTest.kt @@ -1,192 +0,0 @@ -import com.fasterxml.jackson.databind.ObjectMapper -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.server.testing.* -import io.ktor.util.* -import io.ktor.utils.io.* -import io.netty.handler.codec.http.HttpResponseStatus -import kotlinx.coroutines.runBlocking -import org.junit.Ignore -import org.junit.Test -import tech.libeufin.sandbox.sandboxApp -import tech.libeufin.util.buildBasicAuthLine -import tech.libeufin.util.getIban -import java.io.ByteArrayOutputStream - -class SandboxLegacyApiTest { - fun dbHelper (f: () -> Unit) { - withTestDatabase { - prepSandboxDb() - f() - } - } - val mapper = ObjectMapper() - - // EBICS Subscribers API. - @Test - fun adminEbicsSubscribers() { - dbHelper { - testApplication { - application(sandboxApp) - runBlocking { - /** - * Create a EBICS subscriber. That conflicts because - * MakeEnv.kt created it already, but tests access control - * and conflict detection. - */ - var body = mapper.writeValueAsString(object { - val hostID = "eufinSandbox" - val userID = "foo" - val systemID = "foo" - val partnerID = "foo" - }) - var r: HttpResponse = client.post("/admin/ebics/subscribers") { - expectSuccess = false - contentType(ContentType.Application.Json) - basicAuth("admin", "foo") - setBody(body) - } - assert(r.status.value == HttpStatusCode.Conflict.value) - - // Check that EBICS subscriber indeed exists. - r = client.get("/admin/ebics/subscribers") { - basicAuth("admin", "foo") - } - assert(r.status.value == HttpStatusCode.OK.value) - val respObj = mapper.readTree(r.bodyAsText()) - assert("foo" == respObj.get("subscribers").get(0).get("userID").asText()) - - // Try same operations as above, with wrong admin credentials - r = client.get("/admin/ebics/subscribers") { - expectSuccess = false - basicAuth("admin", "wrong") - } - assert(r.status.value == HttpStatusCode.Unauthorized.value) - r = client.post("/admin/ebics/subscribers") { - expectSuccess = false - basicAuth("admin", "wrong") - } - assert(r.status.value == HttpStatusCode.Unauthorized.value) - // Good credentials, but insufficient rights. - r = client.get("/admin/ebics/subscribers") { - expectSuccess = false - basicAuth("foo", "foo") - } - assert(r.status.value == HttpStatusCode.Forbidden.value) - r = client.post("/admin/ebics/subscribers") { - expectSuccess = false - basicAuth("foo", "foo") - } - assert(r.status.value == HttpStatusCode.Forbidden.value) - /** - * Give a bank account to the existing subscriber. Bank account - * is (implicitly / hard-coded) hosted at default demobank. - */ - // Create new subscriber. No need to have the related customer. - body = mapper.writeValueAsString(object { - val hostID = "eufinSandbox" - val userID = "baz" - val partnerID = "baz" - val systemID = "foo" - }) - client.post("/admin/ebics/subscribers") { - expectSuccess = true - contentType(ContentType.Application.Json) - basicAuth("admin", "foo") - setBody(body) - } - // Associate new bank account to it. - body = mapper.writeValueAsString(object { - val subscriber = object { - val userID = "baz" - val partnerID = "baz" - val systemID = "baz" - val hostID = "eufinSandbox" - } - val iban = getIban() - val bic = "SANDBOXX" - val name = "Now Have Account" - val label = "baz" - }) - client.post("/admin/ebics/bank-accounts") { - expectSuccess = true - contentType(ContentType.Application.Json) - basicAuth("admin", "foo") - setBody(body) - } - r = client.get("/admin/ebics/subscribers") { - basicAuth("admin", "foo") - } - assert(r.status.value == HttpStatusCode.OK.value) - val respObj_ = mapper.readTree(r.bodyAsText()) - val bankAccountLabel = respObj_.get("subscribers").get(1).get("demobankAccountLabel").asText() - assert("baz" == bankAccountLabel) - // Same operation, wrong/unauth credentials. - r = client.post("/admin/ebics/bank-accounts") { - expectSuccess = false - basicAuth("admin", "wrong") - } - assert(r.status.value == HttpStatusCode.Unauthorized.value) - r = client.post("/admin/ebics/bank-accounts") { - expectSuccess = false - basicAuth("foo", "foo") - } - assert(r.status.value == HttpStatusCode.Forbidden.value) - } - } - } - } - - // EBICS Hosts API. - @Test - fun adminEbicsCreateHost() { - dbHelper { - testApplication { - application(sandboxApp) - runBlocking { - val body = mapper.writeValueAsString( - object { - val hostID = "www" - var ebicsVersion = "www" - } - ) - // Valid request, good credentials. - client.post("/admin/ebics/hosts") { - expectSuccess = true - setBody(body) - contentType(ContentType.Application.Json) - basicAuth("admin", "foo") - } - var r = client.get("/admin/ebics/hosts") { expectSuccess = false } - assert(r.status.value == HttpResponseStatus.UNAUTHORIZED.code()) - client.get("/admin/ebics/hosts") { - basicAuth("admin", "foo") - expectSuccess = true - } - // Invalid, with good credentials. - r = client.post("/admin/ebics/hosts") { - expectSuccess = false - setBody("invalid") - contentType(ContentType.Application.Json) - basicAuth("admin", "foo") - } - assert(r.status.value == HttpStatusCode.BadRequest.value) - // Unauth: admin with wrong password. - r = client.post("/admin/ebics/hosts") { - expectSuccess = false - basicAuth("admin", "bar") - } - assert(r.status.value == HttpStatusCode.Unauthorized.value) - // Auth & forbidden resource. - r = client.post("/admin/ebics/hosts") { - expectSuccess = false - basicAuth("foo", "foo") - } - assert(r.status.value == HttpStatusCode.Forbidden.value) - } - } - } - } -} -\ No newline at end of file diff --git a/nexus/src/test/kotlin/SchedulingTest.kt b/nexus/src/test/kotlin/SchedulingTest.kt @@ -1,179 +0,0 @@ -import io.ktor.client.* -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.http.* -import io.ktor.server.testing.* -import kotlinx.coroutines.* -import org.junit.Ignore -import org.junit.Test -import tech.libeufin.nexus.bankaccount.fetchBankAccountTransactions -import tech.libeufin.nexus.server.FetchLevel -import tech.libeufin.nexus.server.FetchSpecAllJson -import tech.libeufin.nexus.whileTrueOperationScheduler -import tech.libeufin.sandbox.sandboxApp -import java.util.* -import kotlin.concurrent.schedule -import kotlin.text.get - -/** - * This test suite helps to _measure_ the scheduler performance. - * It is NOT meant to assert on values, but rather to _launch_ and - * give the chance to monitor the CPU usage with TOP(1) - */ - -/** - * It emerged that whether asking transactions via EBICS or x-libeufin-bank - * is NOT performance relevant! For example, asking for a bank account - * balance - via the plain Access API - brings the CPU usage to > 10%. Asking - * for /config - via Integration API - used to oscillate the CPU usage - * between 3 and 10%. - * - * The scheduler's loop style is not relevant either: a while-true & delay(1000) - * or a Java Timer did NOT change the perf. - */ - -// This class focuses on the perf. of Nexus scheduling. -class SchedulingTest { - // Launching the scheduler to measure its perf with TOP(1) - @Ignore // Ignoring because no assert takes place. - @Test - fun normalOperation() { - withTestDatabase { - prepNexusDb() - prepSandboxDb() - testApplication { - application(sandboxApp) - whileTrueOperationScheduler(client) - // javaTimerOperationScheduler(client) - } - } - runBlocking { - launch { awaitCancellation() } - } - } - - // Allows TOP(1) on the bare connection operations without the scheduling overhead. - // Not strictly related to scheduling, but perf. is a major part of scheduling. - @Test - @Ignore // Ignoring because no assert takes place. - fun bareOperationXLibeufinBank() { - withTestDatabase { - prepNexusDb() - prepSandboxDb() - testApplication { - application(sandboxApp) - runBlocking { - while (true) { - // Even x-libeufin-bank takes 10-20% CPU - fetchBankAccountTransactions( - client, - fetchSpec = FetchSpecAllJson( - level = FetchLevel.STATEMENT, - bankConnection = "bar" - ), - accountId = "bar" - ) - delay(1000L) - } - } - } - } - } - // Same as the previous, but on a EBICS connection. - // Perf. is only slightly worse than the JSON based x-libeufin-bank connection. - @Ignore // Ignoring because no assert takes place. - @Test - fun bareOperationEbics() { - withTestDatabase { - prepNexusDb() - prepSandboxDb() - testApplication { - application(sandboxApp) - runBlocking { - while (true) { - fetchBankAccountTransactions( - client, - fetchSpec = FetchSpecAllJson( - level = FetchLevel.STATEMENT, - bankConnection = "foo" - ), - accountId = "foo" - ) - delay(1000L) - } - } - } - } - } - - // HTTP requests loop, to measure perf. via TOP(1) - @Ignore // because no assert takes place. - @Test - fun plainSandboxReqLoop() { - withTestDatabase { - prepSandboxDb() - testApplication { - application(sandboxApp) - while (true) { - // This brings the CPU to > 10% - /*client.get("/demobanks/default/access-api/accounts/foo") { - expectSuccess = true - contentType(ContentType.Application.Json) - basicAuth("foo", "foo") - }*/ - // This brings the CPU between 3 and 10% - /*client.get("/demobanks/default/integration-api/config") { - expectSuccess = true - contentType(ContentType.Application.Json) - // This caused 3 to 9% CPU => did not cause more usage. - // basicAuth("foo", "foo") - }*/ - // Between 2 and 3% CPU. - client.get("/") - delay(1000L) - } - } - } - } -} - -// This class investigates two ways of scheduling, regardless of the one used by Nexus. -class PlainJavaScheduling { - val instanceTimer = Timer() - // Below 5% CPU time. - private fun loopWithJavaTimer() { - println("with Java Timer " + - "doing at ${System.currentTimeMillis() / 1000}.." - ) // uncertain time goes by. - instanceTimer.schedule( - delay = 1200, - action = { loopWithJavaTimer() } - ) - } - // Below 5% CPU time. - private suspend fun loopWithWhileTrue() { - val client = HttpClient() - while (true) { - println("With while-true " + - "doing at ${System.currentTimeMillis() / 1000}.." - ) // uncertain time goes by. - client.get("https://exchange.demo.taler.net/wrong") { - basicAuth("foo", "foo") - } - delay(1000) - } - } - @Ignore // due to no assert. - @Test - fun javaTimerLoop() { - loopWithJavaTimer() - runBlocking { delay(timeMillis = 30000) } - } - @Ignore // due to no assert. - @Test - fun whileTrueLoop() { - runBlocking { - loopWithWhileTrue() - } - } -} -\ No newline at end of file diff --git a/nexus/src/test/kotlin/SplitString.kt b/nexus/src/test/kotlin/SplitString.kt @@ -1,14 +0,0 @@ -package tech.libeufin.nexus - -import org.junit.Test - -class SplitString { - - @Test - fun splitString() { - val chunks = mutableListOf<String>("first", "second", "third", "fourth") - val join = chunks.joinToString("|") - val chunkAgain = join.split("|") - assert(chunks == chunkAgain) - } -} -\ No newline at end of file diff --git a/nexus/src/test/kotlin/SubjectNormalization.kt b/nexus/src/test/kotlin/SubjectNormalization.kt @@ -1,36 +0,0 @@ -import org.junit.Test -import tech.libeufin.util.CryptoUtil -import tech.libeufin.util.extractReservePubFromSubject - -class SubjectNormalization { - - @Test - fun testBeforeAndAfter() { - val mereValue = "1ENVZ6EYGB6Z509KRJ6E59GK1EQXZF8XXNY9SN33C2KDGSHV9KA0" - assert(mereValue == extractReservePubFromSubject(mereValue)) - assert(mereValue == extractReservePubFromSubject("noise before ${mereValue} noise after")) - val mereValueNewLines = "\t1ENVZ6EYGB6Z\n\n\n509KRJ6E59GK1EQXZF8XXNY9\nSN33C2KDGSHV9KA0" - assert(mereValue == extractReservePubFromSubject(mereValueNewLines)) - assert(mereValue == extractReservePubFromSubject("noise before $mereValueNewLines noise after")) - } - - /** - * Here we test whether the value that the extractor picks - * from a payment subjects is then validated by the crypto backend. - */ - @Test - fun extractorVsDecoder() { - val validPub = "7R422Z6C5TPG0JM32KRWV093J0AG0GVZV1247F9PBSFZT6Y61G1G" - assert(CryptoUtil.checkValidEddsaPublicKey(validPub)) - // Swapping zeros with Os. - assert(CryptoUtil.checkValidEddsaPublicKey(validPub.replace('0', 'O'))) - // At this point, the decoder handles 0s and Os interchangeably. - // Now check that the reserve pub. extractor behaves equally. - val extractedPub = extractReservePubFromSubject(validPub) // has 0s. - // The "!!" ensures that the extractor found a likely reserve pub. - assert(CryptoUtil.checkValidEddsaPublicKey(extractedPub!!)) - val extractedPubWithOs = extractReservePubFromSubject(validPub.replace('0', 'O')) - // The "!!" ensures that the extractor did find the reserve pub. with Os instead of zeros. - assert(CryptoUtil.checkValidEddsaPublicKey(extractedPubWithOs!!)) - } -} -\ No newline at end of file diff --git a/nexus/src/test/kotlin/TalerTest.kt b/nexus/src/test/kotlin/TalerTest.kt @@ -1,260 +0,0 @@ -import com.fasterxml.jackson.databind.ObjectMapper -import io.ktor.client.call.* -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.server.testing.* -import kotlinx.coroutines.* -import org.jetbrains.exposed.sql.transactions.transaction -import org.junit.Ignore -import org.junit.Test -import tech.libeufin.nexus.bankaccount.fetchBankAccountTransactions -import tech.libeufin.nexus.bankaccount.submitAllPaymentInitiations -import tech.libeufin.nexus.ingestFacadeTransactions -import tech.libeufin.nexus.maybeTalerRefunds -import tech.libeufin.nexus.server.* -import tech.libeufin.nexus.talerFilter -import tech.libeufin.sandbox.sandboxApp -import tech.libeufin.sandbox.wireTransfer -import tech.libeufin.util.NotificationsChannelDomains -import tech.libeufin.util.getIban - -// This class tests the features related to the Taler facade. -class TalerTest { - private val mapper = ObjectMapper() - - @Test - fun historyOutgoingTestEbics() { - historyOutgoingTest("foo") - } - @Test - fun historyOutgoingTestXLibeufinBank() { - historyOutgoingTest("bar") - } - - // Checking that a call to POST /transfer results in - // an outgoing payment in GET /history/outgoing. - fun historyOutgoingTest(testedAccount: String) { - withNexusAndSandboxUser { - testApplication { - application(nexusApp) - client.post("/facades/$testedAccount-facade/taler-wire-gateway/transfer") { - contentType(ContentType.Application.Json) - basicAuth(testedAccount, testedAccount) // exchange's credentials - expectSuccess = true - setBody(""" - { "request_uid": "twg_transfer_0", - "amount": "TESTKUDOS:3", - "exchange_base_url": "http://exchange.example.com/", - "wtid": "T0", - "credit_account": "payto://iban/${BANK_IBAN}?receiver-name=Not-Used" - } - """.trimIndent()) - } - } - /* The bank connection sends the payment instruction to the bank here. - * and the reconciliation mechanism in Nexus should detect that one - * outgoing payment was indeed the one instructed via the TWG. The - * reconciliation will make the outgoing payment visible via /history/outgoing. - * The following block achieve this by starting Sandbox and sending all - * the prepared payments to it. - */ - testApplication { - application(sandboxApp) - submitAllPaymentInitiations(client, testedAccount) - /* Now downloads transactions from the bank, where the payment - submitted in the previous block is expected to appear as outgoing. - */ - fetchBankAccountTransactions( - client, - fetchSpec = FetchSpecTimeRangeJson( - level = if (testedAccount == "bar") FetchLevel.STATEMENT else FetchLevel.REPORT, - start = "2020-01-01", - end = "3000-01-01", - bankConnection = testedAccount - ), - accountId = testedAccount - ) - } - /** - * Now Nexus starts again, in order to serve /history/outgoing - * along the TWG. - */ - testApplication { - application(nexusApp) - val r = client.get("/facades/$testedAccount-facade/taler-wire-gateway/history/outgoing?delta=5") { - expectSuccess = true - contentType(ContentType.Application.Json) - basicAuth(testedAccount, testedAccount) - } - assert(r.status.value == HttpStatusCode.OK.value) - val j = mapper.readTree(r.readBytes()) - val wtidFromTwg = j.get("outgoing_transactions").get(0).get("wtid").asText() - assert(wtidFromTwg == "T0") - } - } - } - - // Tests that incoming Taler txs arrive via EBICS. - @Test - fun historyIncomingTestEbics() { - historyIncomingTest( - testedAccount = "foo", - connType = BankConnectionType.EBICS - ) - } - - // Tests that incoming Taler txs arrive via x-libeufin-bank. - @Test - fun historyIncomingTestXLibeufinBank() { - historyIncomingTest( - testedAccount = "bar", - connType = BankConnectionType.X_LIBEUFIN_BANK - ) - } - - // Tests that even if one call is long-polling, other calls respond. - @Test - fun servingTest() { - withTestDatabase { - prepNexusDb() - testApplication { - application(nexusApp) - val currentTime = System.currentTimeMillis() - runBlocking { - launch { - val r = client.get("/facades/foo-facade/taler-wire-gateway/history/incoming?delta=5&start=0&long_poll_ms=5000") { - expectSuccess = true - contentType(ContentType.Application.Json) - basicAuth("foo", "foo") // user & pw always equal. - } - assert(r.status.value == HttpStatusCode.NoContent.value) - } - val R = client.get("/") { - expectSuccess = true - } - val latestTime = System.currentTimeMillis() - // Checks that the call didn't hang for the whole long_poll_ms. - assert(R.status.value == HttpStatusCode.OK.value - && (latestTime - currentTime) < 2000 - ) - } - } - } - } - - // Downloads Taler txs via the default connection of 'testedAccount'. - // This allows to test the Taler logic on different connection types. - private fun historyIncomingTest(testedAccount: String, connType: BankConnectionType) { - val reservePub = "GX5H5RME193FDRCM1HZKERXXQ2K21KH7788CKQM8X6MYKYRBP8F0" - withNexusAndSandboxUser { - testApplication { - application(nexusApp) - runBlocking { - /** - * This block issues the request by long-polling and - * lets the execution proceed where the actions to unblock - * the polling are taken. - */ - launch { - val r = client.get("/facades/${testedAccount}-facade/taler-wire-gateway/history/incoming?delta=5&start=0&long_poll_ms=30000") { - expectSuccess = true - contentType(ContentType.Application.Json) - basicAuth(testedAccount, testedAccount) // user & pw always equal. - } - assertWithPrint( - r.status.value == HttpStatusCode.OK.value, - "Long-polling history had status: ${r.status.value} and" + - " body: ${r.bodyAsText()}" - ) - val j = mapper.readTree(r.readBytes()) - val reservePubFromTwg = j.get("incoming_transactions").get(0).get("reserve_pub").asText() - assert(reservePubFromTwg == reservePub) - } - launch { - delay(500) - newNexusBankTransaction( - currency = "KUDOS", - value = "10", - subject = reservePub, - creditorAcct = testedAccount, - connType = connType - ) - ingestFacadeTransactions( - bankAccountId = testedAccount, // bank account local to Nexus. - facadeType = NexusFacadeType.TALER, - incomingFilterCb = ::talerFilter, - refundCb = ::maybeTalerRefunds - ) - } - } - } - } - } - - @Ignore // Ignoring because no assert takes place. - @Test // Triggering a refund because of a duplicate reserve pub. - fun refundTest() { - withNexusAndSandboxUser { - // Creating a Taler facade for the user 'foo'. - testApplication { - application(nexusApp) - client.post("/facades") { - expectSuccess = true - contentType(ContentType.Application.Json) - basicAuth("foo", "foo") - setBody(""" - { "name":"foo-facade", - "type":"taler-wire-gateway", - "config": { - "bankAccount":"foo", - "bankConnection":"foo", - "currency":"TESTKUDOS", - "reserveTransferLevel":"report" - } - }""".trimIndent() - ) - } - } - wireTransfer( - "bar", - "foo", - demobank = "default", - "5WFM8PXN7Y315RVZFJ280299B94W1HR1AAHH6XNDYEJBC0T3E5N0", - "TESTKUDOS:3" - ) - testApplication { - application(sandboxApp) - // Nexus downloads the fresh transaction. - fetchBankAccountTransactions( - client, - fetchSpec = FetchSpecAllJson( - level = FetchLevel.REPORT, - "foo" - ), - "foo" - ) - } - wireTransfer( - "bar", - "foo", - demobank = "default", - "5WFM8PXN7Y315RVZFJ280299B94W1HR1AAHH6XNDYEJBC0T3E5N0", - "TESTKUDOS:3" - ) - testApplication { - application(sandboxApp) - // Nexus downloads the new transaction, having a duplicate subject. - fetchBankAccountTransactions( - client, - fetchSpec = FetchSpecAllJson( - level = FetchLevel.REPORT, - "foo" - ), - "foo" - ) - } - } - } -} -\ No newline at end of file diff --git a/nexus/src/test/kotlin/XLibeufinBankTest.kt b/nexus/src/test/kotlin/XLibeufinBankTest.kt @@ -1,159 +0,0 @@ -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.server.testing.* -import org.jetbrains.exposed.sql.transactions.transaction -import org.junit.Test -import tech.libeufin.nexus.* -import tech.libeufin.nexus.bankaccount.addPaymentInitiation -import tech.libeufin.nexus.bankaccount.ingestBankMessagesIntoAccount -import tech.libeufin.nexus.server.* -import tech.libeufin.nexus.xlibeufinbank.XlibeufinBankConnectionProtocol -import tech.libeufin.sandbox.BankAccountTransactionEntity -import tech.libeufin.sandbox.BankAccountTransactionsTable -import tech.libeufin.sandbox.sandboxApp -import tech.libeufin.sandbox.wireTransfer -import tech.libeufin.util.XLibeufinBankTransaction -import tech.libeufin.util.getIban -import java.net.URL - -// Testing the x-libeufin-bank communication - -class XLibeufinBankTest { - private val mapper = jacksonObjectMapper() - @Test - fun urlParse() { - val u = URL("http://localhost") - println(u.authority) - } - - /** - * This test tries to submit a transaction to Sandbox - * via the x-libeufin-bank connection and later - after - * having downloaded its transactions - tries to reconcile - * it as sent. - */ - @Test - fun submitTransaction() { - withTestDatabase { - prepSandboxDb() - prepNexusDb() - testApplication { - application(sandboxApp) - val pId = addPaymentInitiation( - Pain001Data( - creditorIban = FOO_USER_IBAN, - creditorBic = "SANDBOXX", - creditorName = "Tester", - subject = "test payment", - sum = "1", - currency = "TESTKUDOS" - ), - transaction { - NexusBankAccountEntity.findByName("bar") ?: - throw Exception("Test failed, env didn't provide Nexus bank account 'bar'") - } - ) - val conn = XlibeufinBankConnectionProtocol() - conn.submitPaymentInitiation(this.client, pId.id.value) - val maybeArrivedPayment = transaction { - BankAccountTransactionEntity.find { - BankAccountTransactionsTable.pmtInfId eq pId.paymentInformationId - }.firstOrNull() - } - // Now look for the payment in the database. - assert(maybeArrivedPayment != null) - } - } - } - /** - * Testing that Nexus downloads one transaction from - * Sandbox via the x-libeufin-bank protocol supplier - * and stores it in the Nexus internal transactions - * table. - * - * NOTE: the test should be extended by checking that - * downloading twice the transaction doesn't lead to asset - * duplication locally in Nexus. - */ - @Test - fun fetchTransaction() { - withTestDatabase { - prepSandboxDb() - prepNexusDb() - testApplication { - // Creating the Sandbox transaction that's expected to be ingested. - wireTransfer( - debitAccount = "bar", - creditAccount = "foo", - demobank = "default", - subject = "x-libeufin-bank test transaction", - amount = "TESTKUDOS:333" - ) - val fooUser = getNexusUser("foo") - // Creating the x-libeufin-bank connection to interact with Sandbox. - val conn = XlibeufinBankConnectionProtocol() - val jDetails = """{ - "username": "foo", - "password": "foo", - "baseUrl": "http://localhost/demobanks/default/access-api" - }""".trimIndent() - conn.createConnection( - connId = "x", - user = fooUser, - data = mapper.readTree(jDetails) - ) - // Starting _Sandbox_ to check how it reacts to Nexus request. - application(sandboxApp) - /** - * Doing two rounds of download: the first is expected to - * record the payment as new, and the second is expected to - * ignore it because it has already it in the database. - */ - repeat(2) { - // Invoke transaction fetcher offered by the x-libeufin-bank connection. - conn.fetchTransactions( - fetchSpec = FetchSpecAllJson( - FetchLevel.STATEMENT, - null - ), - accountId = "foo", - bankConnectionId = "x", - client = client - ) - } - // The messages are in the database now, invoke the - // ingestion routine to parse them into the Nexus internal - // format. - ingestBankMessagesIntoAccount("x", "foo") - // Asserting that the payment made it to the database in the Nexus format. - transaction { - val maybeTx = NexusBankTransactionEntity.all() - // This assertion checks that the payment is not doubled in the database: - assert(maybeTx.count() == 1L) - val tx = maybeTx.first().parseDetailsIntoObject<CamtBankAccountEntry>() - assert(tx.getSingletonSubject() == "x-libeufin-bank test transaction") - } - } - } - } - - // Testing that Nexus responds with correct connection details. - // Currently only testing that the request doesn't throw any error. - @Test - fun connectionDetails() { - withTestDatabase { - prepNexusDb() - testApplication { - application(nexusApp) - val r = client.get("/bank-connections/bar") { - basicAuth("bar", "bar") - expectSuccess = true - } - println(r.bodyAsText()) - } - } - } -} -\ No newline at end of file diff --git a/nexus/src/test/kotlin/XPathTest.kt b/nexus/src/test/kotlin/XPathTest.kt @@ -1,42 +0,0 @@ -package tech.libeufin.nexus - -import org.junit.Test -import org.w3c.dom.Document -import tech.libeufin.util.XMLUtil -import tech.libeufin.util.pickString - -class XPathTest { - - @Test - fun pickDataFromSimpleXml() { - val xml = """ - <root xmlns="foo"> - <node>lorem ipsum</node> - </root>""".trimIndent() - val doc: Document = XMLUtil.parseStringIntoDom(xml) - println(doc.pickString( "//*[local-name()='node']")) - } -} - - - - - - - - - - - - - - - - - - - - - - - diff --git a/nexus/src/test/resources/iso20022-samples/camt.053/de.camt.053.001.02.xml b/nexus/src/test/resources/iso20022-samples/camt.053/de.camt.053.001.02.xml @@ -1,488 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!-- This file has been placed in the public domain --> -<!-- Sample camt.053 according to the interpretation of the German DK rules --> -<!-- IBANs have been randomly generated with a BBAN of 12345678 --> -<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02 camt.053.001.02.xsd"> - <BkToCstmrStmt> - <GrpHdr> - <MsgId>msg-001</MsgId> - <CreDtTm>2020-07-03T12:44:40+05:30</CreDtTm> - </GrpHdr> - <Stmt> - <Id>stmt-001</Id> - <CreDtTm>2020-07-03T11:00:40+05:30</CreDtTm> - <Acct> - <Id> - <IBAN>DE54123456784713474163</IBAN> - </Id> - </Acct> - <Bal> - <Tp> - <CdOrPrtry> - <Cd>PRCD</Cd> - </CdOrPrtry> - </Tp> - <Amt Ccy="EUR">500</Amt> - <CdtDbtInd>CRDT</CdtDbtInd> - <Dt> - <Dt>2020-07-03</Dt> - </Dt> - </Bal> - - <!-- Credit due to incoming SCT --> - <Ntry> - <Amt Ccy="EUR">100.00</Amt> - <CdtDbtInd>CRDT</CdtDbtInd> - <Sts>BOOK</Sts> - <BookgDt> - <Dt>2020-07-02</Dt> - </BookgDt> - <ValDt> - <Dt>2020-07-04</Dt> - </ValDt> - <AcctSvcrRef>acctsvcrref-001</AcctSvcrRef> - <BkTxCd> - <Domn> - <Cd>PMNT</Cd> - <Fmly> - <Cd>RCDT</Cd> - <SubFmlyCd>ESCT</SubFmlyCd> - </Fmly> - </Domn> - <Prtry> - <Cd>166</Cd> - <Issr>DK</Issr> - </Prtry> - </BkTxCd> - <NtryDtls> - <TxDtls> - <Refs> - <EndToEndId>e2e-001</EndToEndId> - </Refs> - <BkTxCd> - <Domn> - <Cd>PMNT</Cd> - <Fmly> - <Cd>RCDT</Cd> - <SubFmlyCd>ESCT</SubFmlyCd> - </Fmly> - </Domn> - <Prtry> - <Cd>NTRF+166</Cd> - <Issr>DK</Issr> - </Prtry> - </BkTxCd> - <RltdPties> - <Dbtr> - <Nm>Debtor One</Nm> - </Dbtr> - <DbtrAcct> - <Id> - <IBAN>DE52123456789473323175</IBAN> - </Id> - </DbtrAcct> - <UltmtDbtr> - <Nm>Ultimate Debtor One</Nm> - </UltmtDbtr> - <Cdtr> - <Nm>Creditor One</Nm> - </Cdtr> - <UltmtCdtr> - <Nm>Ultimate Creditor One</Nm> - </UltmtCdtr> - </RltdPties> - <Purp> - <Cd>GDDS</Cd> - </Purp> - <RmtInf> - <Ustrd>unstructured info one</Ustrd> - </RmtInf> - </TxDtls> - </NtryDtls> - <AddtlNtryInf>SEPA GUTSCHRIFT</AddtlNtryInf> - </Ntry> - - <!-- Entry to illustrate multiple ustrd elements --> - <Ntry> - <Amt Ccy="EUR">50.00</Amt> - <CdtDbtInd>CRDT</CdtDbtInd> - <Sts>BOOK</Sts> - <BookgDt> - <Dt>2020-07-02</Dt> - </BookgDt> - <ValDt> - <Dt>2020-07-04</Dt> - </ValDt> - <AcctSvcrRef>acctsvcrref-002</AcctSvcrRef> - <BkTxCd> - <Domn> - <Cd>PMNT</Cd> - <Fmly> - <Cd>RCDT</Cd> - <SubFmlyCd>ESCT</SubFmlyCd> - </Fmly> - </Domn> - <Prtry> - <Cd>166</Cd> - <Issr>DK</Issr> - </Prtry> - </BkTxCd> - <!-- Credit due to incoming SCT --> - <NtryDtls> - <TxDtls> - <Refs> - <EndToEndId>e2e-002</EndToEndId> - </Refs> - <BkTxCd> - <Domn> - <Cd>PMNT</Cd> - <Fmly> - <Cd>RCDT</Cd> - <SubFmlyCd>ESCT</SubFmlyCd> - </Fmly> - </Domn> - <Prtry> - <Cd>NTRF+166</Cd> - <Issr>DK</Issr> - </Prtry> - </BkTxCd> - <RltdPties> - <Dbtr> - <Nm>Debtor One</Nm> - </Dbtr> - <DbtrAcct> - <Id> - <IBAN>DE52123456789473323175</IBAN> - </Id> - </DbtrAcct> - <Cdtr> - <Nm>Creditor One</Nm> - </Cdtr> - </RltdPties> - <RmtInf> - <Ustrd>unstructured </Ustrd> - <Ustrd>info </Ustrd> - <Ustrd>across </Ustrd> - <Ustrd>lines</Ustrd> - </RmtInf> - </TxDtls> - </NtryDtls> - </Ntry> - - <!-- - Credit due to a return resulting from a batch payment initiation where only one payment failed. - This data was obtained by doing a transaction on a GLS Bank account, but we've replaced - the account's IBAN with a random one. - Note how the original creditor and debtor are preserved and not flipped. - Unfortunately the original payment didn't have an end-to-end ID, so it would be harder - to correlate this message to the original payment initiation --> - <Ntry> - <Amt Ccy="EUR">1.12</Amt> - <CdtDbtInd>CRDT</CdtDbtInd> - <Sts>BOOK</Sts> - <BookgDt> - <Dt>2020-06-30</Dt> - </BookgDt> - <ValDt> - <Dt>2020-06-30</Dt> - </ValDt> - <AcctSvcrRef>2020063011423362000</AcctSvcrRef> - <BkTxCd> - <Domn> - <Cd>PMNT</Cd> - <Fmly> - <Cd>ICDT</Cd> - <SubFmlyCd>RRTN</SubFmlyCd> - </Fmly> - </Domn> - <Prtry> - <Cd>NRTI+159+00931</Cd> - <Issr>DK</Issr> - </Prtry> - </BkTxCd> - <NtryDtls> - <TxDtls> - <Refs> - <EndToEndId>NOTPROVIDED</EndToEndId> - </Refs> - <AmtDtls> - <TxAmt> - <Amt Ccy="EUR">1.12</Amt> - </TxAmt> - </AmtDtls> - <BkTxCd> - <Domn> - <Cd>PMNT</Cd> - <Fmly> - <Cd>ICDT</Cd> - <SubFmlyCd>RRTN</SubFmlyCd> - </Fmly> - </Domn> - <Prtry> - <Cd>NRTI+159+00931</Cd> - <Issr>DK</Issr> - </Prtry> - </BkTxCd> - <RltdPties> - <Dbtr> - <Nm>Account Owner</Nm> - </Dbtr> - <DbtrAcct> - <Id> - <IBAN>DE54123456784713474163</IBAN> - </Id> - </DbtrAcct> - <Cdtr> - <Nm>Nonexistent Creditor</Nm> - </Cdtr> - <CdtrAcct> - <Id> - <IBAN>DE24500105177398216438</IBAN> - </Id> - </CdtrAcct> - </RltdPties> - <RmtInf> - <Ustrd>Retoure SEPA Ueberweisung vom 29.06.2020, Rueckgabegrund: AC01 IBAN fehlerhaft und ungültig SVWZ: RETURN, Sammelposten Nummer Zwei IBAN: DE2</Ustrd> - <Ustrd>4500105177398216438 BIC: INGDDEFFXXX</Ustrd> - </RmtInf> - <RtrInf> - <OrgnlBkTxCd> - <Prtry> - <Cd>116</Cd> - <Issr>DK</Issr> - </Prtry> - </OrgnlBkTxCd> - <Orgtr> - <Id> - <OrgId> - <BICOrBEI>GENODEM1GLS</BICOrBEI> - </OrgId> - </Id> - </Orgtr> - <Rsn> - <Cd>AC01</Cd> - </Rsn> - <AddtlInf>IBAN fehlerhaft und ungültig</AddtlInf> - </RtrInf> - </TxDtls> - </NtryDtls> - <AddtlNtryInf>Retouren</AddtlNtryInf> - </Ntry> - - <!-- Credit due to incoming USD transfer --> - <Ntry> - <Amt Ccy="EUR">1000</Amt> - <CdtDbtInd>CRDT</CdtDbtInd> - <Sts>BOOK</Sts> - <BookgDt> - <Dt>2020-07-03</Dt> - </BookgDt> - <ValDt> - <Dt>2020-07-04</Dt> - </ValDt> - <AcctSvcrRef>acctsvcrref-002</AcctSvcrRef> - <BkTxCd> - <Domn> - <Cd>PMNT</Cd> - <Fmly> - <Cd>RCDT</Cd> - <SubFmlyCd>XBCT</SubFmlyCd> - </Fmly> - </Domn> - <Prtry> - <Cd>NTRF+202</Cd> - <Issr>DK</Issr> - </Prtry> - </BkTxCd> - <NtryDtls> - <TxDtls> - <AmtDtls> - <InstdAmt> - <Amt Ccy="USD">1500</Amt> - </InstdAmt> - <TxAmt> - <Amt Ccy="EUR">1000</Amt> - </TxAmt> - <CntrValAmt> - <Amt Ccy="EUR">1250.0</Amt> - <CcyXchg> - <SrcCcy>USD</SrcCcy> - <TrgtCcy>EUR</TrgtCcy> - <XchgRate>1.20</XchgRate> - </CcyXchg> - </CntrValAmt> - </AmtDtls> - <BkTxCd> - <Domn> - <Cd>PMNT</Cd> - <Fmly> - <Cd>RCDT</Cd> - <SubFmlyCd>XBCT</SubFmlyCd> - </Fmly> - </Domn> - <Prtry> - <Cd>NTRF+202</Cd> - <Issr>DK</Issr> - </Prtry> - </BkTxCd> - <Chrgs> - <Amt Ccy="EUR">250.00</Amt> - </Chrgs> - <RltdPties> - <Dbtr> - <Nm>Mr USA</Nm> - <PstlAdr> - <Ctry>US</Ctry> - <AdrLine>42 Some Street</AdrLine> - <AdrLine>4242 Somewhere</AdrLine> - </PstlAdr> - </Dbtr> - <DbtrAcct> - <Id> - <Othr> - <Id>9876543</Id> - </Othr> - </Id> - </DbtrAcct> - </RltdPties> - <RltdAgts> - <DbtrAgt> - <FinInstnId> - <BIC>BANKUSNY</BIC> - </FinInstnId> - </DbtrAgt> - </RltdAgts> - <RmtInf> - <Ustrd>Invoice No. 4242</Ustrd> - </RmtInf> - </TxDtls> - </NtryDtls> - <AddtlNtryInf>AZV-UEBERWEISUNGSGUTSCHRIFT</AddtlNtryInf> - </Ntry> - - <Ntry> - <Amt Ccy="EUR">48.42</Amt> - <CdtDbtInd>DBIT</CdtDbtInd> - <Sts>BOOK</Sts> - <BookgDt> - <Dt>2020-07-07</Dt> - </BookgDt> - <ValDt> - <Dt>2020-07-07</Dt> - </ValDt> - <AcctSvcrRef>acctsvcrref-005</AcctSvcrRef> - <BkTxCd> - <Domn> - <Cd>PMNT</Cd> - <Fmly> - <Cd>ICDT</Cd> - <SubFmlyCd>ESCT</SubFmlyCd> - </Fmly> - </Domn> - </BkTxCd> - <AmtDtls> - <TxAmt> - <Amt Ccy="CHF">46.3</Amt> - </TxAmt> - </AmtDtls> - <NtryDtls> - <Btch> - <MsgId>UXC20070700006</MsgId> - <PmtInfId>UXC20070700006PI00001</PmtInfId> - <NbOfTxs>2</NbOfTxs> - <TtlAmt Ccy="EUR">46.3</TtlAmt> - <CdtDbtInd>DBIT</CdtDbtInd> - </Btch> - <TxDtls> - <AmtDtls> - <TxAmt> - <Amt Ccy="EUR">23.1</Amt> - </TxAmt> - </AmtDtls> - <BkTxCd> - <Domn> - <Cd>PMNT</Cd> - <Fmly> - <Cd>ICDT</Cd> - <SubFmlyCd>ESCT</SubFmlyCd> - </Fmly> - </Domn> - </BkTxCd> - <RltdPties> - <Cdtr> - <Nm>Zahlungsempfaenger 23, ZA 5, DE</Nm> - <PstlAdr> - <Ctry>DE</Ctry> - <AdrLine>DE Adresszeile 1</AdrLine> - <AdrLine>DE Adresszeile 2</AdrLine> - </PstlAdr> - </Cdtr> - <CdtrAcct> - <Id> - <IBAN>DE32733516350012345678</IBAN> - </Id> - </CdtrAcct> - </RltdPties> - <RltdAgts> - <CdtrAgt> - <FinInstnId> - <BIC>BYLADEM1ALR</BIC> - </FinInstnId> - </CdtrAgt> - </RltdAgts> - </TxDtls> - <TxDtls> - <Refs> - <MsgId>asdfasdf</MsgId> - <AcctSvcrRef>5j3k453k45</AcctSvcrRef> - <PmtInfId>6j564l56</PmtInfId> - <InstrId>6jl5lj65afasdf</InstrId> - <EndToEndId>jh45k34h5l</EndToEndId> - </Refs> - <AmtDtls> - <TxAmt> - <Amt Ccy="EUR">23.2</Amt> - </TxAmt> - </AmtDtls> - <BkTxCd> - <Domn> - <Cd>PMNT</Cd> - <Fmly> - <Cd>ICDT</Cd> - <SubFmlyCd>ESCT</SubFmlyCd> - </Fmly> - </Domn> - <Prtry> - <Cd>K25</Cd> - </Prtry> - </BkTxCd> - <RltdPties> - <Cdtr> - <Nm>Zahlungsempfaenger 23, ZA 5, AT</Nm> - <PstlAdr> - <Ctry>AT</Ctry> - <AdrLine>AT Adresszeile 1</AdrLine> - <AdrLine>AT Adresszeile 2</AdrLine> - </PstlAdr> - </Cdtr> - <CdtrAcct> - <Id> - <IBAN>AT071100000012345678</IBAN> - </Id> - </CdtrAcct> - </RltdPties> - <RltdAgts> - <CdtrAgt> - <FinInstnId> - <BIC>BKAUATWW</BIC> - </FinInstnId> - </CdtrAgt> - </RltdAgts> - </TxDtls> - </NtryDtls> - <AddtlNtryInf>Order</AddtlNtryInf> - </Ntry> - - </Stmt> - </BkToCstmrStmt> -</Document> diff --git a/nexus/src/test/resources/logback-test.xml b/nexus/src/test/resources/logback-test.xml @@ -1,28 +0,0 @@ -<!-- configuration scan="true" --> -<configuration> - <appender name="STDERR" class="ch.qos.logback.core.ConsoleAppender"> - <target>System.err</target> - <encoder> - <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> - </encoder> - </appender> - - <logger name="tech.libeufin.nexus" level="ALL" additivity="false"> - <appender-ref ref="STDERR" /> - </logger> - - <logger name="tech.libeufin.sandbox" level="ALL" additivity="false"> - <appender-ref ref="STDERR" /> - </logger> - - <logger name="io.netty" level="WARN"/> - <logger name="ktor" level="WARN"/> - <logger name="Exposed" level="WARN"/> - <logger name="tech.libeufin.util" level="DEBUG"/> - <logger name="ch.qos" level="WARN"/> - - <root level="WARN"> - <appender-ref ref="STDERR"/> - </root> - -</configuration> diff --git a/util/build.gradle b/util/build.gradle @@ -58,9 +58,6 @@ dependencies { testImplementation group: 'junit', name: 'junit', version: '4.13.2' testImplementation 'org.jetbrains.kotlin:kotlin-test-junit:1.5.21' testImplementation 'org.jetbrains.kotlin:kotlin-test:1.5.21' - testImplementation project(":bank") - testImplementation project(":nexus") - } application { diff --git a/util/src/main/kotlin/Config.kt b/util/src/main/kotlin/Config.kt @@ -64,10 +64,7 @@ fun getValueFromEnv(varName: String): String? { return ret } -/** - * Gets the connection string in Postgres format and - * returns the JDBC version of it. - */ +// Gets the DB connection string from env, or fail if not found. fun getDbConnFromEnv(varName: String): String { val dbConnStr = System.getenv(varName) if (dbConnStr.isNullOrBlank() or dbConnStr.isNullOrEmpty()) { diff --git a/util/src/main/kotlin/DB.kt b/util/src/main/kotlin/DB.kt @@ -255,12 +255,16 @@ fun connectWithSchema(jdbcConn: String, schemaName: String? = null) { } } +// Prepends "jdbc:" to the Postgres database connection string. +fun getJdbcConnectionFromPg(pgConn: String): String { + return "jdbc:$pgConn" +} /** * This function converts a postgresql://-URI to a JDBC one. * It is only needed because JDBC strings based on Unix domain * sockets need individual intervention. */ -fun getJdbcConnectionFromPg(pgConn: String): String { +fun _getJdbcConnectionFromPg(pgConn: String): String { if (!pgConn.startsWith("postgresql://") && !pgConn.startsWith("postgres://")) { logger.info("Not a Postgres connection string: $pgConn") throw internalServerError("Not a Postgres connection string: $pgConn") diff --git a/util/src/main/kotlin/HTTP.kt b/util/src/main/kotlin/HTTP.kt @@ -36,7 +36,7 @@ fun badGateway(msg: String): UtilError { /** * Returns the token (including the 'secret-token:' prefix) - * from a Authorization header. Throws exception on malformations + * from an Authorization header. Throws exception on malformations * Note, the token gets URL-decoded before being returned. */ fun extractToken(authHeader: String): String { @@ -153,20 +153,14 @@ fun expectAdmin(username: String?) { } fun getHTTPBasicAuthCredentials(request: io.ktor.server.request.ApplicationRequest): Pair<String, String> { - val authHeader = getAuthorizationHeader(request) + val authHeader = getAuthorizationRawHeader(request) return extractUserAndPassword(authHeader) } -/** - * Extracts the Authorization:-header line and throws error if not found. - */ -fun getAuthorizationHeader(request: ApplicationRequest): String { +// Extracts the Authorization:-header line and throws error if not found. +fun getAuthorizationRawHeader(request: ApplicationRequest): String { val authorization = request.headers["Authorization"] - // logger.debug("Found Authorization header: $authorization") - return authorization ?: throw UtilError( - HttpStatusCode.Unauthorized, "Authorization header not found", - LibeufinErrorCode.LIBEUFIN_EC_AUTHENTICATION_FAILED - ) + return authorization ?: throw badRequest("Authorization header not found") } // Builds the Authorization:-header value, given the credentials. @@ -176,6 +170,24 @@ fun buildBasicAuthLine(username: String, password: String): String { val enc = bytesToBase64(cred.toByteArray(Charsets.UTF_8)) return ret+enc } + +/** + * Holds the details contained in an Authorization header. + * The content is held as it was found in the header and supposed + * to be processed according to the scheme. + */ +data class AuthorizationDetails( + val scheme: String, + val content: String +) +// Returns the authorization scheme mentioned in the Auth header. +fun getAuthorizationDetails(authorizationHeader: String): AuthorizationDetails { + val split = authorizationHeader.split(" ") + if (split.isEmpty()) throw badRequest("malformed Authorization header: contains no space") + if (split.size != 2) throw badRequest("malformed Authorization header: contains more than one space") + return AuthorizationDetails(scheme = split[0], content = split[1]) +} + /** * This helper function parses a Authorization:-header line, decode the credentials * and returns a pair made of username and hashed (sha256) password. The hashed value diff --git a/util/src/main/kotlin/iban.kt b/util/src/main/kotlin/iban.kt @@ -4,10 +4,10 @@ import java.math.BigInteger fun getIban(): String { val ccNoCheck = "131400" // DE00 - val bban = (0..3).map { + val bban = (0..10).map { (0..9).random() }.joinToString("") // 4 digits BBAN. - var checkDigits = "98".toBigInteger().minus("$bban$ccNoCheck".toBigInteger().mod("97".toBigInteger())).toString() + var checkDigits: String = "98".toBigInteger().minus("$bban$ccNoCheck".toBigInteger().mod("97".toBigInteger())).toString() if (checkDigits.length == 1) { checkDigits = "0${checkDigits}" } diff --git a/util/src/main/kotlin/startServer.kt b/util/src/main/kotlin/startServer.kt @@ -30,6 +30,8 @@ private fun serverMain(options: StartServerOptions, app: Application.() -> Unit) } module(app) }, + // Maybe remove this? Was introduced + // to debug concurrency issues.. configure = { connectionGroupSize = 1 workerGroupSize = 1 diff --git a/util/src/main/kotlin/time.kt b/util/src/main/kotlin/time.kt @@ -30,6 +30,8 @@ fun setClock(rel: Duration) { fun getNow(): ZonedDateTime { return ZonedDateTime.now(ZoneId.systemDefault()) } + +fun ZonedDateTime.toMicro(): Long = this.nano / 1000L fun getNowMillis(): Long = getNow().toInstant().toEpochMilli() fun getSystemTimeNow(): ZonedDateTime { diff --git a/util/src/test/kotlin/StartServerTest.kt b/util/src/test/kotlin/StartServerTest.kt @@ -1,32 +0,0 @@ -import org.junit.Ignore -import org.junit.Test -import tech.libeufin.nexus.server.nexusApp -import tech.libeufin.sandbox.sandboxApp -import tech.libeufin.util.StartServerOptions -import tech.libeufin.util.startServerWithIPv4Fallback - -@Ignore -class StartServerTest { - @Test - fun sandboxStart() { - startServerWithIPv4Fallback( - options = StartServerOptions( - ipv4OnlyOpt = false, - localhostOnlyOpt = false, - portOpt = 5000 - ), - app = sandboxApp - ) - } - @Test - fun nexusStart() { - startServerWithIPv4Fallback( - options = StartServerOptions( - ipv4OnlyOpt = false, - localhostOnlyOpt = true, - portOpt = 5000 - ), - app = nexusApp - ) - } -} -\ No newline at end of file