diff --git a/quantara/frontend/src/hooks/useClosePosition.js b/quantara/frontend/src/hooks/useClosePosition.js index 24c1ffad9..294a77108 100644 --- a/quantara/frontend/src/hooks/useClosePosition.js +++ b/quantara/frontend/src/hooks/useClosePosition.js @@ -1,5 +1,5 @@ import { useMutation, useQuery } from '@tanstack/react-query'; -import { axiosInstance } from '../utils/axios'; +import { axiosInstance, getAuthHeaders } from '../utils/axios'; import { closePosition } from '../services/transaction'; import { useWalletStore } from '../stores/useWalletStore'; import { notify } from '../components/layout/notifier/Notifier'; @@ -21,19 +21,19 @@ export const useClosePosition = () => { console.error('closePositionEvent: walletId is undefined'); return; } - const response = await axiosInstance.get('/api/get-repay-data', { - params: { - wallet_id: walletId, - }, - }); + const authHeaders = await getAuthHeaders(walletId); + const response = await axiosInstance.post( + '/api/get-repay-data', + {}, + { headers: authHeaders }, + ); const transactionResult = await closePosition(response.data); console.log('TransactionResult', transactionResult); - await axiosInstance.get('/api/close-position', { - params: { - position_id: response.data.position_id, - transaction_hash: transactionResult.transaction_hash, - }, - }); + await axiosInstance.post( + `/api/close-position/${response.data.position_id}`, + { transaction_hash: transactionResult.transaction_hash }, + { headers: authHeaders }, + ); }, onError: (error) => { console.error('Error during closePositionEvent', error); diff --git a/quantara/frontend/src/hooks/useWithdrawAll.js b/quantara/frontend/src/hooks/useWithdrawAll.js index e38f363d3..d95a70fab 100644 --- a/quantara/frontend/src/hooks/useWithdrawAll.js +++ b/quantara/frontend/src/hooks/useWithdrawAll.js @@ -1,5 +1,5 @@ import { useMutation } from '@tanstack/react-query'; -import { axiosInstance } from '../utils/axios'; +import { axiosInstance, getAuthHeaders } from '../utils/axios'; import { notify } from '../components/layout/notifier/Notifier'; import { sendWithdrawAllTransaction } from '../services/transaction'; @@ -14,16 +14,24 @@ const useWithdrawAll = () => { mutationFn: async (walletId) => { if (!walletId) throw new Error('Wallet ID is required.'); - const { data: withdraw_data } = await axiosInstance.get(`/api/get-withdraw-all-data?wallet_id=${walletId}`); + const authHeaders = await getAuthHeaders(walletId); + + const { data: withdraw_data } = await axiosInstance.post( + '/api/get-withdraw-all-data', + {}, + { headers: authHeaders }, + ); const { transaction_hash } = await sendWithdrawAllTransaction( withdraw_data, withdraw_data.repay_data.contract_address ); - await axiosInstance.get('/api/close-position', { - params: { transaction_hash: transaction_hash, position_id: withdraw_data.repay_data.position_id }, - }); + await axiosInstance.post( + `/api/close-position/${withdraw_data.repay_data.position_id}`, + { transaction_hash }, + { headers: authHeaders }, + ); }, onSuccess: () => { notify('Withdraw All operation completed successfully!', 'success'); diff --git a/quantara/frontend/src/services/transaction.js b/quantara/frontend/src/services/transaction.js index 080cb1eed..83049e86f 100644 --- a/quantara/frontend/src/services/transaction.js +++ b/quantara/frontend/src/services/transaction.js @@ -276,12 +276,11 @@ export const handleTransaction = async (connectedWalletId, formData, setTokenAmo ); // Notify backend of position creation with real transaction hash - await axiosInstance.get(`/api/open-position`, { - params: { - position_id: transactionData.position_id, - transaction_hash, - }, - }); + await axiosInstance.post( + `/api/open-position/${transactionData.position_id}`, + { transaction_hash }, + { headers: authHeaders }, + ); setTokenAmount(''); } catch (err) { diff --git a/quantara/frontend/test/hooks/useClosePosition.test.jsx b/quantara/frontend/test/hooks/useClosePosition.test.jsx new file mode 100644 index 000000000..e9ecc9392 --- /dev/null +++ b/quantara/frontend/test/hooks/useClosePosition.test.jsx @@ -0,0 +1,83 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { useClosePosition } from '../../src/hooks/useClosePosition'; +import { useWalletStore } from '../../src/stores/useWalletStore'; +import { axiosInstance, getAuthHeaders } from '../../src/utils/axios'; +import { closePosition } from '../../src/services/transaction'; + +vi.mock('../../src/utils/axios', () => ({ + axiosInstance: { get: vi.fn(), post: vi.fn() }, + getAuthHeaders: vi.fn(), +})); + +vi.mock('../../src/services/transaction', () => ({ + closePosition: vi.fn(), +})); + +vi.mock('../../src/components/layout/notifier/Notifier', () => ({ + notify: vi.fn(), + ToastWithLink: vi.fn(), +})); + +const AUTH_HEADERS = { + 'x-wallet-id': 'GABC123', + 'x-nonce': 'nonce', + 'x-signature': 'sig', +}; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }); + return ({ children }) => ( + {children} + ); +}; + +describe('useClosePosition', () => { + beforeEach(() => { + vi.clearAllMocks(); + useWalletStore.setState({ walletId: 'GABC123' }); + getAuthHeaders.mockResolvedValue(AUTH_HEADERS); + }); + + afterEach(() => { + useWalletStore.setState({ walletId: null }); + }); + + it('fetches repay data and closes the position via POST with auth headers', async () => { + axiosInstance.post.mockResolvedValueOnce({ + data: { + position_id: 'pos-1', + contract_address: 'C123', + supply_token: 'S', + debt_token: 'D', + }, + }); + closePosition.mockResolvedValue({ transaction_hash: 'txhash' }); + + const { result } = renderHook(() => useClosePosition(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.mutate(); + }); + + await waitFor(() => expect(closePosition).toHaveBeenCalled()); + + expect(getAuthHeaders).toHaveBeenCalledWith('GABC123'); + expect(axiosInstance.post).toHaveBeenCalledWith( + '/api/get-repay-data', + {}, + { headers: AUTH_HEADERS }, + ); + expect(axiosInstance.post).toHaveBeenCalledWith( + '/api/close-position/pos-1', + { transaction_hash: 'txhash' }, + { headers: AUTH_HEADERS }, + ); + }); +}); diff --git a/quantara/frontend/test/hooks/useWithdrawAll.test.jsx b/quantara/frontend/test/hooks/useWithdrawAll.test.jsx new file mode 100644 index 000000000..e989a2c93 --- /dev/null +++ b/quantara/frontend/test/hooks/useWithdrawAll.test.jsx @@ -0,0 +1,80 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import useWithdrawAll from '../../src/hooks/useWithdrawAll'; +import { axiosInstance, getAuthHeaders } from '../../src/utils/axios'; +import { sendWithdrawAllTransaction } from '../../src/services/transaction'; + +vi.mock('../../src/utils/axios', () => ({ + axiosInstance: { get: vi.fn(), post: vi.fn() }, + getAuthHeaders: vi.fn(), +})); + +vi.mock('../../src/services/transaction', () => ({ + sendWithdrawAllTransaction: vi.fn(), +})); + +vi.mock('../../src/components/layout/notifier/Notifier', () => ({ + notify: vi.fn(), + ToastWithLink: vi.fn(), +})); + +const AUTH_HEADERS = { + 'x-wallet-id': 'GABC123', + 'x-nonce': 'nonce', + 'x-signature': 'sig', +}; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }); + return ({ children }) => ( + {children} + ); +}; + +describe('useWithdrawAll', () => { + beforeEach(() => { + vi.clearAllMocks(); + getAuthHeaders.mockResolvedValue(AUTH_HEADERS); + }); + + it('fetches withdraw-all data and closes the position via POST with auth headers', async () => { + axiosInstance.post.mockResolvedValueOnce({ + data: { + repay_data: { + position_id: 'pos-1', + contract_address: 'C123', + supply_token: 'S', + debt_token: 'D', + }, + tokens: [], + }, + }); + sendWithdrawAllTransaction.mockResolvedValue({ transaction_hash: 'txhash' }); + + const { result } = renderHook(() => useWithdrawAll(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.withdrawAll('GABC123'); + }); + + await waitFor(() => expect(sendWithdrawAllTransaction).toHaveBeenCalled()); + + expect(getAuthHeaders).toHaveBeenCalledWith('GABC123'); + expect(axiosInstance.post).toHaveBeenCalledWith( + '/api/get-withdraw-all-data', + {}, + { headers: AUTH_HEADERS }, + ); + expect(axiosInstance.post).toHaveBeenCalledWith( + '/api/close-position/pos-1', + { transaction_hash: 'txhash' }, + { headers: AUTH_HEADERS }, + ); + }); +}); diff --git a/quantara/frontend/test/services/transaction.test.js b/quantara/frontend/test/services/transaction.test.js index 83a59c5f7..c5703d1ba 100644 --- a/quantara/frontend/test/services/transaction.test.js +++ b/quantara/frontend/test/services/transaction.test.js @@ -106,19 +106,18 @@ describe('Transaction Functions', () => { }); it('should handle successful transaction flow', async () => { - axiosInstance.get.mockResolvedValueOnce({ - data: { status: 'open' }, - }); - await handleTransaction(mockWalletId, mockFormData, mockSetTokenAmount, mockSetLoading); + const authHeaders = { 'x-wallet-id': 'mock', 'x-nonce': 'mock', 'x-signature': 'mock' }; expect(mockSetLoading).toHaveBeenCalledWith(true); expect(getWalletPublicKey).toHaveBeenCalled(); - expect(axiosInstance.post).toHaveBeenCalledWith('/api/create-position', mockFormData, { headers: { 'x-wallet-id': 'mock', 'x-nonce': 'mock', 'x-signature': 'mock' } }); + expect(axiosInstance.post).toHaveBeenCalledWith('/api/create-position', mockFormData, { headers: authHeaders }); expect(invokeSorobanContract).toHaveBeenCalled(); - expect(axiosInstance.get).toHaveBeenCalledWith('/api/open-position', { - params: { position_id: 1, transaction_hash: mockTransactionHash }, - }); + expect(axiosInstance.post).toHaveBeenCalledWith( + '/api/open-position/1', + { transaction_hash: mockTransactionHash }, + { headers: authHeaders }, + ); expect(mockSetTokenAmount).toHaveBeenCalledWith(''); expect(mockSetLoading).toHaveBeenCalledWith(false); }); diff --git a/quantara/web_app/api/errors.py b/quantara/web_app/api/errors.py index 68b996971..3f0846b2b 100644 --- a/quantara/web_app/api/errors.py +++ b/quantara/web_app/api/errors.py @@ -160,6 +160,19 @@ async def api_error_handler(request: Request, exc: APIError) -> JSONResponse: } }, }, + 403: { + "description": "Forbidden — the authenticated wallet is not allowed to perform this action.", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ErrorEnvelope"}, + "example": { + "detail": "Wallet does not own this position.", + "code": "not_position_owner", + "request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + }, + } + }, + }, 404: { "description": "Not Found — the requested resource does not exist.", "content": { diff --git a/quantara/web_app/api/position.py b/quantara/web_app/api/position.py index cd083a097..11dae221d 100644 --- a/quantara/web_app/api/position.py +++ b/quantara/web_app/api/position.py @@ -2,14 +2,17 @@ This module handles position-related API endpoints for the Stellar-based Quantara protocol. """ +import json from decimal import Decimal, InvalidOperation from uuid import UUID from fastapi import APIRouter, HTTPException, Query, Depends, Request +from web_app.api.errors import APIError from web_app.api.serializers.position import ( AddPositionDepositData, PositionFormData, + PositionStateChangeRequest, TokenMultiplierResponse, UserPositionExtraDepositsResponse, UserPositionHistoryResponse, @@ -25,7 +28,7 @@ from web_app.db.crud import PositionDBConnector, TransactionDBConnector from web_app.api.dependencies import get_stellar_client from web_app.contract_tools.blockchain_call import StellarClient -from web_app.db.models import Status, TransactionStatus +from web_app.db.models import OutboxEvent, Position, Status, TransactionStatus from web_app.api.rate_limiter import limiter, WRITE_LIMIT, USER_DATA_LIMIT, READ_LIMIT from web_app.utils.logger import get_logger @@ -37,6 +40,33 @@ PAGINATION_STEP = 10 +async def require_position_owner( + position_id: UUID, + wallet: str = Depends(verify_wallet_signature), +) -> Position: + """Resolve a position and verify the authenticated wallet owns it. + + Used as a reusable FastAPI dependency on position-scoped endpoints. + Raises ``APIError(404)`` when the position does not exist and + ``APIError(403)`` when the authenticated wallet is not its owner. + """ + position = position_db_connector.get_position_by_id(position_id) + if position is None: + raise APIError( + status_code=404, + code="position_not_found", + detail="Position not found", + ) + user = position_db_connector.get_user_by_wallet_id(wallet) + if user is None or position.user_id != user.id: + raise APIError( + status_code=403, + code="not_position_owner", + detail="Wallet does not own this position", + ) + return position + + @router.get( "/api/get-multipliers", tags=["Position Operations"], @@ -112,36 +142,46 @@ async def create_position_with_transaction_data( return LoopLiquidityData(**deposit_data) -@router.get( +@router.post( "/api/get-repay-data", tags=["Position Operations"], response_model=RepayTransactionDataResponse, summary="Get repay data", response_description="Returns the repay transaction data.", ) -@limiter.limit(WRITE_LIMIT, key_func=lambda request: f"wallet:{request.query_params.get('wallet_id', request.client.host)}") +@limiter.limit( + WRITE_LIMIT, + key_func=lambda request: f"wallet:{request.headers.get('x-wallet-id', request.client.host)}", +) async def get_repay_data( request: Request, - wallet_id: str, client: StellarClient = Depends(get_stellar_client), + wallet: str = Depends(verify_wallet_signature), ) -> RepayTransactionDataResponse: """ Obtain data for position closing. - :param wallet_id: Wallet ID (Stellar public key G...) + The wallet is taken from the authenticated signature, so callers can only + request repay data for their own wallet. + :return: Dict containing the repay transaction data """ - if not wallet_id: - raise HTTPException(status_code=404, detail="Wallet not found") - contract_address, position_id, token_symbol = position_db_connector.get_repay_data( - wallet_id + wallet + ) + is_opened_position = await PositionMixin.is_opened_position( + contract_address, client ) - is_opened_position = await PositionMixin.is_opened_position(contract_address, client) if not is_opened_position: - raise HTTPException(status_code=400, detail="Position was closed") + raise APIError( + status_code=400, code="position_closed", detail="Position was closed" + ) if not position_id: - raise HTTPException(status_code=404, detail="Position not found or closed") + raise APIError( + status_code=404, + code="position_not_found", + detail="Position not found or closed", + ) repay_data = await DepositMixin.get_repay_data(token_symbol, client) repay_data["contract_address"] = contract_address @@ -149,115 +189,133 @@ async def get_repay_data( return repay_data -@router.get( - "/api/close-position", +@router.post( + "/api/close-position/{position_id}", tags=["Position Operations"], response_model=str, summary="Close a position", response_description="Returns the position status", ) @limiter.limit(WRITE_LIMIT) -async def close_position(request: Request, position_id: UUID, transaction_hash: str) -> str: +async def close_position( + request: Request, + position_id: UUID, + body: PositionStateChangeRequest, + position: Position = Depends(require_position_owner), +) -> str: """ - Close a position. + Close a position owned by the authenticated wallet. :param position_id: Position UUID - :param transaction_hash: Transaction hash from the Soroban close_position call + :param body: Request body carrying the Soroban close_position transaction hash :return: Position status string """ - if position_id is None or str(position_id) == "undefined": - raise HTTPException(status_code=404, detail="Position not Found") - if not transaction_hash: - raise HTTPException(status_code=400, detail="Transaction hash is required") + if not body.transaction_hash: + raise APIError( + status_code=400, + code="transaction_hash_required", + detail="Transaction hash is required", + ) - position_status = position_db_connector.close_position(str(position_id)) + position_status = position_db_connector.close_position(position_id) position_db_connector.save_transaction( - position_id=position_id, status="closed", transaction_hash=transaction_hash + position_id=position_id, status="closed", transaction_hash=body.transaction_hash ) return position_status -@router.get( - "/api/open-position", +@router.post( + "/api/open-position/{position_id}", tags=["Position Operations"], response_model=str, summary="Open a position", response_description="Returns the positions status", ) @limiter.limit(WRITE_LIMIT) -async def open_position(request: Request, position_id: str, transaction_hash: str) -> str: +async def open_position( + request: Request, + position_id: UUID, + body: PositionStateChangeRequest, + position: Position = Depends(require_position_owner), +) -> str: """ Open a position after a successful Soroban loop_liquidity transaction. :param position_id: Position ID - :param transaction_hash: Transaction hash from the Soroban call + :param body: Request body carrying the Soroban loop_liquidity transaction hash :return: Position status string """ - if not position_id: - raise HTTPException(status_code=404, detail="Position not found") - if not transaction_hash: - raise HTTPException(status_code=400, detail="Transaction hash is required") - - from uuid import UUID - import json - from web_app.db.models import OutboxEvent, Position - - try: - pos_uuid = UUID(position_id) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid position ID format") - - position = position_db_connector.get_object(Position, pos_uuid) - if not position: - raise HTTPException(status_code=404, detail="Position not found") + if not body.transaction_hash: + raise APIError( + status_code=400, + code="transaction_hash_required", + detail="Transaction hash is required", + ) - payload = json.dumps({ - "position_id": str(pos_uuid), - "transaction_hash": transaction_hash - }) + payload = json.dumps( + { + "position_id": str(position_id), + "transaction_hash": body.transaction_hash, + } + ) outbox_event = OutboxEvent( event_type="PositionOpened", payload=payload, status="pending", - retry_count=0 + retry_count=0, ) try: position_db_connector.write_to_db(outbox_event) except Exception as e: logger.error("failed_to_write_outbox_event", error=str(e)) - raise HTTPException(status_code=500, detail="Failed to queue position opening") + raise APIError( + status_code=500, + code="outbox_write_failed", + detail="Failed to queue position opening", + ) return "pending" -@router.get( +@router.post( "/api/get-withdraw-all-data", tags=["Position Operations"], summary="Get data to close position and withdraw all tokens", response_model=WithdrawAllData, response_description="Object containing data to withdraw all from the position", ) -@limiter.limit(WRITE_LIMIT, key_func=lambda request: f"wallet:{request.query_params.get('wallet_id', request.client.host)}") +@limiter.limit( + WRITE_LIMIT, + key_func=lambda request: f"wallet:{request.headers.get('x-wallet-id', request.client.host)}", +) async def get_withdraw_data( request: Request, - wallet_id: str, - client: StellarClient = Depends(get_stellar_client) + client: StellarClient = Depends(get_stellar_client), + wallet: str = Depends(verify_wallet_signature), ) -> WithdrawAllData: """ Prepare data to withdraw all tokens and close a position. - :param wallet_id: Stellar public key of the user + The wallet is taken from the authenticated signature, so callers can only + request withdraw data for their own wallet. + :return: Dict containing repay data and list of extra token addresses """ contract_address, position_id, token_symbol = position_db_connector.get_repay_data( - wallet_id + wallet ) if not await PositionMixin.is_opened_position(contract_address, client): - raise HTTPException(status_code=400, detail="Position was closed") + raise APIError( + status_code=400, code="position_closed", detail="Position was closed" + ) if not position_id: - raise HTTPException(status_code=404, detail="Position not found or closed") + raise APIError( + status_code=404, + code="position_not_found", + detail="Position not found or closed", + ) repay_data = await DepositMixin.get_repay_data(token_symbol, client) extra_tokens = position_db_connector.get_extra_deposits_data(position_id).keys() diff --git a/quantara/web_app/api/serializers/position.py b/quantara/web_app/api/serializers/position.py index 0bf9f07b2..64dac8b7e 100644 --- a/quantara/web_app/api/serializers/position.py +++ b/quantara/web_app/api/serializers/position.py @@ -6,7 +6,7 @@ from typing import Optional from uuid import UUID -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, Field, field_validator class PositionFormData(BaseModel): @@ -97,6 +97,16 @@ class AddPositionDepositData(BaseModel): transaction_hash: Optional[str] = None +class PositionStateChangeRequest(BaseModel): + """Request body for the position close/open state-change endpoints.""" + + transaction_hash: str = Field( + ..., + min_length=1, + description="Soroban transaction hash of the close/open contract call.", + ) + + class UserExtraDeposit(BaseModel): """ Data model representing extra deposit diff --git a/quantara/web_app/tests/test_outbox.py b/quantara/web_app/tests/test_outbox.py index ffab4e1df..4ef5f2e1b 100644 --- a/quantara/web_app/tests/test_outbox.py +++ b/quantara/web_app/tests/test_outbox.py @@ -7,7 +7,7 @@ from fastapi.testclient import TestClient from web_app.api.main import app -from web_app.db.models import OutboxEvent, Position, Transaction +from web_app.db.models import OutboxEvent, Position, Transaction, User from web_app.tasks.outbox_relay import OutboxRelay, process_position_opened_task @@ -15,28 +15,39 @@ async def test_open_position_queues_outbox_event(client: TestClient) -> None: position_id = str(uuid.uuid4()) transaction_hash = "test_tx_hash" - + owner_id = uuid.uuid4() + mock_position = MagicMock(spec=Position) mock_position.id = uuid.UUID(position_id) mock_position.status = "pending" - + mock_position.user_id = owner_id + + mock_user = MagicMock(spec=User) + mock_user.id = owner_id + saved_events = [] - + def mock_write(obj): if isinstance(obj, OutboxEvent): saved_events.append(obj) return obj - with patch("web_app.api.position.PositionDBConnector.get_object", return_value=mock_position) as mock_get, \ - patch("web_app.api.position.PositionDBConnector.write_to_db", side_effect=mock_write) as mock_write_db: - - response = client.get( - f"/api/open-position?position_id={position_id}&transaction_hash={transaction_hash}" + with patch( + "web_app.api.position.position_db_connector.get_position_by_id", + return_value=mock_position, + ), patch( + "web_app.api.position.position_db_connector.get_user_by_wallet_id", + return_value=mock_user, + ), patch( + "web_app.api.position.position_db_connector.write_to_db", side_effect=mock_write + ): + response = client.post( + f"/api/open-position/{position_id}", + json={"transaction_hash": transaction_hash}, ) assert response.status_code == 200 assert response.json() == "pending" - - mock_get.assert_called_once_with(Position, uuid.UUID(position_id)) + assert len(saved_events) == 1 assert saved_events[0].event_type == "PositionOpened" payload = json.loads(saved_events[0].payload) diff --git a/quantara/web_app/tests/test_positions.py b/quantara/web_app/tests/test_positions.py index 0dbef2b47..0ff00f37e 100644 --- a/quantara/web_app/tests/test_positions.py +++ b/quantara/web_app/tests/test_positions.py @@ -7,6 +7,7 @@ """ +import json import uuid from datetime import datetime from unittest.mock import Mock, patch @@ -17,188 +18,268 @@ from httpx import AsyncClient from web_app.api.main import app -from web_app.db.models import Position, TransactionStatus +from web_app.api.position import position_db_connector +from web_app.api.wallet_auth import verify_wallet_signature +from web_app.db.models import Position, TransactionStatus, User from web_app.tests.conftest import dict_to_object +def _unauthorized_auth() -> str: + """Simulate verify_wallet_signature rejecting a missing/invalid signature.""" + raise HTTPException(status_code=401, detail="Invalid or expired nonce") + + +def _owner_mocks(position_id: uuid.UUID, owner_user_id: uuid.UUID): + """Build a position/user pair whose ids satisfy require_position_owner.""" + position = Mock(spec=Position) + position.id = position_id + position.user_id = owner_user_id + user = Mock(spec=User) + user.id = owner_user_id + return position, user + + @pytest.mark.anyio -async def test_open_position_success(client: TestClient) -> None: - """ - Test for successfully opening a position by queuing an outbox event. - """ - position_id = str(uuid.uuid4()) - transaction_hash = "valid_transaction_hash" - - mock_position = Mock(spec=Position) - mock_position.id = uuid.UUID(position_id) - mock_position.status = "pending" +async def test_close_position_success(client: TestClient) -> None: + """Closing an owned position returns its new status and records the tx.""" + position_id = uuid.uuid4() + owner_id = uuid.uuid4() + position, user = _owner_mocks(position_id, owner_id) - with patch( - "web_app.api.position.PositionDBConnector.get_object", return_value=mock_position - ) as mock_get, patch( - "web_app.api.position.PositionDBConnector.write_to_db", return_value=None - ) as mock_write: - response = client.get( - f"/api/open-position?position_id={position_id}&transaction_hash={transaction_hash}" + with ( + patch.object( + position_db_connector, "get_position_by_id", return_value=position + ), + patch.object(position_db_connector, "get_user_by_wallet_id", return_value=user), + patch.object( + position_db_connector, "close_position", return_value="closed" + ) as mock_close, + patch.object( + position_db_connector, "save_transaction", return_value=None + ) as mock_save, + ): + response = client.post( + f"/api/close-position/{position_id}", + json={"transaction_hash": "0xabc123"}, ) - assert response.is_success - assert response.json() == "pending" + + assert response.status_code == 200 + assert response.json() == "closed" + mock_close.assert_called_once_with(position_id) + mock_save.assert_called_once_with( + position_id=position_id, status="closed", transaction_hash="0xabc123" + ) @pytest.mark.anyio -async def test_open_position_missing_position_data( - client: TestClient, -) -> None: - """ - Test for missing position data, which should return a 404 error. - Args: - client (TestClient): The test client for the FastAPI application. - Returns: - None - """ - response = client.get("/api/open-position?position_id=&transaction_hash=") - assert response.status_code == 404 - assert response.json() == {"detail": "Position not found"} +async def test_close_position_rejects_unauthenticated(client: TestClient) -> None: + """An unauthenticated request to close-position is rejected with 401.""" + app.dependency_overrides[verify_wallet_signature] = _unauthorized_auth + try: + response = client.post( + f"/api/close-position/{uuid.uuid4()}", + json={"transaction_hash": "0xabc123"}, + ) + finally: + app.dependency_overrides[verify_wallet_signature] = lambda: "test_wallet" + assert response.status_code == 401 @pytest.mark.anyio -async def test_close_position_success(client: TestClient) -> None: - """ - Test for successfully closing a position using a valid position ID. - Args: - client (TestClient): The test client for the FastAPI application. - Returns: - None - """ - position_id = str(uuid.uuid4()) - transaction_hash = "0xabc123" - with patch( - "web_app.db.crud.PositionDBConnector.close_position" - ) as mock_close_position: - mock_close_position.return_value = "Position successfully closed" +async def test_close_position_rejects_wrong_owner(client: TestClient) -> None: + """A wallet that does not own the position is rejected with 403.""" + position_id = uuid.uuid4() + position, _ = _owner_mocks(position_id, uuid.uuid4()) + other_user = Mock(spec=User) + other_user.id = uuid.uuid4() - response = client.get( - f"/api/close-position?position_id={position_id}&transaction_hash={transaction_hash}" + with ( + patch.object( + position_db_connector, "get_position_by_id", return_value=position + ), + patch.object( + position_db_connector, "get_user_by_wallet_id", return_value=other_user + ), + ): + response = client.post( + f"/api/close-position/{position_id}", + json={"transaction_hash": "0xabc123"}, ) - assert response.status_code == 200 - assert response.json() == "Position successfully closed" + assert response.status_code == 403 + assert response.json()["code"] == "not_position_owner" @pytest.mark.anyio -async def test_close_position_invalid_position_id(client: TestClient) -> None: - """ - Test for attempting to close a position using an invalid position ID, - which should return a 404 error. - Args: - client (TestClient): The test client for the FastAPI application. - Returns: - None - """ - invalid_position_id = str(uuid.uuid4()) - with patch( - "web_app.db.crud.PositionDBConnector.close_position" - ) as mock_close_position: - mock_close_position.side_effect = HTTPException( - status_code=404, detail="Position not Found" +async def test_open_position_success(client: TestClient) -> None: + """Opening an owned position queues a PositionOpened outbox event.""" + position_id = uuid.uuid4() + owner_id = uuid.uuid4() + position, user = _owner_mocks(position_id, owner_id) + saved_events = [] + + def _write(obj): + saved_events.append(obj) + return obj + + with ( + patch.object( + position_db_connector, "get_position_by_id", return_value=position + ), + patch.object(position_db_connector, "get_user_by_wallet_id", return_value=user), + patch.object(position_db_connector, "write_to_db", side_effect=_write), + ): + response = client.post( + f"/api/open-position/{position_id}", + json={"transaction_hash": "0xabc123"}, ) - response = client.get( - f"/api/close-position?position_id={invalid_position_id}&transaction_hash=0xabc123" + + assert response.status_code == 200 + assert response.json() == "pending" + assert len(saved_events) == 1 + assert saved_events[0].event_type == "PositionOpened" + payload = json.loads(saved_events[0].payload) + assert payload["position_id"] == str(position_id) + assert payload["transaction_hash"] == "0xabc123" + + +@pytest.mark.anyio +async def test_open_position_rejects_unauthenticated(client: TestClient) -> None: + """An unauthenticated request to open-position is rejected with 401.""" + app.dependency_overrides[verify_wallet_signature] = _unauthorized_auth + try: + response = client.post( + f"/api/open-position/{uuid.uuid4()}", + json={"transaction_hash": "0xabc123"}, ) - assert response.status_code == 404 - assert response.json() == {"detail": "Position not Found"} + finally: + app.dependency_overrides[verify_wallet_signature] = lambda: "test_wallet" + assert response.status_code == 401 @pytest.mark.anyio -async def test_get_repay_data_success( - client: TestClient, -) -> None: - """ - Test for successfully retrieving repayment data. - Args: - client (TestClient): The test client for the FastAPI application. - Returns: - None - """ - supply_token = "valid_supply_token" - wallet_id = "valid_wallet_id" +async def test_open_position_rejects_wrong_owner(client: TestClient) -> None: + """A wallet that does not own the position cannot queue its opening.""" + position_id = uuid.uuid4() + position, _ = _owner_mocks(position_id, uuid.uuid4()) + other_user = Mock(spec=User) + other_user.id = uuid.uuid4() + + with ( + patch.object( + position_db_connector, "get_position_by_id", return_value=position + ), + patch.object( + position_db_connector, "get_user_by_wallet_id", return_value=other_user + ), + ): + response = client.post( + f"/api/open-position/{position_id}", + json={"transaction_hash": "0xabc123"}, + ) + + assert response.status_code == 403 + assert response.json()["code"] == "not_position_owner" + + +@pytest.mark.anyio +async def test_get_repay_data_success(client: TestClient) -> None: + """Repay data is returned only for the authenticated wallet.""" + position_id = uuid.uuid4() mock_repay_data = { "supply_token": "mock_supply_token", "debt_token": "mock_debt_token", "borrow_portion_percent": 1, } + with ( + patch.object( + position_db_connector, + "get_repay_data", + return_value=("34702534789504389704385", position_id, "ETH"), + ), patch( - "web_app.contract_tools.mixins.deposit.DepositMixin.get_repay_data" - ) as mock_get_repay_data, - patch( - "web_app.db.crud.PositionDBConnector.get_contract_address_by_wallet_id" - ) as mock_get_contract_address, - patch( - "web_app.db.crud.PositionDBConnector.get_position_id_by_wallet_id" - ) as mock_get_position_wallet_id, - patch( - "web_app.api.position.position_db_connector.get_repay_data" - ) as mock_position_db_connector_get_repay_data, + "web_app.contract_tools.mixins.position.PositionMixin.is_opened_position", + return_value=True, + ), patch( - "web_app.contract_tools.mixins.position.PositionMixin.is_opened_position" - ) as mock_is_opened_position, + "web_app.contract_tools.mixins.deposit.DepositMixin.get_repay_data", + return_value=mock_repay_data, + ), ): - mock_get_repay_data.return_value = mock_repay_data - mock_get_contract_address.return_value = "34702534789504389704385" - mock_get_position_wallet_id.return_value = 123 - mock_get_repay_data.return_value = mock_repay_data - mock_position_db_connector_get_repay_data.return_value = ( - mock_get_contract_address.return_value, - mock_get_position_wallet_id.return_value, - supply_token, - ) - mock_is_opened_position.return_value = True - response = client.get( - f"/api/get-repay-data?supply_token={supply_token}&wallet_id={wallet_id}" - ) - expected_response = { - **mock_repay_data, - "contract_address": "34702534789504389704385", - "position_id": "123", - } - assert response.is_success - assert response.json() == expected_response + response = client.post("/api/get-repay-data") + + assert response.status_code == 200 + assert response.json() == { + **mock_repay_data, + "contract_address": "34702534789504389704385", + "position_id": str(position_id), + } @pytest.mark.anyio -async def test_get_repay_data_missing_wallet_id( - client: AsyncClient, -) -> None: - """ - Test for missing wallet ID when attempting to retrieve repayment data, - which should return a 404 error. - Args: - client (AsyncClient): The test client for the FastAPI application. - Returns: - None - """ - supply_token = "valid_supply_token" - wallet_id = "" +async def test_get_repay_data_rejects_unauthenticated(client: TestClient) -> None: + """Repay data cannot be fetched without a valid wallet signature.""" + app.dependency_overrides[verify_wallet_signature] = _unauthorized_auth + try: + response = client.post("/api/get-repay-data") + finally: + app.dependency_overrides[verify_wallet_signature] = lambda: "test_wallet" + assert response.status_code == 401 + + +@pytest.mark.anyio +async def test_get_withdraw_data_success(client: TestClient) -> None: + """Withdraw-all data is returned only for the authenticated wallet.""" + position_id = uuid.uuid4() + mock_repay_data = { + "supply_token": "mock_supply_token", + "debt_token": "mock_debt_token", + "borrow_portion_percent": 1, + } + with ( + patch.object( + position_db_connector, + "get_repay_data", + return_value=("34702534789504389704385", position_id, "USDC"), + ), patch( - "web_app.contract_tools.mixins.deposit.DepositMixin.get_repay_data" - ) as mock_get_repay_data, + "web_app.contract_tools.mixins.position.PositionMixin.is_opened_position", + return_value=True, + ), patch( - "web_app.db.crud.PositionDBConnector.get_contract_address_by_wallet_id" - ) as mock_get_contract_address, + "web_app.contract_tools.mixins.deposit.DepositMixin.get_repay_data", + return_value=mock_repay_data, + ), + patch.object( + position_db_connector, + "get_extra_deposits_data", + return_value={"ETH": "100"}, + ), patch( - "web_app.db.crud.PositionDBConnector.get_position_id_by_wallet_id" - ) as mock_get_position_id, + "web_app.contract_tools.constants.TokenParams.get_token_address", + return_value="0xETH_TOKEN", + ), ): - mock_get_repay_data.return_value = None - mock_get_contract_address.side_effect = None - mock_get_position_id.side_effect = None - response = client.get( - f"/api/get-repay-data?supply_token={supply_token}&wallet_id={wallet_id}" - ) - assert response.status_code == 404 - assert response.json() == {"detail": "Wallet not found"} + response = client.post("/api/get-withdraw-all-data") + + assert response.status_code == 200 + body = response.json() + assert body["repay_data"]["position_id"] == str(position_id) + assert body["repay_data"]["contract_address"] == "34702534789504389704385" + assert body["tokens"] == ["0xETH_TOKEN"] + + +@pytest.mark.anyio +async def test_get_withdraw_data_rejects_unauthenticated(client: TestClient) -> None: + """Withdraw-all data cannot be fetched without a valid wallet signature.""" + app.dependency_overrides[verify_wallet_signature] = _unauthorized_auth + try: + response = client.post("/api/get-withdraw-all-data") + finally: + app.dependency_overrides[verify_wallet_signature] = lambda: "test_wallet" + assert response.status_code == 401 @pytest.mark.parametrize( diff --git a/quantara/web_app/tests/test_vault.py b/quantara/web_app/tests/test_vault.py index ad989e5df..c03cb338c 100644 --- a/quantara/web_app/tests/test_vault.py +++ b/quantara/web_app/tests/test_vault.py @@ -9,21 +9,9 @@ import pytest from fastapi.testclient import TestClient -from httpx import ASGITransport, AsyncClient -from web_app.api.main import app from web_app.db.crud import UserDBConnector -client = TestClient(app) - - -@pytest.fixture -async def async_client(): - """Fixture that provides an async client for testing.""" - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://test") as ac: - yield ac - @pytest.mark.anyio @pytest.mark.parametrize( @@ -55,7 +43,7 @@ async def test_deposit_to_vault( expected_status, expected_response, mock_user_db_connector, - async_client, + client: TestClient, ): """Test vault deposit with different scenarios.""" mock_user = MagicMock() @@ -73,9 +61,9 @@ async def test_deposit_to_vault( "web_app.db.crud.DepositDBConnector.create_vault", return_value=mock_vault, ): - response = await async_client.post("/api/vault/deposit", json=test_data) + response = client.post("/api/vault/deposit", json=test_data) else: - response = await async_client.post("/api/vault/deposit", json=test_data) + response = client.post("/api/vault/deposit", json=test_data) assert response.status_code == expected_status expected = ( @@ -112,7 +100,7 @@ async def test_get_vault_balance( balance, expected_status, expected_response, - async_client, + client: TestClient, ): """Test vault balance retrieval with different scenarios.""" with patch( @@ -120,7 +108,7 @@ async def test_get_vault_balance( return_value=balance, ): url = f"/api/vault/balance?wallet_id={wallet_id}&symbol={symbol}" - response = await async_client.get(url) + response = client.get(url) assert response.status_code == expected_status expected = ( @@ -156,7 +144,7 @@ async def test_get_vault_balance( ], ) async def test_add_vault_balance( - test_data, expected_status, expected_response, async_client + test_data, expected_status, expected_response, client: TestClient ): """Test adding to vault balance with different scenarios.""" mock_vault = MagicMock() @@ -171,7 +159,7 @@ async def test_add_vault_balance( "web_app.db.crud.DepositDBConnector.add_vault_balance", **patch_kwargs, ): - response = await async_client.post("/api/vault/add_balance", json=test_data) + response = client.post("/api/vault/add_balance", json=test_data) assert response.status_code == expected_status expected = (