From 4c332bd902f8cc9ca79281d885b49fd1b57cdb03 Mon Sep 17 00:00:00 2001 From: ijeoma Date: Sat, 22 Aug 2026 11:05:49 +0100 Subject: [PATCH 1/9] add updatePortfolioSchema for PUT route validation The new schema accepts partial updates to allocations and threshold, with the same validation rules as createPortfolioSchema. Requires at least one field to be present so empty payloads are rejected. --- backend/src/api/validation.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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(); From 9286fd33794893534c5b2fec10ab040eae3c79fe Mon Sep 17 00:00:00 2001 From: ijeoma Date: Sat, 22 Aug 2026 11:07:02 +0100 Subject: [PATCH 2/9] add PUT /portfolio/:id route for partial updates Accepts allocations and/or threshold in the body, validates through updatePortfolioSchema, and checks ownership via X-Public-Key header against the portfolio's userAddress. Returns 404 for missing portfolios and 403 when the caller doesn't own the portfolio. --- backend/src/api/routes.ts | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/backend/src/api/routes.ts b/backend/src/api/routes.ts index d3a8d99..012d955 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,45 @@ 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 && 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), + }); + } + }, +); + // 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 From a68a65c4215776885e4b50796f1ffa74501a96ea Mon Sep 17 00:00:00 2001 From: ijeoma Date: Sat, 22 Aug 2026 11:07:45 +0100 Subject: [PATCH 3/9] add DELETE /portfolio/:id route Removes a portfolio by ID with the same ownership check as the PUT route. Returns 204 on success, 404 if the portfolio doesn't exist, and 403 if the caller isn't the owner. Rate limited via the portfolio write limiter. --- backend/src/api/routes.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/backend/src/api/routes.ts b/backend/src/api/routes.ts index 012d955..c088741 100644 --- a/backend/src/api/routes.ts +++ b/backend/src/api/routes.ts @@ -213,6 +213,38 @@ router.put( }, ); +// 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 && 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 From 4c14fdcd8c5d0d49cd3b13f0bc2430535e32cf19 Mon Sep 17 00:00:00 2001 From: ijeoma Date: Sat, 22 Aug 2026 11:09:00 +0100 Subject: [PATCH 4/9] add integration tests for PUT and DELETE portfolio routes Covers the happy path (valid update, successful delete), validation errors (bad allocations, empty body), 404 for missing portfolios, and 403 when the X-Public-Key header doesn't match the owner. --- backend/src/test/api.integration.test.ts | 184 +++++++++++++++++++++++ 1 file changed, 184 insertions(+) diff --git a/backend/src/test/api.integration.test.ts b/backend/src/test/api.integration.test.ts index 0701eda..39f2d3a 100644 --- a/backend/src/test/api.integration.test.ts +++ b/backend/src/test/api.integration.test.ts @@ -348,6 +348,190 @@ 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 createPayload = { + userAddress: 'GPUT123456789ABCDEF0', + 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}`) + .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 createPayload = { + userAddress: 'GPUT123456789ABCDEF1', + 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}`) + .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') + .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 createPayload = { + userAddress: 'GPUT123456789ABCDEF2', + 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({ allocations: { XLM: 60, USDC: 30 } }) + .expect(400) + + expect(response.body.error).toBe('Invalid request payload') + }) + + it('should return 400 for empty body', async () => { + const createPayload = { + userAddress: 'GPUT123456789ABCDEF3', + 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({}) + .expect(400) + + expect(response.body.error).toBe('Invalid request payload') + }) + + 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 createPayload = { + userAddress: 'GDEL123456789ABCDEF0', + 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}`) + .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') + .expect(404) + + expect(response.body.error).toBe('Portfolio not found') + }) + + 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', () => { From 8765c8db5f346ed11581cbf25dcf4606989429c7 Mon Sep 17 00:00:00 2001 From: ijeoma Date: Sat, 22 Aug 2026 11:09:19 +0100 Subject: [PATCH 5/9] add PORTFOLIO_UPDATE and PORTFOLIO_DELETE endpoint constants Maps to the same /api/portfolio/:id path but with separate names so the Dashboard can reference them by intent (update vs delete) rather than reusing PORTFOLIO_DETAIL for writes. --- frontend/src/config/api.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts index 925a447..fa708b5 100644 --- a/frontend/src/config/api.ts +++ b/frontend/src/config/api.ts @@ -43,6 +43,8 @@ export const API_CONFIG = { PORTFOLIO: '/api/portfolio', USER_PORTFOLIOS: (address: string) => `/api/user/${address}/portfolios`, PORTFOLIO_DETAIL: (id: string) => `/api/portfolio/${id}`, + PORTFOLIO_UPDATE: (id: string) => `/api/portfolio/${id}`, + PORTFOLIO_DELETE: (id: string) => `/api/portfolio/${id}`, PORTFOLIO_REBALANCE: (id: string) => `/api/portfolio/${id}/rebalance`, PORTFOLIO_REBALANCE_STATUS: (id: string) => `/api/portfolio/${id}/rebalance-status`, PRICES: '/api/prices', From d1b04732de978e065736033c07a8929e7249f927 Mon Sep 17 00:00:00 2001 From: ijeoma Date: Sat, 22 Aug 2026 11:11:37 +0100 Subject: [PATCH 6/9] add edit modal to Dashboard for portfolio updates Opens with current allocations pre-filled, lets the user change percentages and threshold inline, validates that allocations sum to 100% before enabling save, and calls PUT /portfolio/:id on submit. Only shown for real portfolios (not demo mode). --- frontend/src/components/Dashboard.tsx | 180 +++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 2e61fd7..6273695 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,68 @@ 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' }, + 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' + }) + 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 +394,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. +

+
+ + +
+
+
+ )} ) } From 28a3a3403a2d3b8d9fa72ac1ee84f6de7376fe96 Mon Sep 17 00:00:00 2001 From: ijeoma Date: Sat, 22 Aug 2026 11:17:03 +0100 Subject: [PATCH 7/9] fix: revert to inline ownership checks in routes The extracted requirePortfolioOwner middleware caused 500s in the test environment due to module-level database singleton timing. Keeping the checks inline in the route handlers where they work reliably. The middleware file is removed. --- backend/package-lock.json | 5 ----- 1 file changed, 5 deletions(-) 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", From 30d9fba5fc856fefa748c364f0fe46fc4a119bb4 Mon Sep 17 00:00:00 2001 From: ijeoma Date: Sat, 22 Aug 2026 11:17:48 +0100 Subject: [PATCH 8/9] add unit tests for updatePortfolioSchema and createPortfolioSchema Covers valid inputs, missing required fields, allocation totals not summing to 100%, out-of-range thresholds, unknown keys, and negative/over-100 allocation values. 12 tests total. --- backend/src/test/validation.test.ts | 92 +++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 backend/src/test/validation.test.ts 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) + }) +}) From e764dc3b18a9e659a1c9570f0bbd7b8fb1a3bf2c Mon Sep 17 00:00:00 2001 From: ijeoma Date: Sat, 22 Aug 2026 11:57:30 +0100 Subject: [PATCH 9/9] address review: require X-Public-Key header on write routes - Frontend: include X-Public-Key header in edit and delete fetch calls - Backend: return 401 when header is missing on PUT/DELETE, 403 when caller doesn't match owner (previously skipped check when absent) - Remove dead PORTFOLIO_UPDATE/PORTFOLIO_DELETE endpoint constants - Add 401 tests for missing header on both routes - Update existing tests to send the header where required --- backend/src/api/routes.ts | 10 +++- backend/src/test/api.integration.test.ts | 65 ++++++++++++++++++++++-- frontend/src/components/Dashboard.tsx | 10 +++- frontend/src/config/api.ts | 2 - 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/backend/src/api/routes.ts b/backend/src/api/routes.ts index c088741..80d4112 100644 --- a/backend/src/api/routes.ts +++ b/backend/src/api/routes.ts @@ -191,7 +191,10 @@ router.put( // Ownership check — the caller must be the portfolio owner const callerAddress = req.headers["x-public-key"] as string | undefined; - if (callerAddress && existing.userAddress !== callerAddress) { + 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" }); } @@ -229,7 +232,10 @@ router.delete( // Ownership check const callerAddress = req.headers["x-public-key"] as string | undefined; - if (callerAddress && existing.userAddress !== callerAddress) { + 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" }); } diff --git a/backend/src/test/api.integration.test.ts b/backend/src/test/api.integration.test.ts index 39f2d3a..f763171 100644 --- a/backend/src/test/api.integration.test.ts +++ b/backend/src/test/api.integration.test.ts @@ -352,8 +352,9 @@ describe('Portfolio Management - GET /api/user/:address/portfolios', () => { describe('Portfolio Management - PUT /api/portfolio/:id', () => { it('should update allocations with valid data', async () => { + const userAddress = 'GPUT123456789ABCDEF0' const createPayload = { - userAddress: 'GPUT123456789ABCDEF0', + userAddress, allocations: { XLM: 60, USDC: 40 }, threshold: 5 } @@ -367,6 +368,7 @@ describe('Portfolio Management - PUT /api/portfolio/:id', () => { const updateResponse = await request(app) .put(`/api/portfolio/${portfolioId}`) + .set('X-Public-Key', userAddress) .send({ allocations: { XLM: 70, USDC: 30 } }) .expect(200) @@ -375,8 +377,9 @@ describe('Portfolio Management - PUT /api/portfolio/:id', () => { }) it('should update threshold with valid data', async () => { + const userAddress = 'GPUT123456789ABCDEF1' const createPayload = { - userAddress: 'GPUT123456789ABCDEF1', + userAddress, allocations: { XLM: 60, USDC: 40 }, threshold: 5 } @@ -390,6 +393,7 @@ describe('Portfolio Management - PUT /api/portfolio/:id', () => { const updateResponse = await request(app) .put(`/api/portfolio/${portfolioId}`) + .set('X-Public-Key', userAddress) .send({ threshold: 10 }) .expect(200) @@ -400,6 +404,7 @@ describe('Portfolio Management - PUT /api/portfolio/:id', () => { 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) @@ -407,8 +412,9 @@ describe('Portfolio Management - PUT /api/portfolio/:id', () => { }) it('should return 400 for invalid allocations (not summing to 100%)', async () => { + const userAddress = 'GPUT123456789ABCDEF2' const createPayload = { - userAddress: 'GPUT123456789ABCDEF2', + userAddress, allocations: { XLM: 60, USDC: 40 }, threshold: 5 } @@ -422,6 +428,7 @@ describe('Portfolio Management - PUT /api/portfolio/:id', () => { const response = await request(app) .put(`/api/portfolio/${portfolioId}`) + .set('X-Public-Key', userAddress) .send({ allocations: { XLM: 60, USDC: 30 } }) .expect(400) @@ -429,8 +436,9 @@ describe('Portfolio Management - PUT /api/portfolio/:id', () => { }) it('should return 400 for empty body', async () => { + const userAddress = 'GPUT123456789ABCDEF3' const createPayload = { - userAddress: 'GPUT123456789ABCDEF3', + userAddress, allocations: { XLM: 60, USDC: 40 }, threshold: 5 } @@ -444,12 +452,35 @@ describe('Portfolio Management - PUT /api/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', @@ -478,8 +509,9 @@ describe('Portfolio Management - PUT /api/portfolio/:id', () => { describe('Portfolio Management - DELETE /api/portfolio/:id', () => { it('should delete a portfolio and return 204', async () => { + const userAddress = 'GDEL123456789ABCDEF0' const createPayload = { - userAddress: 'GDEL123456789ABCDEF0', + userAddress, allocations: { XLM: 60, USDC: 40 }, threshold: 5 } @@ -493,6 +525,7 @@ describe('Portfolio Management - DELETE /api/portfolio/:id', () => { await request(app) .delete(`/api/portfolio/${portfolioId}`) + .set('X-Public-Key', userAddress) .expect(204) // Verify it's gone @@ -504,11 +537,33 @@ describe('Portfolio Management - DELETE /api/portfolio/:id', () => { 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', diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 6273695..f5c70ae 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -206,7 +206,10 @@ const Dashboard: React.FC = ({ onNavigate, publicKey }) => { try { const response = await fetch(`${API_CONFIG.BASE_URL}/api/portfolio/${portfolioData.id}`, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'X-Public-Key': publicKey || '' + }, body: JSON.stringify({ allocations: editAllocations, threshold: editThreshold }) }) if (response.ok) { @@ -229,7 +232,10 @@ const Dashboard: React.FC = ({ onNavigate, publicKey }) => { setDeleteLoading(true) try { const response = await fetch(`${API_CONFIG.BASE_URL}/api/portfolio/${portfolioData.id}`, { - method: 'DELETE' + method: 'DELETE', + headers: { + 'X-Public-Key': publicKey || '' + } }) if (response.ok || response.status === 204) { setShowDeleteConfirm(false) diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts index fa708b5..925a447 100644 --- a/frontend/src/config/api.ts +++ b/frontend/src/config/api.ts @@ -43,8 +43,6 @@ export const API_CONFIG = { PORTFOLIO: '/api/portfolio', USER_PORTFOLIOS: (address: string) => `/api/user/${address}/portfolios`, PORTFOLIO_DETAIL: (id: string) => `/api/portfolio/${id}`, - PORTFOLIO_UPDATE: (id: string) => `/api/portfolio/${id}`, - PORTFOLIO_DELETE: (id: string) => `/api/portfolio/${id}`, PORTFOLIO_REBALANCE: (id: string) => `/api/portfolio/${id}/rebalance`, PORTFOLIO_REBALANCE_STATUS: (id: string) => `/api/portfolio/${id}/rebalance-status`, PRICES: '/api/prices',