From 50a01f5dfc98dbf2bf15ea60fd50dd955c959bfa Mon Sep 17 00:00:00 2001
From: Judah Praise <306440888+1Judah@users.noreply.github.com>
Date: Thu, 20 Aug 2026 13:55:35 +0000
Subject: [PATCH 1/2] fix(api): require wallet auth and ownership on position
lifecycle endpoints
Migrate close/open/repay-data/withdraw-all endpoints from unauthenticated
GET mutations to POST with verify_wallet_signature and a reusable
require_position_owner dependency, and update the frontend call sites.
---
.../frontend/src/hooks/useClosePosition.js | 24 +-
quantara/frontend/src/hooks/useWithdrawAll.js | 18 +-
quantara/frontend/src/services/transaction.js | 11 +-
.../test/hooks/useClosePosition.test.jsx | 83 ++++
.../test/hooks/useWithdrawAll.test.jsx | 80 ++++
.../test/services/transaction.test.js | 15 +-
quantara/web_app/api/errors.py | 13 +
quantara/web_app/api/position.py | 174 ++++++---
quantara/web_app/api/serializers/position.py | 12 +-
quantara/web_app/tests/test_outbox.py | 33 +-
quantara/web_app/tests/test_positions.py | 365 +++++++++++-------
11 files changed, 585 insertions(+), 243 deletions(-)
create mode 100644 quantara/frontend/test/hooks/useClosePosition.test.jsx
create mode 100644 quantara/frontend/test/hooks/useWithdrawAll.test.jsx
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(
From e9681ec3e77a36932e9f38b531315e01b2868213 Mon Sep 17 00:00:00 2001
From: Judah Praise <306440888+1Judah@users.noreply.github.com>
Date: Thu, 20 Aug 2026 18:05:21 +0000
Subject: [PATCH 2/2] fix(tests): drive vault tests through TestClient to fix
trio backend on CI
test_vault.py was the only test module driving the real app (with its
BaseHTTPMiddleware stack) through httpx's ASGITransport under the anyio
trio backend. On the GitHub Actions runner every trio variant fails with
"RuntimeError: must be called from async context" raised inside
BaseHTTPMiddleware's anyio task group. This is a pre-existing main-branch
breakage: every open PR fails the same six tests regardless of its diff.
Switch the module to the shared `client` (TestClient) fixture used by the
rest of the suite (test_positions, test_outbox, etc.). TestClient runs the
app on an asyncio portal thread, which sidesteps the trio task-group path
entirely and matches the pattern already proven green in CI. Assertions
and mock setup are unchanged; only the request transport differs.
Verified locally: 409/409 tests pass on the full web_app suite.
---
quantara/web_app/tests/test_vault.py | 26 +++++++-------------------
1 file changed, 7 insertions(+), 19 deletions(-)
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 = (