From 80004a0fe5d4971a768c75a77b4f3ce9dfa9281b Mon Sep 17 00:00:00 2001 From: samwel141 Date: Wed, 30 Sep 2026 14:57:13 +0300 Subject: [PATCH] fix(action-token): let a claim name the wallet that receives The app sends the wallet on every claim, and the schema stopped accepting it, so Joi rejected the request and every claim answered 422 "wallet is not allowed". Redeem accepts it again and checks it with canActFor: the account id first, falling back to a manage trust for wallets that carry no keycloak id. Absent the field, the login wallet still receives, as before. This is also what the app needs to honour the wallet a visitor just created to claim into, which was the original report. Closes Greenstand/treetracker-wallet-app#855 --- __tests__/actiontoken-redeem-wallet.spec.js | 81 +++++++++++++++++++ server/handlers/actionTokenHandler/index.js | 8 +- server/handlers/actionTokenHandler/schemas.js | 1 + server/services/ActionTokenService.js | 18 ++++- 4 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 __tests__/actiontoken-redeem-wallet.spec.js diff --git a/__tests__/actiontoken-redeem-wallet.spec.js b/__tests__/actiontoken-redeem-wallet.spec.js new file mode 100644 index 00000000..228a90df --- /dev/null +++ b/__tests__/actiontoken-redeem-wallet.spec.js @@ -0,0 +1,81 @@ +require('dotenv').config(); +const request = require('supertest'); +const { expect } = require('chai'); +const server = require('../server/app'); +const seed = require('./seed'); + +describe('Action token: claiming into a chosen wallet', () => { + let bearerTokenA; + let bearerTokenB; + let link; + + beforeEach(async () => { + await seed.clear(); + await seed.seed(); + bearerTokenA = seed.wallet.keycloak_account_id; + bearerTokenB = seed.walletB.keycloak_account_id; + + const issued = await request(server) + .post('/action-tokens') + .set('Authorization', `Bearer ${bearerTokenA}`) + .send({ + recipient_email: 'samwel@example.com', + bundle: { bundle_size: 1 }, + }) + .expect(201); + link = issued.body.action_token; + }); + + it('accepts a wallet name, which the app sends on every claim', async () => { + const res = await request(server) + .post('/action-tokens/redeem') + .set('Authorization', `Bearer ${bearerTokenB}`) + .send({ action_token: link, wallet: seed.walletB.name }); + + expect(res).to.have.property('statusCode', 200); + expect(res.body).to.have.property('state', 'completed'); + }); + + it('puts the tokens in the wallet just created, not the login wallet', async () => { + const created = await request(server) + .post('/wallets') + .set('content-type', 'application/json') + .set('Authorization', `Bearer ${bearerTokenB}`) + .send({ wallet: 'walletB-second' }) + .expect(201); + + const redeemed = await request(server) + .post('/action-tokens/redeem') + .set('Authorization', `Bearer ${bearerTokenB}`) + .send({ action_token: link, wallet: 'walletB-second' }) + .expect(200); + + const moved = redeemed.body.parameters.tokens[0]; + + const inNew = await request(server) + .get(`/tokens?wallet=walletB-second`) + .set('Authorization', `Bearer ${bearerTokenB}`) + .expect(200); + + expect(created.body.id).to.exist; + expect(inNew.body.tokens.map((t) => t.id)).to.include(moved); + }); + + it('still claims into the login wallet when no wallet is named', async () => { + const res = await request(server) + .post('/action-tokens/redeem') + .set('Authorization', `Bearer ${bearerTokenB}`) + .send({ action_token: link }); + + expect(res).to.have.property('statusCode', 200); + }); + + it('refuses a wallet the account does not own', async () => { + const res = await request(server) + .post('/action-tokens/redeem') + .set('Authorization', `Bearer ${bearerTokenB}`) + .send({ action_token: link, wallet: seed.wallet.name }); + + expect(res).to.have.property('statusCode', 403); + }); +}); diff --git a/server/handlers/actionTokenHandler/index.js b/server/handlers/actionTokenHandler/index.js index f7a1d6c9..4ecba5b5 100644 --- a/server/handlers/actionTokenHandler/index.js +++ b/server/handlers/actionTokenHandler/index.js @@ -25,10 +25,14 @@ const actionTokenRedeem = async (req, res) => { const validatedBody = await actionTokenRedeemSchema.validateAsync(req.body, { abortEarly: false, }); - const { wallet_id } = req; + const { wallet_id, keycloak_id } = req; const actionTokenService = new ActionTokenService(); - const result = await actionTokenService.redeem(validatedBody, wallet_id); + const result = await actionTokenService.redeem( + validatedBody, + wallet_id, + keycloak_id, + ); res.status(200).json(result); }; diff --git a/server/handlers/actionTokenHandler/schemas.js b/server/handlers/actionTokenHandler/schemas.js index bd3a28a1..c51b5cca 100644 --- a/server/handlers/actionTokenHandler/schemas.js +++ b/server/handlers/actionTokenHandler/schemas.js @@ -26,6 +26,7 @@ const actionTokenGenerateSchema = Joi.alternatives().conditional( const actionTokenRedeemSchema = Joi.object({ action_token: Joi.string().required(), + wallet: Joi.string(), }); module.exports = { actionTokenGenerateSchema, actionTokenRedeemSchema }; diff --git a/server/services/ActionTokenService.js b/server/services/ActionTokenService.js index 73375ec2..f86e7d18 100644 --- a/server/services/ActionTokenService.js +++ b/server/services/ActionTokenService.js @@ -143,11 +143,25 @@ class ActionTokenService { }; } - async redeem({ action_token }, walletLoginId) { + async redeem({ action_token, wallet }, walletLoginId, keycloakId) { const payload = ActionTokenService.verifyActionToken(action_token); + + let receiverWalletId = walletLoginId; + if (wallet) { + const receiver = await this._walletService.getByName(wallet); + const isOwn = await this._walletService.canActFor(receiver, { + keycloakId, + walletLoginId, + }); + if (!isOwn) { + throw new HttpError(403, 'Wallet does not belong to this account'); + } + receiverWalletId = receiver.id; + } + return this._transferService.redeemActionToken({ senderWalletId: payload.sender_wallet_id, - receiverWalletId: walletLoginId, + receiverWalletId, tokenIds: payload.token_ids, tokenCount: payload.token_count, });