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 = (