taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit fc237ea7c02f209440a0935f6cd2ce6aa44deee3
parent bc8d12e92cd78611cccd4b45c2aa1b70fe1d390b
Author: Florian Dold <dold@taler.net>
Date:   Sun, 13 Sep 2026 23:22:42 +0200

taler-harness: add Android KYC and transaction action scenarios

Keep KYC completion and transaction actions in separate scenario modules,
with shared service fixtures and reusable Maestro flows. Cover withdrawal,
P2P and deposit KYC, bank-account authentication, aborts, suspension,
resumption and deletion in the opt-in Android suite.

Use real bank transfers for deposit authentication and read the transfer
reference from the displayed wallet instructions.

Diffstat:
Mpackages/taler-harness/android/README.md | 48+++++++++++++++++++++++++++++++++++++++++++-----
Apackages/taler-harness/android/flows/back.yaml | 17+++++++++++++++++
Apackages/taler-harness/android/flows/balance-amount.yaml | 20++++++++++++++++++++
Mpackages/taler-harness/android/flows/balance.yaml | 1+
Apackages/taler-harness/android/flows/deposit-kyc-auth.yaml | 32++++++++++++++++++++++++++++++++
Apackages/taler-harness/android/flows/deposit-submit.yaml | 30++++++++++++++++++++++++++++++
Mpackages/taler-harness/android/flows/deposit.yaml | 22+++++-----------------
Apackages/taler-harness/android/flows/kyc-required.yaml | 22++++++++++++++++++++++
Apackages/taler-harness/android/flows/kyc-visit.yaml | 43+++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/android/flows/payment-cancel.yaml | 27+++++++++++++++++++++++++++
Apackages/taler-harness/android/flows/peer-ready.yaml | 22++++++++++++++++++++++
Apackages/taler-harness/android/flows/peer-receive.yaml | 30++++++++++++++++++++++++++++++
Apackages/taler-harness/android/flows/peer-request.yaml | 29+++++++++++++++++++++++++++++
Apackages/taler-harness/android/flows/peer-send.yaml | 29+++++++++++++++++++++++++++++
Apackages/taler-harness/android/flows/transaction-action.yaml | 19+++++++++++++++++++
Apackages/taler-harness/android/flows/transaction-delete.yaml | 42++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/android/flows/transaction-open.yaml | 19+++++++++++++++++++
Apackages/taler-harness/android/flows/transaction-state.yaml | 21+++++++++++++++++++++
Mpackages/taler-harness/android/flows/withdraw-complete.yaml | 7++++---
Mpackages/taler-harness/android/flows/withdraw.yaml | 1-
Mpackages/taler-harness/src/harness/android-wallet.ts | 24++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-wallet-android-kyc.ts | 219+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-wallet-android-transactions.ts | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-wallet-android.ts | 78+++++++++++-------------------------------------------------------------------
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 4++++
Apackages/taler-harness/src/integrationtests/wallet-android-test-support.ts | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
26 files changed, 1103 insertions(+), 93 deletions(-)

diff --git a/packages/taler-harness/android/README.md b/packages/taler-harness/android/README.md @@ -9,8 +9,11 @@ and settlement use the harness service clients. ## Requirements - The usual local integration-test services and PostgreSQL used by taler-harness. + Withdrawal abort and deposit authentication use Libeufin Bank: Fakebank does + not implement the withdrawal abort endpoint or customer bank transfers. - Java 21, the Android SDK (`platform-tools`, `build-tools`, an emulator and system image), and Maestro 2.10.0. +- The installed exchange KYC web UI and Chrome on the emulator for the KYC browser handoff. - A disposable Android emulator in English. Set `TALER_ANDROID_DEVICE` to its explicit adb serial. The suite reinstalls **net.taler.wallet.nightly**, deleting its wallet data on every test. @@ -33,6 +36,13 @@ it with a temporary test key, verifies the signature, and prints its SHA-256. It does not require the nightly publishing key. Rebuild after Android source changes; the test runner installs the supplied APK without downloading one. +Use an APK with wallet core built from this checkout. The core bundled with +`net.taler:qtart:1.6.42` predates the withdrawal resume fix (`1b31cd676`) and the +fixes included with these scenarios: KYC authentication transfer subjects, +deposit continuation after authentication, and cancellation of proposed payments. +Rebuilding only the Android sources with that older dependency still uses the +older wallet core. + ## Run Start a configured emulator, wait for it to boot, and check `adb devices`: @@ -97,6 +107,30 @@ taler-harness run-integrationtests --suites wallet-android \ runs the exchange aggregator and transfer worker three hours ahead; the Android device's clock stays unchanged. +The additional scenarios exercise one outcome per fresh wallet: + +| Area | Scenario | Required outcome | +| -------- | ------------------------------------ | ------------------------------------------------------------------------- | +| KYC | Bank withdrawal | Visit KYC instructions, return, finish after approval | +| KYC | Receive a P2P payment | Approve the recipient, receive funds, sender completes | +| KYC | Create a P2P request | Approve the requester, publish the invoice, receive payment | +| KYC | Deposit to the withdrawal account | KYC without an authentication transfer, then bank credit | +| KYC auth | Deposit to a different account | Inspect wire instructions, authenticate, complete KYC, then bank credit | +| Actions | Abort a bank withdrawal | Wallet and bank both record the abort | +| Actions | Retry, suspend, resume a withdrawal | Retry stays pending; bank confirmation is processed only after resume | +| Actions | Delete a completed transaction | Cancel leaves it intact; confirm removes history without changing balance | +| Actions | Abort during deposit KYC or KYC auth | Recover funds and leave the recipient bank balance unchanged | +| Actions | Abort an unclaimed P2P payment | Recover funds and retain the aborted transaction after restart | +| Actions | Abort a proposed purchase | No merchant payment and no wallet balance change | + +KYC uses a deterministic exchange INFO requirement. Maestro opens the real +exchange KYC web UI and checks its instructions; the harness then acts as the +reviewer through the exchange AML API. These scenarios cover the Android handoff +and transaction continuation, not an external identity provider's forms. +For P2P, a separate harness wallet supplies the other party. The payment-request +URI and authentication-transfer reference are read from the displayed UI using +Maestro's accessibility hierarchy, without accessing the Android wallet database. + Each scenario writes harness service logs and `android-logcat-stdout.log` to its result directory. Each UI phase has a JUnit report and Maestro's command log, failure screenshot, and accessibility hierarchy under `android/`. Named @@ -106,8 +140,12 @@ ports it created. If a process is forcibly killed, remove its port mappings before rerunning (`adb -s "$TALER_ANDROID_DEVICE" reverse --remove tcp:8081`, and similarly for 8082 and 8083). -The YAML files are small UI phases invoked by -`src/integrationtests/test-wallet-android.ts`, rather than a standalone Maestro -directory suite. Keep backend fixtures and assertions in TypeScript and UI -actions in the flows. Waiting should depend on visible state, with an explicit -timeout for asynchronous transaction completion. +The TypeScript scenarios are grouped by behavior: `test-wallet-android.ts` +contains the basic operations, `test-wallet-android-kyc.ts` the KYC completion +paths, and `test-wallet-android-transactions.ts` the transaction actions. +`wallet-android-test-support.ts` owns service setup, funding, AML approval, and +settlement. Each scenario explicitly orders its UI phases and backend assertions. +The YAML files are reusable UI phases, not a standalone Maestro directory suite. +Keep backend fixtures and assertions in TypeScript and UI actions in the flows. +Waiting should depend on visible state, with an explicit timeout for asynchronous +transaction completion. diff --git a/packages/taler-harness/android/flows/back.yaml b/packages/taler-harness/android/flows/back.yaml @@ -0,0 +1,17 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- back diff --git a/packages/taler-harness/android/flows/balance-amount.yaml b/packages/taler-harness/android/flows/balance-amount.yaml @@ -0,0 +1,20 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- launchApp +- tapOn: "Balances" +- assertVisible: ".*${AMOUNT}" +- takeScreenshot: checked-balance diff --git a/packages/taler-harness/android/flows/balance.yaml b/packages/taler-harness/android/flows/balance.yaml @@ -15,5 +15,6 @@ appId: ${APP_ID} --- - launchApp +- tapOn: "Balances" - assertVisible: ".*9[.]85" - takeScreenshot: funded-balance diff --git a/packages/taler-harness/android/flows/deposit-kyc-auth.yaml b/packages/taler-harness/android/flows/deposit-kyc-auth.yaml @@ -0,0 +1,32 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- runFlow: + file: transaction-state.yaml + env: + STATE: "You need to prove having control over the bank account for the deposit.*" +- assertNotVisible: "Complete KYC" +- tapOn: "Wire transfer instructions" +- assertVisible: "You need to transfer .*0[.]01.* from the bank account ${ACCOUNT}.*" +- assertVisible: "Do not use a different bank account for the deposit, or the verification will fail[.]" +- scrollUntilVisible: + element: "Subject" +- assertVisible: "Taler KYC [0-9A-HJKMNP-TV-Z]{52}" +- assertVisible: "This is mandatory, otherwise the verification will fail[.]" +- assertVisible: + text: "exchange" + below: "Account" +- takeScreenshot: deposit-auth-instructions diff --git a/packages/taler-harness/android/flows/deposit-submit.yaml b/packages/taler-harness/android/flows/deposit-submit.yaml @@ -0,0 +1,30 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- launchApp +- tapOn: + text: "Actions" + traits: square +- tapOn: "Deposit" +- tapOn: "Android payout" +- assertVisible: ${ACCOUNT} +- tapOn: + containsChild: + text: "Amount to deposit" +# The amount input shifts digits into fractional places: 200 is 2.00. +- inputText: "200" +- assertVisible: "1[.]98 TESTKUDOS" +- tapOn: "Make deposit" diff --git a/packages/taler-harness/android/flows/deposit.yaml b/packages/taler-harness/android/flows/deposit.yaml @@ -14,21 +14,9 @@ appId: ${APP_ID} --- -- launchApp -- tapOn: - text: "Actions" - traits: square -- tapOn: "Deposit" -- tapOn: "Android payout" -- assertVisible: ${ACCOUNT} -- tapOn: - containsChild: - text: "Amount to deposit" -# The amount input shifts digits into fractional places: 200 is 2.00. -- inputText: "200" -- assertVisible: "1[.]98 TESTKUDOS" -- tapOn: "Make deposit" -- extendedWaitUntil: - visible: "This transaction is finalizing" - timeout: 60000 +- runFlow: deposit-submit.yaml +- runFlow: + file: transaction-state.yaml + env: + STATE: "This transaction is finalizing" - takeScreenshot: deposit-awaiting-settlement diff --git a/packages/taler-harness/android/flows/kyc-required.yaml b/packages/taler-harness/android/flows/kyc-required.yaml @@ -0,0 +1,22 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- runFlow: + file: transaction-state.yaml + env: + STATE: "This transaction would exceed a limit set by the payment service.*" +- assertVisible: "Complete KYC" +- assertNotVisible: "Wire transfer instructions" diff --git a/packages/taler-harness/android/flows/kyc-visit.yaml b/packages/taler-harness/android/flows/kyc-visit.yaml @@ -0,0 +1,43 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- runFlow: kyc-required.yaml +- tapOn: "Complete KYC" +- extendedWaitUntil: + visible: "Use without an account|Android identity check: contact the test exchange[.]" + timeout: 60000 +# A fresh emulator may still have Chrome's first-run screens. +- runFlow: + when: + visible: "Use without an account" + commands: + - tapOn: "Use without an account" +- runFlow: + when: + visible: "No thanks" + commands: + - tapOn: "No thanks" +- runFlow: + when: + visible: "Got it" + commands: + - tapOn: "Got it" +- extendedWaitUntil: + visible: "Android identity check: contact the test exchange[.]" + timeout: 60000 +- takeScreenshot: exchange-kyc-instructions +- back +- assertVisible: "Complete KYC" diff --git a/packages/taler-harness/android/flows/payment-cancel.yaml b/packages/taler-harness/android/flows/payment-cancel.yaml @@ -0,0 +1,27 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- assertVisible: "Review order" +- tapOn: "Cancel" +- assertVisible: "Are you sure you want to cancel this payment[?].*" +- tapOn: "Go back" +- assertVisible: "Review order" +- tapOn: "Cancel" +- tapOn: + text: "Cancel payment" + below: "Are you sure you want to cancel this payment[?].*" + enabled: true +- takeScreenshot: payment-cancelled diff --git a/packages/taler-harness/android/flows/peer-ready.yaml b/packages/taler-harness/android/flows/peer-ready.yaml @@ -0,0 +1,22 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- extendedWaitUntil: + visible: "Alternatively, copy and send this URI:" + timeout: 60000 +- scrollUntilVisible: + element: "taler[+]http://.*" +- takeScreenshot: peer-ready diff --git a/packages/taler-harness/android/flows/peer-receive.yaml b/packages/taler-harness/android/flows/peer-receive.yaml @@ -0,0 +1,30 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- runFlow: open-uri.yaml +- assertVisible: "Do you want to receive this payment[?]" +- assertVisible: ${SUMMARY} +- runFlow: + when: + visible: "Review terms of service" + commands: + - tapOn: "Review terms of service" + - assertVisible: "Android UI test exchange terms[.]" + - tapOn: "Accept Terms of Service" +- tapOn: + text: "Receive payment" + below: ${SUMMARY} + enabled: true diff --git a/packages/taler-harness/android/flows/peer-request.yaml b/packages/taler-harness/android/flows/peer-request.yaml @@ -0,0 +1,29 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- launchApp +- tapOn: + text: "Actions" + traits: square +- tapOn: "Request" +- tapOn: + containsChild: + text: "Amount to receive" +- inputText: "200" +- tapOn: "Purpose" +- inputText: ${SUMMARY} +- hideKeyboard +- tapOn: "Request .*2[.]00" diff --git a/packages/taler-harness/android/flows/peer-send.yaml b/packages/taler-harness/android/flows/peer-send.yaml @@ -0,0 +1,29 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- launchApp +- tapOn: + text: "Actions" + traits: square +- tapOn: "Send" +- tapOn: + containsChild: + text: "Amount to send" +- inputText: "200" +- tapOn: "Purpose" +- inputText: ${SUMMARY} +- hideKeyboard +- tapOn: "Send .*2[.]00" diff --git a/packages/taler-harness/android/flows/transaction-action.yaml b/packages/taler-harness/android/flows/transaction-action.yaml @@ -0,0 +1,19 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- scrollUntilVisible: + element: ${ACTION} +- tapOn: ${ACTION} diff --git a/packages/taler-harness/android/flows/transaction-delete.yaml b/packages/taler-harness/android/flows/transaction-delete.yaml @@ -0,0 +1,42 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- runFlow: + file: transaction-action.yaml + env: + ACTION: "Delete" +- assertVisible: "Delete transaction" +- tapOn: "Cancel" +- runFlow: + file: transaction-state.yaml + env: + STATE: ${STATE} +- runFlow: + file: transaction-action.yaml + env: + ACTION: "Delete" +- assertVisible: "Delete transaction" +- tapOn: + text: "Delete" + below: "Are you sure you want to remove this transaction from your wallet[?]" + rightOf: "Cancel" +- extendedWaitUntil: + visible: "Balances" + timeout: 10000 +- launchApp +- tapOn: "From http://localhost:8081" +- assertNotVisible: ${TITLE} +- takeScreenshot: transaction-deleted diff --git a/packages/taler-harness/android/flows/transaction-open.yaml b/packages/taler-harness/android/flows/transaction-open.yaml @@ -0,0 +1,19 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- launchApp +- tapOn: "From http://localhost:8081" +- tapOn: ${TITLE} diff --git a/packages/taler-harness/android/flows/transaction-state.yaml b/packages/taler-harness/android/flows/transaction-state.yaml @@ -0,0 +1,21 @@ +# This file is part of GNU Taler +# (C) 2026 Taler Systems S.A. +# +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# GNU Taler 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +appId: ${APP_ID} +--- +- scrollUntilVisible: + direction: UP + element: ${STATE} + timeout: 60000 +- takeScreenshot: transaction-state diff --git a/packages/taler-harness/android/flows/withdraw-complete.yaml b/packages/taler-harness/android/flows/withdraw-complete.yaml @@ -14,9 +14,10 @@ appId: ${APP_ID} --- -- extendedWaitUntil: - visible: "This transaction was completed successfully" - timeout: 60000 +- runFlow: + file: transaction-state.yaml + env: + STATE: "This transaction was completed successfully" - assertVisible: ".*9[.]85" - assertVisible: ".*10[.]00" - assertVisible: ".*0[.]15" diff --git a/packages/taler-harness/android/flows/withdraw.yaml b/packages/taler-harness/android/flows/withdraw.yaml @@ -14,7 +14,6 @@ appId: ${APP_ID} --- -- runFlow: setup.yaml - runFlow: file: open-uri.yaml env: diff --git a/packages/taler-harness/src/harness/android-wallet.ts b/packages/taler-harness/src/harness/android-wallet.ts @@ -126,4 +126,28 @@ export class AndroidWallet { await this.adb("reverse", "--remove", `tcp:${port}`); } } + + /** Read a displayed URI or bank-transfer subject for the external counterparty. */ + async readText(pattern: RegExp): Promise<string> { + const hierarchy = await runCommand( + this.t, + "android-hierarchy", + process.env.MAESTRO ?? "maestro", + ["--device", this.serial, "hierarchy", "--no-ansi"], + ); + type Node = { attributes?: { text?: string }; children?: Node[] }; + const texts: string[] = []; + function visit(node: Node) { + if (node.attributes?.text) texts.push(node.attributes.text); + node.children?.forEach(visit); + } + visit(JSON.parse(hierarchy)); + const matches = [...new Set(texts.filter((text) => pattern.test(text)))]; + if (matches.length !== 1) { + throw Error( + `Expected one displayed text matching ${pattern}, found ${matches.length}`, + ); + } + return matches[0]; + } } diff --git a/packages/taler-harness/src/integrationtests/test-wallet-android-kyc.ts b/packages/taler-harness/src/integrationtests/test-wallet-android-kyc.ts @@ -0,0 +1,219 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + Amounts, + CreditDebitIndicator, + succeedOrThrow, + TalerCoreBankHttpClient, + TransactionMajorState, + TransactionIdStr, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { doPeerPushDebit } from "../harness/environments.js"; +import { GlobalTestState } from "../harness/harness.js"; +import { + approveAndroidKyc, + confirmAndroidWithdrawal, + fundAndroidCounterparty, + settleAndroidDeposit, + startAndroidWithdrawal, + withAndroidWallet, +} from "./wallet-android-test-support.js"; + +export async function runWalletAndroidKycWithdrawalTest(t: GlobalTestState) { + await withAndroidWallet( + t, + { funded: false, kycOperation: "withdraw" }, + async (wallet, env) => { + const withdrawal = await startAndroidWithdrawal(wallet, env); + await confirmAndroidWithdrawal(env, withdrawal); + await wallet.flow("kyc-visit"); + await approveAndroidKyc(t, env); + await wallet.flow("withdraw-complete"); + await wallet.flow("balance"); + }, + ); +} + +/** The Android recipient must complete KYC before it can merge the sender's purse. */ +export async function runWalletAndroidKycPeerPushTest(t: GlobalTestState) { + await withAndroidWallet( + t, + { funded: false, kycOperation: "merge" }, + async (wallet, env) => { + await fundAndroidCounterparty(t, env); + const summary = "Android KYC incoming payment"; + const payment = await doPeerPushDebit(t, { + walletClient: env.walletClient, + amount: "TESTKUDOS:2", + summary, + }); + await wallet.flow("peer-receive", { + URI: payment.talerUri, + SUMMARY: summary, + }); + await wallet.flow("kyc-visit"); + await approveAndroidKyc(t, env); + await wallet.flow("transaction-state", { + STATE: "This transaction was completed successfully", + }); + await env.walletClient.call( + WalletApiOperation.TestingWaitTransactionState, + { + transactionId: payment.transactionId as TransactionIdStr, + txState: { major: TransactionMajorState.Done }, + }, + ); + }, + ); +} + +/** An Android payment request needs KYC before it becomes a payable invoice. */ +export async function runWalletAndroidKycPeerPullTest(t: GlobalTestState) { + await withAndroidWallet( + t, + { funded: true, kycOperation: "merge" }, + async (wallet, env) => { + await fundAndroidCounterparty(t, env); + await wallet.flow("peer-request", { + SUMMARY: "Android KYC payment request", + }); + await wallet.flow("kyc-visit"); + await approveAndroidKyc(t, env); + await wallet.flow("peer-ready"); + const uri = await wallet.readText(/^taler\+http:\/\/pay-pull\//); + const payment = await env.walletClient.call( + WalletApiOperation.PreparePeerPullDebit, + { talerUri: uri }, + ); + await env.walletClient.call(WalletApiOperation.ConfirmPeerPullDebit, { + transactionId: payment.transactionId, + }); + await wallet.flow("transaction-state", { + STATE: "This transaction was completed successfully", + }); + await env.walletClient.call( + WalletApiOperation.TestingWaitTransactionState, + { + transactionId: payment.transactionId as TransactionIdStr, + txState: { major: TransactionMajorState.Done }, + }, + ); + }, + ); +} + +/** Depositing back to the funding account requires KYC but no authentication transfer. */ +export async function runWalletAndroidKycDepositTest(t: GlobalTestState) { + await withAndroidWallet( + t, + { funded: true, kycOperation: "deposit" }, + async (wallet, env, account) => { + t.assertTrue(account !== undefined); + env.bankClient.setAuth(account); + const before = await env.bankClient.getAccountBalance(account.username); + await wallet.flow("save-account", { ACCOUNT: account.username }); + await wallet.flow("deposit-submit", { ACCOUNT: account.username }); + await wallet.flow("kyc-visit"); + await approveAndroidKyc(t, env); + await wallet.flow("transaction-state", { + STATE: "This transaction is finalizing", + }); + await settleAndroidDeposit(env); + await wallet.flow("deposit-complete"); + const after = await env.bankClient.getAccountBalance(account.username); + t.assertDeepEqual( + after.balance.credit_debit_indicator, + CreditDebitIndicator.Credit, + ); + t.assertAmountEquals( + after.balance.amount, + Amounts.add(before.balance.amount, "TESTKUDOS:1.98").amount, + ); + }, + ); +} + +/** A different account must be authenticated before the ordinary deposit KYC check. */ +export async function runWalletAndroidKycAuthDepositTest(t: GlobalTestState) { + // Use a real customer transfer; Fakebank has no POST /accounts/.../transactions. + await withAndroidWallet( + t, + { funded: true, kycOperation: "deposit", bank: "libeufin" }, + async (wallet, env) => { + const account = await env.bankClient.createRandomBankUser(); + env.bankClient.setAuth(account); + const before = await env.bankClient.getAccountBalance(account.username); + await wallet.flow("save-account", { ACCOUNT: account.username }); + await wallet.flow("deposit-submit", { ACCOUNT: account.username }); + await wallet.flow("deposit-kyc-auth", { ACCOUNT: account.username }); + // Use the reference the user sees in the wire instructions, not wallet internals. + const subject = await wallet.readText( + /^Taler KYC [0-9A-HJKMNP-TV-Z]{52}$/, + ); + const bankApi = new TalerCoreBankHttpClient(env.bank.corebankApiBaseUrl); + const token = + account.token ?? + succeedOrThrow( + await bankApi.createAccessToken( + account.username, + { + type: "basic", + username: account.username, + password: account.password, + }, + { scope: "readwrite" }, + ), + ).access_token; + const payto = new URL(env.exchangeBankAccount.accountPaytoUri); + payto.searchParams.set("message", subject); + succeedOrThrow( + await bankApi.createTransaction( + { username: account.username, token }, + { payto_uri: payto.href, amount: "TESTKUDOS:0.01" }, + ), + ); + // The instructions close automatically when the bank transfer authenticates the account. + await wallet.flow("kyc-visit"); + await approveAndroidKyc(t, env); + await wallet.flow("transaction-state", { + STATE: "This transaction is finalizing", + }); + await settleAndroidDeposit(env); + await wallet.flow("deposit-complete"); + const after = await env.bankClient.getAccountBalance(account.username); + t.assertDeepEqual( + after.balance.credit_debit_indicator, + CreditDebitIndicator.Credit, + ); + t.assertAmountEquals( + after.balance.amount, + Amounts.add(before.balance.amount, "TESTKUDOS:1.97").amount, + ); + }, + ); +} + +export const androidWalletKycTests = [ + runWalletAndroidKycWithdrawalTest, + runWalletAndroidKycPeerPushTest, + runWalletAndroidKycPeerPullTest, + runWalletAndroidKycDepositTest, + runWalletAndroidKycAuthDepositTest, +].map((test) => + Object.assign(test, { suites: ["wallet-android"], timeoutMs: 360_000 }), +); diff --git a/packages/taler-harness/src/integrationtests/test-wallet-android-transactions.ts b/packages/taler-harness/src/integrationtests/test-wallet-android-transactions.ts @@ -0,0 +1,208 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + succeedOrThrow, + TalerBankIntegrationHttpClient, + TalerMerchantInstanceHttpClient, +} from "@gnu-taler/taler-util"; +import { GlobalTestState } from "../harness/harness.js"; +import { + approveAndroidKyc, + confirmAndroidWithdrawal, + settleAndroidDeposit, + startAndroidWithdrawal, + withAndroidWallet, +} from "./wallet-android-test-support.js"; + +const aborted = "This transaction was aborted"; +const suspended = "This transaction is suspended"; + +/** Aborting before bank authorization must also cancel the bank operation. */ +export async function runWalletAndroidWithdrawalAbortTest(t: GlobalTestState) { + // Fakebank does not implement the Bank Integration API abort endpoint. + await withAndroidWallet( + t, + { funded: false, bank: "libeufin" }, + async (wallet, env) => { + const withdrawal = await startAndroidWithdrawal(wallet, env); + await wallet.flow("transaction-action", { ACTION: "Abort" }); + await wallet.flow("transaction-open", { TITLE: "Withdraw" }); + await wallet.flow("transaction-state", { STATE: aborted }); + const bank = new TalerBankIntegrationHttpClient( + `${env.bank.corebankApiBaseUrl}taler-integration/`, + ); + const state = succeedOrThrow( + await bank.getWithdrawalOperationById( + withdrawal.operation.withdrawal_id, + ), + ); + t.assertDeepEqual(state.status, "aborted"); + }, + ); +} + +/** A bank confirmation received while suspended is processed only after resume. */ +export async function runWalletAndroidWithdrawalSuspendResumeTest( + t: GlobalTestState, +) { + await withAndroidWallet(t, { funded: false }, async (wallet, env) => { + const withdrawal = await startAndroidWithdrawal(wallet, env); + await wallet.flow("transaction-action", { ACTION: "Retry" }); + await wallet.flow("transaction-state", { + STATE: "Waiting for authorization in the bank", + }); + await wallet.flow("transaction-action", { ACTION: "Suspend" }); + await wallet.flow("transaction-state", { STATE: suspended }); + await confirmAndroidWithdrawal(env, withdrawal); + await wallet.flow("transaction-open", { TITLE: "Withdraw" }); + await wallet.flow("transaction-state", { STATE: suspended }); + await wallet.flow("transaction-action", { ACTION: "Resume" }); + await wallet.flow("withdraw-complete"); + await wallet.flow("balance"); + }); +} + +/** Deleting history must require confirmation and leave the wallet balance unchanged. */ +export async function runWalletAndroidTransactionDeleteTest( + t: GlobalTestState, +) { + await withAndroidWallet(t, { funded: true }, async (wallet) => { + await wallet.flow("transaction-delete", { + TITLE: "Withdraw", + STATE: "This transaction was completed successfully", + }); + await wallet.flow("balance"); + }); +} + +async function depositAbortTest( + t: GlobalTestState, + accountKind: "funding" | "new", +) { + t.allowLog({ + file: "exchange-httpd-testexchange-1-stderr.log", + level: "WARNING", + message: + /^External protocol violation detected at \.\.\/src\/exchange\/taler-exchange-httpd_post-coins-COIN_PUB-refund\.c:\d+\.$/, + maxOccurrences: 1, + justification: + "aborting the KYC-blocked deposit requests a refund for a coin whose deposit the exchange never accepted", + }); + await withAndroidWallet( + t, + { funded: true, kycOperation: "deposit" }, + async (wallet, env, fundingAccount) => { + t.assertTrue(fundingAccount !== undefined); + const account = + accountKind === "funding" + ? fundingAccount + : await env.bankClient.createRandomBankUser(); + env.bankClient.setAuth(account); + const before = await env.bankClient.getAccountBalance(account.username); + await wallet.flow("save-account", { ACCOUNT: account.username }); + await wallet.flow("deposit-submit", { ACCOUNT: account.username }); + if (accountKind === "funding") { + await wallet.flow("kyc-required"); + } else { + await wallet.flow("deposit-kyc-auth", { ACCOUNT: account.username }); + await wallet.flow("back"); + } + await wallet.flow("transaction-action", { ACTION: "Abort" }); + await wallet.flow("transaction-open", { + TITLE: "Deposit to Android test customer", + }); + await wallet.flow("transaction-state", { STATE: aborted }); + if (accountKind === "funding") await approveAndroidKyc(t, env); + await settleAndroidDeposit(env); + const after = await env.bankClient.getAccountBalance(account.username); + t.assertDeepEqual(after.balance, before.balance); + // Coin selection and recovery cost 0.31; the remaining 9.54 is available again. + await wallet.flow("balance-amount", { AMOUNT: "9[.]54" }); + }, + ); +} + +export async function runWalletAndroidKycDepositAbortTest(t: GlobalTestState) { + await depositAbortTest(t, "funding"); +} + +export async function runWalletAndroidKycAuthDepositAbortTest( + t: GlobalTestState, +) { + await depositAbortTest(t, "new"); +} + +/** An unclaimed outgoing payment can be reclaimed and remains aborted after restart. */ +export async function runWalletAndroidPeerPushAbortTest(t: GlobalTestState) { + await withAndroidWallet(t, { funded: true }, async (wallet) => { + const summary = "Android unclaimed payment"; + await wallet.flow("peer-send", { SUMMARY: summary }); + await wallet.flow("peer-ready"); + await wallet.flow("transaction-action", { ACTION: "Abort" }); + await wallet.flow("transaction-open", { TITLE: "Sending" }); + await wallet.flow("transaction-state", { STATE: aborted }); + // Creating and reclaiming the purse costs 0.26 in this exchange fixture. + await wallet.flow("balance-amount", { AMOUNT: "9[.]59" }); + }); +} + +/** Cancel a proposed purchase before authorizing any payment. */ +export async function runWalletAndroidPaymentAbortTest(t: GlobalTestState) { + await withAndroidWallet(t, { funded: true }, async (wallet, env) => { + const client = new TalerMerchantInstanceHttpClient( + env.merchant.makeInstanceBaseUrl(), + ); + const order = succeedOrThrow( + await client.createOrder(env.merchantAdminAccessToken, { + order: { summary: "Android cancelled purchase", amount: "TESTKUDOS:1" }, + }), + ); + const unpaid = succeedOrThrow( + await client.getOrderDetails( + env.merchantAdminAccessToken, + order.order_id, + ), + ); + t.assertTrue(unpaid.order_status === "unpaid"); + await wallet.flow("open-uri", { URI: unpaid.taler_pay_uri }); + await wallet.flow("payment-cancel"); + await wallet.flow("transaction-open", { + TITLE: "Default Instance", + }); + await wallet.flow("transaction-state", { STATE: aborted }); + const after = succeedOrThrow( + await client.getOrderDetails( + env.merchantAdminAccessToken, + order.order_id, + ), + ); + t.assertDeepEqual(after.order_status, "claimed"); + await wallet.flow("balance"); + }); +} + +export const androidWalletTransactionTests = [ + runWalletAndroidWithdrawalAbortTest, + runWalletAndroidWithdrawalSuspendResumeTest, + runWalletAndroidTransactionDeleteTest, + runWalletAndroidKycDepositAbortTest, + runWalletAndroidKycAuthDepositAbortTest, + runWalletAndroidPeerPushAbortTest, + runWalletAndroidPaymentAbortTest, +].map((test) => + Object.assign(test, { suites: ["wallet-android"], timeoutMs: 360_000 }), +); diff --git a/packages/taler-harness/src/integrationtests/test-wallet-android.ts b/packages/taler-harness/src/integrationtests/test-wallet-android.ts @@ -14,8 +14,6 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import fs from "node:fs"; -import path from "node:path"; import { AmountString, Amounts, @@ -25,72 +23,20 @@ import { TalerMerchantInstanceHttpClient, TemplateType, } from "@gnu-taler/taler-util"; -import { AndroidWallet } from "../harness/android-wallet.js"; -import { createSimpleTestkudosEnvironmentV3 } from "../harness/environments.js"; import { GlobalTestState } from "../harness/harness.js"; - -type Environment = Awaited< - ReturnType<typeof createSimpleTestkudosEnvironmentV3> ->; -type Scenario = (wallet: AndroidWallet, env: Environment) => Promise<void>; - -/** Every test owns fresh services and wallet data; all wallet actions use the UI. */ -async function withAndroidWallet( - t: GlobalTestState, - funded: boolean, - scenario: Scenario, -) { - const wallet = new AndroidWallet(t); - try { - await wallet.prepare(); - const terms = path.resolve(t.testDir, "terms"); - fs.mkdirSync(path.join(terms, "en"), { recursive: true }); - fs.writeFileSync( - path.join(terms, "en", "v1.txt"), - "Android UI test exchange terms.\n", - ); - const env = await createSimpleTestkudosEnvironmentV3(t, undefined, { - additionalExchangeConfig(exchange) { - exchange.changeConfig((config) => { - config.setString("exchange", "terms_etag", "v1"); - config.setString("exchange", "terms_dir", terms); - }); - }, - }); - if (funded) { - const { bankClient } = env; - const user = await bankClient.createRandomBankUser(); - bankClient.setAuth(user); - const withdrawal = await bankClient.createWithdrawalOperation( - user.username, - "TESTKUDOS:10", - ); - await wallet.flow("withdraw", { - WITHDRAW_URI: withdrawal.taler_withdraw_uri, - }); - succeedOrThrow( - await bankClient.confirmWithdrawalOperation(user.username, { - withdrawalOperationId: withdrawal.withdrawal_id, - }), - ); - await wallet.flow("withdraw-complete"); - } else { - await wallet.flow("setup"); - } - await scenario(wallet, env); - } finally { - await wallet.close(); - } -} +import { + withAndroidWallet, + settleAndroidDeposit, +} from "./wallet-android-test-support.js"; export async function runWalletAndroidWithdrawalTest(t: GlobalTestState) { - await withAndroidWallet(t, true, async (wallet) => { + await withAndroidWallet(t, { funded: true }, async (wallet) => { await wallet.flow("balance"); }); } export async function runWalletAndroidPaymentTest(t: GlobalTestState) { - await withAndroidWallet(t, true, async (wallet, env) => { + await withAndroidWallet(t, { funded: true }, async (wallet, env) => { const client = new TalerMerchantInstanceHttpClient( env.merchant.makeInstanceBaseUrl(), ); @@ -132,7 +78,7 @@ async function templateTest( editableAmount: boolean, editableSummary: boolean, ) { - await withAndroidWallet(t, true, async (wallet, env) => { + await withAndroidWallet(t, { funded: true }, async (wallet, env) => { const client = new TalerMerchantInstanceHttpClient( env.merchant.makeInstanceBaseUrl(), ); @@ -208,13 +154,13 @@ export async function runWalletAndroidFixedSummaryTemplateTest( } export async function runWalletAndroidExchangesTest(t: GlobalTestState) { - await withAndroidWallet(t, false, async (wallet, env) => { + await withAndroidWallet(t, { funded: false }, async (wallet, env) => { await wallet.flow("exchanges", { EXCHANGE_URL: env.exchange.baseUrl }); }); } export async function runWalletAndroidBankAccountsTest(t: GlobalTestState) { - await withAndroidWallet(t, false, async (wallet, env) => { + await withAndroidWallet(t, { funded: false }, async (wallet, env) => { const user = await env.bankClient.createRandomBankUser(); await wallet.flow("bank-accounts", { ACCOUNT: user.username, @@ -224,7 +170,7 @@ export async function runWalletAndroidBankAccountsTest(t: GlobalTestState) { } export async function runWalletAndroidDepositTest(t: GlobalTestState) { - await withAndroidWallet(t, true, async (wallet, env) => { + await withAndroidWallet(t, { funded: true }, async (wallet, env) => { const user = await env.bankClient.createRandomBankUser(); env.bankClient.setAuth(user); const before = await env.bankClient.getAccountBalance(user.username); @@ -232,9 +178,7 @@ export async function runWalletAndroidDepositTest(t: GlobalTestState) { await wallet.flow("deposit", { ACCOUNT: user.username }); // Settle without waiting for the wire deadline. Advance only the exchange's // settlement workers, leaving the Android device's clock and TLS intact. - const time = { timetravelMicroseconds: 3 * 60 * 60 * 1_000_000 }; - await env.exchange.runAggregatorOnceWithTimetravel(time); - await env.exchange.runTransferOnceWithTimetravel(time); + await settleAndroidDeposit(env); await wallet.flow("deposit-complete"); const after = await env.bankClient.getAccountBalance(user.username); t.assertDeepEqual( diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -258,6 +258,8 @@ import { runWalletWebUiPwaCashAcceptorTest } from "./test-wallet-web-ui-pwa-cash import { runWalletWebUiPwaStartupTest } from "./test-wallet-web-ui-pwa-startup.js"; import { runWalletWebUiPwaLifecycleTest } from "./test-wallet-web-ui-pwa-lifecycle.js"; import { androidWalletTests } from "./test-wallet-android.js"; +import { androidWalletKycTests } from "./test-wallet-android-kyc.js"; +import { androidWalletTransactionTests } from "./test-wallet-android-transactions.js"; import { runWalletWebUiPwaStorybookTest } from "./test-wallet-web-ui-pwa-storybook.js"; import { runWalletWebUiDemoTest } from "./test-wallet-web-ui-demo.js"; import { @@ -561,6 +563,8 @@ const allTests: TestMainFunction[] = [ runWalletWebUiPwaStartupTest, runWalletWebUiPwaLifecycleTest, ...androidWalletTests, + ...androidWalletKycTests, + ...androidWalletTransactionTests, runWalletWebUiPwaStorybookTest, runWalletWebUiDemoTest, runWalletWebUiPwaSqliteTest, diff --git a/packages/taler-harness/src/integrationtests/wallet-android-test-support.ts b/packages/taler-harness/src/integrationtests/wallet-android-test-support.ts @@ -0,0 +1,182 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import fs from "node:fs"; +import path from "node:path"; +import { + createEddsaKeyPair, + EddsaPrivP, + encodeCrock, + OfficerId, + OfficerSession, + succeedOrThrow, + TalerExchangeHttpClient, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { AndroidWallet } from "../harness/android-wallet.js"; +import { + configureSimpleKycInfoRule, + createSimpleTestkudosEnvironmentV3, + postAmlDecisionNoRules, + withdrawViaBankV4, +} from "../harness/environments.js"; +import { GlobalTestState } from "../harness/harness.js"; + +export type AndroidEnvironment = Awaited< + ReturnType<typeof createSimpleTestkudosEnvironmentV3> +>; +export type AndroidBankUser = Awaited< + ReturnType<AndroidEnvironment["bankClient"]["createRandomBankUser"]> +>; +export type AndroidKycOperation = "withdraw" | "merge" | "deposit"; + +/** Fresh services and app data per scenario; only the counterparty uses wallet RPC. */ +export async function withAndroidWallet( + t: GlobalTestState, + options: { + funded: boolean; + kycOperation?: AndroidKycOperation; + bank?: "libeufin"; + }, + scenario: ( + wallet: AndroidWallet, + env: AndroidEnvironment, + fundingAccount?: AndroidBankUser, + ) => Promise<void>, +) { + const wallet = new AndroidWallet(t); + try { + await wallet.prepare(); + const terms = path.resolve(t.testDir, "terms"); + fs.mkdirSync(path.join(terms, "en"), { recursive: true }); + fs.writeFileSync( + path.join(terms, "en", "v1.txt"), + "Android UI test exchange terms.\n", + ); + const env = await createSimpleTestkudosEnvironmentV3(t, undefined, { + forceLibeufin: options.bank === "libeufin", + additionalExchangeConfig(exchange) { + exchange.changeConfig((config) => { + config.setString("exchange", "terms_etag", "v1"); + config.setString("exchange", "terms_dir", terms); + if (options.kycOperation) { + configureSimpleKycInfoRule(config, options.kycOperation); + config.setString("KYC-RULE-R1", "threshold", "TESTKUDOS:1"); + config.setString( + "KYC-CHECK-C1", + "description", + "Android identity check: contact the test exchange.", + ); + } + }); + }, + }); + await wallet.flow("setup"); + let fundingAccount: AndroidBankUser | undefined; + if (options.funded) { + const withdrawal = await startAndroidWithdrawal(wallet, env); + fundingAccount = withdrawal.user; + await confirmAndroidWithdrawal(env, withdrawal); + await wallet.flow("withdraw-complete"); + } + await scenario(wallet, env, fundingAccount); + } finally { + await wallet.close(); + } +} + +/** Stop at bank authorization, so the scenario controls completion or abort. */ +export async function startAndroidWithdrawal( + wallet: AndroidWallet, + env: AndroidEnvironment, +) { + const user = await env.bankClient.createRandomBankUser(); + env.bankClient.setAuth(user); + const operation = await env.bankClient.createWithdrawalOperation( + user.username, + "TESTKUDOS:10", + ); + await wallet.flow("withdraw", { WITHDRAW_URI: operation.taler_withdraw_uri }); + return { user, operation }; +} + +export async function confirmAndroidWithdrawal( + env: AndroidEnvironment, + withdrawal: Awaited<ReturnType<typeof startAndroidWithdrawal>>, +) { + env.bankClient.setAuth(withdrawal.user); + succeedOrThrow( + await env.bankClient.confirmWithdrawalOperation(withdrawal.user.username, { + withdrawalOperationId: withdrawal.operation.withdrawal_id, + }), + ); +} + +/** Advance settlement workers only; leave the device clock unchanged. */ +export async function settleAndroidDeposit(env: AndroidEnvironment) { + const time = { timetravelMicroseconds: 3 * 60 * 60 * 1_000_000 }; + await env.exchange.runAggregatorOnceWithTimetravel(time); + await env.exchange.runTransferOnceWithTimetravel(time); +} + +/** Act as the exchange's reviewer after the customer has visited the real KYC SPA. */ +export async function approveAndroidKyc( + t: GlobalTestState, + env: AndroidEnvironment, +) { + const keys = createEddsaKeyPair(); + const officer: OfficerSession = { + id: encodeCrock(keys.eddsaPub) as OfficerId, + __signingKey: keys.eddsaPriv as EddsaPrivP, + }; + await env.exchange.enableAmlAccount(officer.id, "Android KYC reviewer"); + const api = new TalerExchangeHttpClient(env.exchange.baseUrl); + const pending = succeedOrThrow( + await api.getAmlLegitimizations(officer, { active: true }), + ); + // Each isolated scenario triggers exactly one check. Never approve unrelated accounts. + t.assertDeepEqual(pending.measures.length, 1); + await postAmlDecisionNoRules(t, { + exchangeBaseUrl: env.exchange.baseUrl, + amlPub: officer.id, + amlPriv: encodeCrock(keys.eddsaPriv), + paytoHash: pending.measures[0].h_payto, + }); + const remaining = succeedOrThrow( + await api.getAmlLegitimizations(officer, { active: true }), + ); + t.assertDeepEqual(remaining.measures.length, 0); +} + +/** Fund the other party through its wallet client; Android remains controlled by Maestro. */ +export async function fundAndroidCounterparty( + t: GlobalTestState, + env: AndroidEnvironment, +) { + await env.walletClient.call(WalletApiOperation.AddExchange, { + uri: env.exchange.baseUrl, + }); + await env.walletClient.call(WalletApiOperation.SetExchangeTosAccepted, { + exchangeBaseUrl: env.exchange.baseUrl, + }); + const funding = await withdrawViaBankV4(t, { + walletClient: env.walletClient, + bank: env.bank, + exchange: env.exchange, + amount: "TESTKUDOS:10", + }); + await funding.withdrawalFinishedCond; +}