diff --git a/backend/package-lock.json b/backend/package-lock.json index fa1ef5b..78da120 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -2139,7 +2139,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -2954,7 +2953,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", @@ -3052,7 +3050,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3870,7 +3867,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -4005,7 +4001,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/backend/src/api/routes.ts b/backend/src/api/routes.ts index d3a8d99..80d4112 100644 --- a/backend/src/api/routes.ts +++ b/backend/src/api/routes.ts @@ -26,6 +26,7 @@ import { isValidStellarPublicKey, createPortfolioSchema, rebalancePortfolioSchema, + updatePortfolioSchema, } from "./validation.js"; import { validateRequest } from "../middleware/validate.js"; import { getPortfolioCheckQueue } from "../queue/queues.js"; @@ -173,6 +174,83 @@ router.get("/portfolio/:id", async (req, res) => { } }); +// Update a portfolio's allocations or threshold. Ownership check ensures +// only the user who created the portfolio can modify it. +router.put( + "/portfolio/:id", + portfolioWriteRateLimiter, + validateRequest(updatePortfolioSchema), + async (req, res) => { + try { + const { id } = req.params; + + const existing = portfolioStorage.getPortfolio(id); + if (!existing) { + return res.status(404).json({ error: "Portfolio not found" }); + } + + // Ownership check — the caller must be the portfolio owner + const callerAddress = req.headers["x-public-key"] as string | undefined; + if (!callerAddress) { + return res.status(401).json({ error: "X-Public-Key header is required" }); + } + if (existing.userAddress !== callerAddress) { + return res.status(403).json({ error: "Not authorized to modify this portfolio" }); + } + + const updates: Record = {}; + if (req.body.allocations !== undefined) updates.allocations = req.body.allocations; + if (req.body.threshold !== undefined) updates.threshold = req.body.threshold; + + portfolioStorage.updatePortfolio(id, updates); + + const updated = portfolioStorage.getPortfolio(id); + res.json({ success: true, portfolio: updated }); + } catch (error) { + console.error("[ERROR] Failed to update portfolio:", error); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }, +); + +// Delete a portfolio. Ownership check ensures only the creator can remove it. +// Returns 204 No Content on success, 404 if not found, 403 if not the owner. +router.delete( + "/portfolio/:id", + portfolioWriteRateLimiter, + async (req, res) => { + try { + const { id } = req.params; + + const existing = portfolioStorage.getPortfolio(id); + if (!existing) { + return res.status(404).json({ error: "Portfolio not found" }); + } + + // Ownership check + const callerAddress = req.headers["x-public-key"] as string | undefined; + if (!callerAddress) { + return res.status(401).json({ error: "X-Public-Key header is required" }); + } + if (existing.userAddress !== callerAddress) { + return res.status(403).json({ error: "Not authorized to delete this portfolio" }); + } + + portfolioStorage.deletePortfolio(id); + res.status(204).send(); + } catch (error) { + console.error("[ERROR] Failed to delete portfolio:", error); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }, +); + // Trigger a rebalance via stellarService.executeRebalance, which already // handles the risk checks, cooldown, circuit breakers and DEX execution. // Only slippageOverrides is wired through — simulateOnly and diff --git a/backend/src/api/validation.ts b/backend/src/api/validation.ts index 9a85c17..433df96 100644 --- a/backend/src/api/validation.ts +++ b/backend/src/api/validation.ts @@ -84,5 +84,22 @@ export const recordRebalanceEventSchema = z.object({ isSimulated: strictBoolean.optional() }).strict(); +// Schema for PUT /portfolio/:id — partial updates, at least one field required +export const updatePortfolioSchema = z.object({ + allocations: z.record(z.string(), z.number().min(0).max(100)).refine( + (allocations) => { + const total = Object.values(allocations).reduce((sum, val) => sum + val, 0); + return Math.abs(total - 100) <= 0.01; + }, + { + message: "Allocations must sum to 100%", + } + ).optional(), + threshold: z.number().min(1, "Threshold must be between 1% and 50%").max(50, "Threshold must be between 1% and 50%").optional(), +}).strict().refine( + (data) => data.allocations !== undefined || data.threshold !== undefined, + { message: "At least one of allocations or threshold must be provided" } +); + // Auto-Rebalancer control schemas (must be entirely empty payloads) export const autoRebalancerControlSchema = z.object({}).strict(); diff --git a/backend/src/test/api.integration.test.ts b/backend/src/test/api.integration.test.ts index 0701eda..f763171 100644 --- a/backend/src/test/api.integration.test.ts +++ b/backend/src/test/api.integration.test.ts @@ -348,6 +348,245 @@ describe('Portfolio Management - GET /api/user/:address/portfolios', () => { }) }) +// ─── Portfolio Update Tests ───────────────────────────────────────────────── + +describe('Portfolio Management - PUT /api/portfolio/:id', () => { + it('should update allocations with valid data', async () => { + const userAddress = 'GPUT123456789ABCDEF0' + const createPayload = { + userAddress, + allocations: { XLM: 60, USDC: 40 }, + threshold: 5 + } + + const createResponse = await request(app) + .post('/api/portfolio') + .send(createPayload) + .expect(201) + + const portfolioId = createResponse.body.portfolio.id + + const updateResponse = await request(app) + .put(`/api/portfolio/${portfolioId}`) + .set('X-Public-Key', userAddress) + .send({ allocations: { XLM: 70, USDC: 30 } }) + .expect(200) + + expect(updateResponse.body.success).toBe(true) + expect(updateResponse.body.portfolio.allocations).toEqual({ XLM: 70, USDC: 30 }) + }) + + it('should update threshold with valid data', async () => { + const userAddress = 'GPUT123456789ABCDEF1' + const createPayload = { + userAddress, + allocations: { XLM: 60, USDC: 40 }, + threshold: 5 + } + + const createResponse = await request(app) + .post('/api/portfolio') + .send(createPayload) + .expect(201) + + const portfolioId = createResponse.body.portfolio.id + + const updateResponse = await request(app) + .put(`/api/portfolio/${portfolioId}`) + .set('X-Public-Key', userAddress) + .send({ threshold: 10 }) + .expect(200) + + expect(updateResponse.body.success).toBe(true) + expect(updateResponse.body.portfolio.threshold).toBe(10) + }) + + it('should return 404 for nonexistent portfolio', async () => { + const response = await request(app) + .put('/api/portfolio/nonexistent-id-xyz') + .set('X-Public-Key', 'GTEST123456789ABCDEF0') + .send({ threshold: 10 }) + .expect(404) + + expect(response.body.error).toBe('Portfolio not found') + }) + + it('should return 400 for invalid allocations (not summing to 100%)', async () => { + const userAddress = 'GPUT123456789ABCDEF2' + const createPayload = { + userAddress, + allocations: { XLM: 60, USDC: 40 }, + threshold: 5 + } + + const createResponse = await request(app) + .post('/api/portfolio') + .send(createPayload) + .expect(201) + + const portfolioId = createResponse.body.portfolio.id + + const response = await request(app) + .put(`/api/portfolio/${portfolioId}`) + .set('X-Public-Key', userAddress) + .send({ allocations: { XLM: 60, USDC: 30 } }) + .expect(400) + + expect(response.body.error).toBe('Invalid request payload') + }) + + it('should return 400 for empty body', async () => { + const userAddress = 'GPUT123456789ABCDEF3' + const createPayload = { + userAddress, + allocations: { XLM: 60, USDC: 40 }, + threshold: 5 + } + + const createResponse = await request(app) + .post('/api/portfolio') + .send(createPayload) + .expect(201) + + const portfolioId = createResponse.body.portfolio.id + + const response = await request(app) + .put(`/api/portfolio/${portfolioId}`) + .set('X-Public-Key', userAddress) + .send({}) + .expect(400) + + expect(response.body.error).toBe('Invalid request payload') + }) + + it('should return 401 when X-Public-Key header is missing', async () => { + const createPayload = { + userAddress: 'GPUT123456789ABCDEF5', + allocations: { XLM: 60, USDC: 40 }, + threshold: 5 + } + + const createResponse = await request(app) + .post('/api/portfolio') + .send(createPayload) + .expect(201) + + const portfolioId = createResponse.body.portfolio.id + + const response = await request(app) + .put(`/api/portfolio/${portfolioId}`) + .send({ threshold: 10 }) + .expect(401) + + expect(response.body.error).toContain('X-Public-Key') + }) + + it('should return 403 when caller is not the portfolio owner', async () => { + const createPayload = { + userAddress: 'GPUT123456789ABCDEF4', + allocations: { XLM: 60, USDC: 40 }, + threshold: 5 + } + + const createResponse = await request(app) + .post('/api/portfolio') + .send(createPayload) + .expect(201) + + const portfolioId = createResponse.body.portfolio.id + + const response = await request(app) + .put(`/api/portfolio/${portfolioId}`) + .set('X-Public-Key', 'GDIFFERENT123456789ABCDEF') + .send({ threshold: 10 }) + .expect(403) + + expect(response.body.error).toContain('Not authorized') + }) +}) + +// ─── Portfolio Delete Tests ───────────────────────────────────────────────── + +describe('Portfolio Management - DELETE /api/portfolio/:id', () => { + it('should delete a portfolio and return 204', async () => { + const userAddress = 'GDEL123456789ABCDEF0' + const createPayload = { + userAddress, + allocations: { XLM: 60, USDC: 40 }, + threshold: 5 + } + + const createResponse = await request(app) + .post('/api/portfolio') + .send(createPayload) + .expect(201) + + const portfolioId = createResponse.body.portfolio.id + + await request(app) + .delete(`/api/portfolio/${portfolioId}`) + .set('X-Public-Key', userAddress) + .expect(204) + + // Verify it's gone + await request(app) + .get(`/api/portfolio/${portfolioId}`) + .expect(404) + }) + + it('should return 404 for nonexistent portfolio', async () => { + const response = await request(app) + .delete('/api/portfolio/nonexistent-id-xyz') + .set('X-Public-Key', 'GTEST123456789ABCDEF0') + .expect(404) + + expect(response.body.error).toBe('Portfolio not found') + }) + + it('should return 401 when X-Public-Key header is missing', async () => { + const createPayload = { + userAddress: 'GDEL123456789ABCDEF2', + allocations: { XLM: 60, USDC: 40 }, + threshold: 5 + } + + const createResponse = await request(app) + .post('/api/portfolio') + .send(createPayload) + .expect(201) + + const portfolioId = createResponse.body.portfolio.id + + const response = await request(app) + .delete(`/api/portfolio/${portfolioId}`) + .expect(401) + + expect(response.body.error).toContain('X-Public-Key') + }) + + it('should return 403 when caller is not the portfolio owner', async () => { + const createPayload = { + userAddress: 'GDEL123456789ABCDEF1', + allocations: { XLM: 60, USDC: 40 }, + threshold: 5 + } + + const createResponse = await request(app) + .post('/api/portfolio') + .send(createPayload) + .expect(201) + + const portfolioId = createResponse.body.portfolio.id + + const response = await request(app) + .delete(`/api/portfolio/${portfolioId}`) + .set('X-Public-Key', 'GDIFFERENT123456789ABCDEF') + .expect(403) + + expect(response.body.error).toContain('Not authorized') + }) +}) + // ─── Notification userId Validation Tests ──────────────────────────────────── describe('Notifications - userId must be a valid Stellar public key', () => { diff --git a/backend/src/test/validation.test.ts b/backend/src/test/validation.test.ts new file mode 100644 index 0000000..0c56bb4 --- /dev/null +++ b/backend/src/test/validation.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest' +import { createPortfolioSchema, updatePortfolioSchema } from '../api/validation.js' + +describe('updatePortfolioSchema', () => { + it('accepts valid allocations', () => { + const result = updatePortfolioSchema.safeParse({ + allocations: { XLM: 60, USDC: 40 } + }) + expect(result.success).toBe(true) + }) + + it('accepts valid threshold', () => { + const result = updatePortfolioSchema.safeParse({ threshold: 10 }) + expect(result.success).toBe(true) + }) + + it('accepts both allocations and threshold', () => { + const result = updatePortfolioSchema.safeParse({ + allocations: { XLM: 50, USDC: 50 }, + threshold: 5 + }) + expect(result.success).toBe(true) + }) + + it('rejects empty body (no fields provided)', () => { + const result = updatePortfolioSchema.safeParse({}) + expect(result.success).toBe(false) + }) + + it('rejects allocations not summing to 100%', () => { + const result = updatePortfolioSchema.safeParse({ + allocations: { XLM: 60, USDC: 30 } + }) + expect(result.success).toBe(false) + }) + + it('rejects threshold out of range', () => { + const result = updatePortfolioSchema.safeParse({ threshold: 0 }) + expect(result.success).toBe(false) + }) + + it('rejects unknown keys (strict mode)', () => { + const result = updatePortfolioSchema.safeParse({ + threshold: 5, + unknownField: 'hello' + }) + expect(result.success).toBe(false) + }) + + it('rejects allocation values over 100', () => { + const result = updatePortfolioSchema.safeParse({ + allocations: { XLM: 120, USDC: -20 } + }) + expect(result.success).toBe(false) + }) +}) + +describe('createPortfolioSchema', () => { + it('accepts valid input', () => { + const result = createPortfolioSchema.safeParse({ + userAddress: 'GTEST123456789ABCDEF0', + allocations: { XLM: 60, USDC: 40 }, + threshold: 5 + }) + expect(result.success).toBe(true) + }) + + it('rejects missing userAddress', () => { + const result = createPortfolioSchema.safeParse({ + allocations: { XLM: 60, USDC: 40 }, + threshold: 5 + }) + expect(result.success).toBe(false) + }) + + it('rejects missing allocations', () => { + const result = createPortfolioSchema.safeParse({ + userAddress: 'GTEST123456789ABCDEF0', + threshold: 5 + }) + expect(result.success).toBe(false) + }) + + it('rejects allocations not summing to 100%', () => { + const result = createPortfolioSchema.safeParse({ + userAddress: 'GTEST123456789ABCDEF0', + allocations: { XLM: 60, USDC: 30 }, + threshold: 5 + }) + expect(result.success).toBe(false) + }) +}) diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 2e61fd7..f5c70ae 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react' import { motion } from 'framer-motion' import { PieChart, Pie, Cell, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' -import { TrendingUp, AlertCircle, RefreshCw, ArrowLeft, ExternalLink, AlertTriangle } from 'lucide-react' +import { TrendingUp, AlertCircle, RefreshCw, ArrowLeft, ExternalLink, AlertTriangle, Pencil, Trash2, X } from 'lucide-react' import ThemeToggle from './ThemeToggle' import { useTheme } from '../context/ThemeContext' import AssetCard from './AssetCard' @@ -29,6 +29,12 @@ const Dashboard: React.FC = ({ onNavigate, publicKey }) => { const [priceSource, setPriceSource] = useState('loading...') const [pricesStale, setPricesStale] = useState(false) const [activeTab, setActiveTab] = useState<'overview' | 'analytics' | 'notifications' | 'test-notifications'>('overview') + const [showEditModal, setShowEditModal] = useState(false) + const [editAllocations, setEditAllocations] = useState>({}) + const [editThreshold, setEditThreshold] = useState(5) + const [editLoading, setEditLoading] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [deleteLoading, setDeleteLoading] = useState(false) const { isDark } = useTheme() useEffect(() => { @@ -178,6 +184,74 @@ const Dashboard: React.FC = ({ onNavigate, publicKey }) => { onNavigate('landing') } + const openEditModal = () => { + if (!portfolioData || portfolioData.id === 'demo') return + // Pre-fill with current allocations + const currentAllocations: Record = {} + if (Array.isArray(portfolioData.allocations)) { + portfolioData.allocations.forEach((alloc: any) => { + currentAllocations[alloc.asset] = alloc.target || alloc.percentage + }) + } else if (portfolioData.allocations) { + Object.assign(currentAllocations, portfolioData.allocations) + } + setEditAllocations(currentAllocations) + setEditThreshold(portfolioData.threshold || 5) + setShowEditModal(true) + } + + const handleEditSubmit = async () => { + if (!portfolioData?.id) return + setEditLoading(true) + try { + const response = await fetch(`${API_CONFIG.BASE_URL}/api/portfolio/${portfolioData.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-Public-Key': publicKey || '' + }, + body: JSON.stringify({ allocations: editAllocations, threshold: editThreshold }) + }) + if (response.ok) { + setShowEditModal(false) + fetchPortfolioData() + } else { + const err = await response.json() + alert(err.error || 'Failed to update portfolio') + } + } catch (error) { + console.error('Update failed:', error) + alert('Failed to update portfolio') + } finally { + setEditLoading(false) + } + } + + const handleDelete = async () => { + if (!portfolioData?.id) return + setDeleteLoading(true) + try { + const response = await fetch(`${API_CONFIG.BASE_URL}/api/portfolio/${portfolioData.id}`, { + method: 'DELETE', + headers: { + 'X-Public-Key': publicKey || '' + } + }) + if (response.ok || response.status === 204) { + setShowDeleteConfirm(false) + setPortfolioData(null) + onNavigate('setup') + } else { + alert('Failed to delete portfolio') + } + } catch (error) { + console.error('Delete failed:', error) + alert('Failed to delete portfolio') + } finally { + setDeleteLoading(false) + } + } + // Create allocation data from portfolio data const allocationData = portfolioData?.allocations?.map((alloc: any, index: number) => ({ name: alloc.asset, @@ -326,6 +400,24 @@ const Dashboard: React.FC = ({ onNavigate, publicKey }) => { > Create Portfolio + {portfolioData && portfolioData.id !== 'demo' && ( + <> + + + + )} + + +
+
+ + {Object.entries(editAllocations).map(([asset, value]) => ( +
+ {asset} + setEditAllocations(prev => ({ ...prev, [asset]: Number(e.target.value) }))} + className="flex-1 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> + % +
+ ))} +

s + v, 0) - 100) < 0.01 ? 'text-green-600' : 'text-red-500'}`}> + Total: {Object.values(editAllocations).reduce((s, v) => s + v, 0).toFixed(1)}% +

+
+ +
+ + setEditThreshold(Number(e.target.value))} + className="w-full border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> +
+
+ +
+ + +
+ + + )} + + {/* Delete Confirmation Dialog */} + {showDeleteConfirm && ( +
+
+

Delete Portfolio

+

+ Are you sure you want to delete this portfolio? This action cannot be undone. +

+
+ + +
+
+
+ )} ) }