From 1f19f4cfcc8bbdbb5113ed11b42bf8c8c42b76d5 Mon Sep 17 00:00:00 2001 From: benalex8797 Date: Mon, 27 Jul 2026 01:49:26 +0100 Subject: [PATCH] fixed issues --- server/package-lock.json | 37 -- server/src/__tests__/exportValidation.test.ts | 614 ++++++++++++++++++ server/src/routes/export.ts | 60 +- .../services/portfolio/exportValidation.ts | 273 ++++++++ 4 files changed, 944 insertions(+), 40 deletions(-) create mode 100644 server/src/__tests__/exportValidation.test.ts create mode 100644 server/src/services/portfolio/exportValidation.ts diff --git a/server/package-lock.json b/server/package-lock.json index f3f9b2266..869ea7a3d 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -594,43 +594,6 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@envelop/core": { "version": "5.5.1", "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.5.1.tgz", diff --git a/server/src/__tests__/exportValidation.test.ts b/server/src/__tests__/exportValidation.test.ts new file mode 100644 index 000000000..17feb84e6 --- /dev/null +++ b/server/src/__tests__/exportValidation.test.ts @@ -0,0 +1,614 @@ +/** + * Tests for portfolio export parameter validation (#1051) + * + * Covers: + * - date window validation (reversed, oversized, future, missing partner) + * - asset filter validation (unknown symbols, malformed strings) + * - format validation + * - all-clear (valid combinations) + * - route-level rejection via supertest + */ + +import request from "supertest"; +import express from "express"; +import { + validateExportParams, + EXPORT_MAX_WINDOW_DAYS, + EXPORT_SUPPORTED_ASSETS, +} from "../services/portfolio/exportValidation"; + +// ── Unit tests: validateExportParams ──────────────────────────────────────── + +describe("validateExportParams — unit", () => { + // ── Happy paths ──────────────────────────────────────────────────────────── + + it("returns valid with no params provided", () => { + const result = validateExportParams({}); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.parsed?.startDate).toBeUndefined(); + expect(result.parsed?.endDate).toBeUndefined(); + expect(result.parsed?.assets).toBeUndefined(); + expect(result.parsed?.format).toBe("csv"); + }); + + it("accepts a valid date window within the limit", () => { + const start = new Date(); + start.setDate(start.getDate() - 30); + const end = new Date(); + end.setDate(end.getDate() - 1); + + const result = validateExportParams({ + startDate: start.toISOString(), + endDate: end.toISOString(), + }); + + expect(result.valid).toBe(true); + expect(result.parsed?.startDate).toEqual(new Date(start.toISOString())); + expect(result.parsed?.endDate).toEqual(new Date(end.toISOString())); + }); + + it("accepts a valid single-asset filter", () => { + const result = validateExportParams({ assets: "USDC" }); + expect(result.valid).toBe(true); + expect(result.parsed?.assets).toEqual(["USDC"]); + }); + + it("accepts multiple valid assets and deduplicates them", () => { + const result = validateExportParams({ assets: "USDC,XLM,USDC" }); + expect(result.valid).toBe(true); + expect(result.parsed?.assets).toEqual(["USDC", "XLM"]); + }); + + it("normalises asset symbols to uppercase", () => { + const result = validateExportParams({ assets: "usdc,xlm" }); + expect(result.valid).toBe(true); + expect(result.parsed?.assets).toEqual(["USDC", "XLM"]); + }); + + it('accepts format "json"', () => { + const result = validateExportParams({ format: "json" }); + expect(result.valid).toBe(true); + expect(result.parsed?.format).toBe("json"); + }); + + it('accepts format "csv"', () => { + const result = validateExportParams({ format: "csv" }); + expect(result.valid).toBe(true); + expect(result.parsed?.format).toBe("csv"); + }); + + it("accepts format in any case (JSON, Csv)", () => { + expect(validateExportParams({ format: "JSON" }).parsed?.format).toBe("json"); + expect(validateExportParams({ format: "Csv" }).parsed?.format).toBe("csv"); + }); + + it("accepts a date window exactly at the maximum allowed size", () => { + const start = new Date(); + start.setDate(start.getDate() - EXPORT_MAX_WINDOW_DAYS); + const end = new Date(); + end.setDate(end.getDate() - 1); + + const result = validateExportParams({ + startDate: start.toISOString(), + endDate: end.toISOString(), + }); + + expect(result.valid).toBe(true); + }); + + // ── Date: missing partner ────────────────────────────────────────────────── + + it("rejects startDate without endDate", () => { + const start = new Date(); + start.setDate(start.getDate() - 5); + + const result = validateExportParams({ startDate: start.toISOString() }); + + expect(result.valid).toBe(false); + expect(result.errors[0].code).toBe("MISSING_END_DATE"); + }); + + it("rejects endDate without startDate", () => { + const end = new Date(); + end.setDate(end.getDate() - 1); + + const result = validateExportParams({ endDate: end.toISOString() }); + + expect(result.valid).toBe(false); + expect(result.errors[0].code).toBe("MISSING_START_DATE"); + }); + + // ── Date: invalid formats ────────────────────────────────────────────────── + + it("rejects a non-date string as startDate", () => { + const end = new Date(); + end.setDate(end.getDate() - 1); + + const result = validateExportParams({ + startDate: "not-a-date", + endDate: end.toISOString(), + }); + + expect(result.valid).toBe(false); + const codes = result.errors.map((e) => e.code); + expect(codes).toContain("INVALID_START_DATE"); + }); + + it("rejects a non-date string as endDate", () => { + const start = new Date(); + start.setDate(start.getDate() - 10); + + const result = validateExportParams({ + startDate: start.toISOString(), + endDate: "31-13-2025", // invalid month + }); + + expect(result.valid).toBe(false); + const codes = result.errors.map((e) => e.code); + expect(codes).toContain("INVALID_END_DATE"); + }); + + // ── Date: reversed range ─────────────────────────────────────────────────── + + it("rejects a reversed date window (startDate > endDate)", () => { + const start = new Date(); + start.setDate(start.getDate() - 1); + const end = new Date(); + end.setDate(end.getDate() - 10); + + const result = validateExportParams({ + startDate: start.toISOString(), + endDate: end.toISOString(), + }); + + expect(result.valid).toBe(false); + expect(result.errors[0].code).toBe("DATE_WINDOW_REVERSED"); + }); + + it("rejects equal startDate and endDate (zero-length range)", () => { + const same = new Date(); + same.setDate(same.getDate() - 5); + const iso = same.toISOString(); + + const result = validateExportParams({ startDate: iso, endDate: iso }); + + expect(result.valid).toBe(false); + expect(result.errors[0].code).toBe("DATE_WINDOW_REVERSED"); + }); + + // ── Date: future ─────────────────────────────────────────────────────────── + + it("rejects a startDate in the future", () => { + const future = new Date(); + future.setDate(future.getDate() + 10); + const end = new Date(); + end.setDate(end.getDate() + 20); + + const result = validateExportParams({ + startDate: future.toISOString(), + endDate: end.toISOString(), + }); + + expect(result.valid).toBe(false); + const codes = result.errors.map((e) => e.code); + expect(codes).toContain("DATE_IN_FUTURE"); + }); + + it("rejects an endDate in the future", () => { + const start = new Date(); + start.setDate(start.getDate() - 10); + const future = new Date(); + future.setDate(future.getDate() + 5); + + const result = validateExportParams({ + startDate: start.toISOString(), + endDate: future.toISOString(), + }); + + expect(result.valid).toBe(false); + const codes = result.errors.map((e) => e.code); + expect(codes).toContain("DATE_IN_FUTURE"); + }); + + // ── Date: overly large window ────────────────────────────────────────────── + + it("rejects a date window larger than the maximum", () => { + const start = new Date(); + start.setFullYear(start.getFullYear() - 3); // 3 years back, well over 366 days + const end = new Date(); + end.setDate(end.getDate() - 1); + + const result = validateExportParams({ + startDate: start.toISOString(), + endDate: end.toISOString(), + }); + + expect(result.valid).toBe(false); + expect(result.errors[0].code).toBe("DATE_WINDOW_TOO_LARGE"); + expect(result.errors[0].details?.maxWindowDays).toBe(EXPORT_MAX_WINDOW_DAYS); + }); + + it("includes the window size and max in the error details", () => { + const start = new Date("2020-01-01T00:00:00Z"); + const end = new Date("2022-01-01T00:00:00Z"); + + const result = validateExportParams({ + startDate: start.toISOString(), + endDate: end.toISOString(), + }); + + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.code === "DATE_WINDOW_TOO_LARGE"); + expect(err).toBeDefined(); + expect(typeof err?.details?.windowDays).toBe("number"); + expect((err?.details?.windowDays as number)).toBeGreaterThan(EXPORT_MAX_WINDOW_DAYS); + }); + + // ── Asset filters ────────────────────────────────────────────────────────── + + it("rejects an unknown asset symbol", () => { + const result = validateExportParams({ assets: "FAKE" }); + + expect(result.valid).toBe(false); + expect(result.errors[0].code).toBe("UNSUPPORTED_ASSET"); + expect(result.errors[0].details?.unsupported).toContain("FAKE"); + }); + + it("rejects a mix of valid and unknown asset symbols", () => { + const result = validateExportParams({ assets: "USDC,UNKNOWN,XLM" }); + + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.code === "UNSUPPORTED_ASSET"); + expect(err?.details?.unsupported).toEqual(["UNKNOWN"]); + }); + + it("includes the supported asset list in the error details", () => { + const result = validateExportParams({ assets: "DOGECOIN" }); + + expect(result.valid).toBe(false); + const err = result.errors[0]; + expect(Array.isArray(err.details?.supported)).toBe(true); + for (const sym of EXPORT_SUPPORTED_ASSETS) { + expect((err.details?.supported as string[]).includes(sym)).toBe(true); + } + }); + + it("rejects a malformed assets param with an empty segment", () => { + const result = validateExportParams({ assets: "USDC,,XLM" }); + + expect(result.valid).toBe(false); + expect(result.errors[0].code).toBe("INVALID_ASSETS_PARAM"); + }); + + it("rejects assets param with a leading comma", () => { + const result = validateExportParams({ assets: ",USDC" }); + + expect(result.valid).toBe(false); + expect(result.errors[0].code).toBe("INVALID_ASSETS_PARAM"); + }); + + it("rejects assets param with a trailing comma", () => { + const result = validateExportParams({ assets: "USDC," }); + + expect(result.valid).toBe(false); + expect(result.errors[0].code).toBe("INVALID_ASSETS_PARAM"); + }); + + // ── Format ───────────────────────────────────────────────────────────────── + + it("rejects an unsupported format value", () => { + const result = validateExportParams({ format: "xml" }); + + expect(result.valid).toBe(false); + expect(result.errors[0].code).toBe("INVALID_FORMAT"); + }); + + it("includes the unsupported format in the error details", () => { + const result = validateExportParams({ format: "pdf" }); + expect(result.errors[0].details?.provided).toBe("pdf"); + }); + + // ── Multiple simultaneous errors ─────────────────────────────────────────── + + it("accumulates multiple independent errors in one response", () => { + const result = validateExportParams({ + assets: "FAKE", + format: "xml", + }); + + expect(result.valid).toBe(false); + const codes = result.errors.map((e) => e.code); + expect(codes).toContain("UNSUPPORTED_ASSET"); + expect(codes).toContain("INVALID_FORMAT"); + }); + + it("accumulates date and asset errors together", () => { + const start = new Date(); + start.setDate(start.getDate() - 1); + const end = new Date(); + end.setDate(end.getDate() - 10); // reversed + + const result = validateExportParams({ + startDate: start.toISOString(), + endDate: end.toISOString(), + assets: "NOTREAL", + }); + + expect(result.valid).toBe(false); + const codes = result.errors.map((e) => e.code); + expect(codes).toContain("DATE_WINDOW_REVERSED"); + expect(codes).toContain("UNSUPPORTED_ASSET"); + }); + + // ── Error shapes are stable ──────────────────────────────────────────────── + + it("every error has a non-empty code and message", () => { + const result = validateExportParams({ + startDate: "bad", + endDate: "alsoBad", + assets: "FAKE", + format: "xml", + }); + + expect(result.valid).toBe(false); + for (const err of result.errors) { + expect(typeof err.code).toBe("string"); + expect(err.code.length).toBeGreaterThan(0); + expect(typeof err.message).toBe("string"); + expect(err.message.length).toBeGreaterThan(0); + } + }); +}); + +// ── Route-level integration tests ──────────────────────────────────────────── + +/** + * We build a minimal express app that wires up validateWalletAddress and + * validateExportQuery directly — without the rate-limiter — so the + * middleware chain can be exercised end-to-end without a real DB or + * triggering 429s during repeated test runs. + */ +import { validateWalletAddress } from "../middleware/validation"; +import { + validateExportParams as _vep, + type ExportValidationResult as _EVR, +} from "../services/portfolio/exportValidation"; +import { sendError as _sendError } from "../utils/errorResponse"; +import { Router, NextFunction } from "express"; + +// A 56-character Stellar G-address that passes the /^[GC][A-Z2-7]{55}$/ check. +const VALID_ADDRESS = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +function makeValidateExportQuery() { + return function validateExportQuery( + req: Request, + res: Response, + next: NextFunction, + ): void { + const result: _EVR = _vep({ + startDate: req.query.startDate as string | undefined, + endDate: req.query.endDate as string | undefined, + assets: req.query.assets as string | undefined, + format: req.query.format as string | undefined, + }); + if (!result.valid) { + _sendError(res, 400, "INVALID_EXPORT_PARAMS", "One or more export parameters are invalid.", result.errors); + return; + } + res.locals.exportParams = result.parsed; + next(); + }; +} + +const testRouter = Router(); +testRouter.get("/:address/export/preview", validateWalletAddress, makeValidateExportQuery(), (_req, res) => { + res.status(503).json({ error: "DB_UNAVAILABLE", message: "stub" }); +}); +testRouter.get("/:address/export", validateWalletAddress, makeValidateExportQuery(), (_req, res) => { + res.status(503).json({ error: "DB_UNAVAILABLE", message: "stub" }); +}); + +const app = express(); +app.use(express.json()); +app.use("/api/users", testRouter); + +describe("Export route — validation middleware integration", () => { + // ── Invalid wallet address ───────────────────────────────────────────────── + + it("rejects a short / invalid wallet address with 400", async () => { + const res = await request(app).get("/api/users/BADADDR/export/preview"); + expect(res.status).toBe(400); + expect(res.body.error).toBe("INVALID_ADDRESS"); + }); + + // ── Reversed date range ──────────────────────────────────────────────────── + + it("rejects a reversed date window on /export/preview with 400", async () => { + const start = new Date(); + start.setDate(start.getDate() - 1); + const end = new Date(); + end.setDate(end.getDate() - 10); + + const res = await request(app) + .get(`/api/users/${VALID_ADDRESS}/export/preview`) + .query({ + startDate: start.toISOString(), + endDate: end.toISOString(), + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("INVALID_EXPORT_PARAMS"); + expect(res.body.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "DATE_WINDOW_REVERSED" }), + ]), + ); + }); + + it("rejects a reversed date window on /export with 400", async () => { + const start = new Date(); + start.setDate(start.getDate() - 1); + const end = new Date(); + end.setDate(end.getDate() - 10); + + const res = await request(app) + .get(`/api/users/${VALID_ADDRESS}/export`) + .query({ + startDate: start.toISOString(), + endDate: end.toISOString(), + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("INVALID_EXPORT_PARAMS"); + }); + + // ── Overly large range ───────────────────────────────────────────────────── + + it("rejects a date range over the max window on /export/preview", async () => { + const start = new Date("2019-01-01T00:00:00Z"); + const end = new Date("2022-01-01T00:00:00Z"); + + const res = await request(app) + .get(`/api/users/${VALID_ADDRESS}/export/preview`) + .query({ startDate: start.toISOString(), endDate: end.toISOString() }); + + expect(res.status).toBe(400); + const codes = (res.body.details as Array<{ code: string }>).map((e) => e.code); + expect(codes).toContain("DATE_WINDOW_TOO_LARGE"); + }); + + // ── Missing partner date ─────────────────────────────────────────────────── + + it("rejects startDate without endDate on /export/preview", async () => { + const start = new Date(); + start.setDate(start.getDate() - 5); + + const res = await request(app) + .get(`/api/users/${VALID_ADDRESS}/export/preview`) + .query({ startDate: start.toISOString() }); + + expect(res.status).toBe(400); + const codes = (res.body.details as Array<{ code: string }>).map((e) => e.code); + expect(codes).toContain("MISSING_END_DATE"); + }); + + it("rejects endDate without startDate on /export", async () => { + const end = new Date(); + end.setDate(end.getDate() - 1); + + const res = await request(app) + .get(`/api/users/${VALID_ADDRESS}/export`) + .query({ endDate: end.toISOString() }); + + expect(res.status).toBe(400); + const codes = (res.body.details as Array<{ code: string }>).map((e) => e.code); + expect(codes).toContain("MISSING_START_DATE"); + }); + + // ── Unknown asset filters ────────────────────────────────────────────────── + + it("rejects an unknown asset filter on /export/preview", async () => { + const res = await request(app) + .get(`/api/users/${VALID_ADDRESS}/export/preview`) + .query({ assets: "DOGECOINER" }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("INVALID_EXPORT_PARAMS"); + const codes = (res.body.details as Array<{ code: string }>).map((e) => e.code); + expect(codes).toContain("UNSUPPORTED_ASSET"); + }); + + it("rejects an unknown asset filter on /export", async () => { + const res = await request(app) + .get(`/api/users/${VALID_ADDRESS}/export`) + .query({ assets: "DOGECOINER" }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("INVALID_EXPORT_PARAMS"); + }); + + it("rejects a malformed assets param on /export/preview", async () => { + const res = await request(app) + .get(`/api/users/${VALID_ADDRESS}/export/preview`) + .query({ assets: "USDC,,XLM" }); + + expect(res.status).toBe(400); + const codes = (res.body.details as Array<{ code: string }>).map((e) => e.code); + expect(codes).toContain("INVALID_ASSETS_PARAM"); + }); + + // ── Invalid format ───────────────────────────────────────────────────────── + + it("rejects an unsupported format on /export/preview", async () => { + const res = await request(app) + .get(`/api/users/${VALID_ADDRESS}/export/preview`) + .query({ format: "xlsx" }); + + expect(res.status).toBe(400); + const codes = (res.body.details as Array<{ code: string }>).map((e) => e.code); + expect(codes).toContain("INVALID_FORMAT"); + }); + + // ── Multiple errors in one response ─────────────────────────────────────── + + it("returns all errors together when multiple params are invalid", async () => { + const start = new Date(); + start.setDate(start.getDate() - 1); + const end = new Date(); + end.setDate(end.getDate() - 10); + + const res = await request(app) + .get(`/api/users/${VALID_ADDRESS}/export/preview`) + .query({ + startDate: start.toISOString(), + endDate: end.toISOString(), + assets: "NOTREAL", + format: "xml", + }); + + expect(res.status).toBe(400); + const codes = (res.body.details as Array<{ code: string }>).map((e) => e.code); + expect(codes).toContain("DATE_WINDOW_REVERSED"); + expect(codes).toContain("UNSUPPORTED_ASSET"); + expect(codes).toContain("INVALID_FORMAT"); + expect(codes.length).toBeGreaterThanOrEqual(3); + }); + + // ── Valid params pass through ────────────────────────────────────────────── + + it("passes through valid params and does not return 400 for param issues", async () => { + const start = new Date(); + start.setDate(start.getDate() - 30); + const end = new Date(); + end.setDate(end.getDate() - 1); + + const res = await request(app) + .get(`/api/users/${VALID_ADDRESS}/export/preview`) + .query({ + startDate: start.toISOString(), + endDate: end.toISOString(), + assets: "USDC,XLM", + format: "json", + }); + + // The stub handler returns 503 — but NOT a 400 from param validation. + expect(res.status).not.toBe(400); + }); + + // ── Error response structure is stable ──────────────────────────────────── + + it("error response always includes error code, message, and details array", async () => { + const res = await request(app) + .get(`/api/users/${VALID_ADDRESS}/export/preview`) + .query({ assets: "FAKE" }); + + expect(res.status).toBe(400); + expect(typeof res.body.error).toBe("string"); + expect(typeof res.body.message).toBe("string"); + expect(Array.isArray(res.body.details)).toBe(true); + for (const item of res.body.details as unknown[]) { + expect(item).toMatchObject({ code: expect.any(String), message: expect.any(String) }); + } + }); +}); diff --git a/server/src/routes/export.ts b/server/src/routes/export.ts index 3146e1ed1..a4b33d06b 100644 --- a/server/src/routes/export.ts +++ b/server/src/routes/export.ts @@ -1,4 +1,4 @@ -import { Router, Request, Response } from "express"; +import { Router, Request, Response, NextFunction } from "express"; import rateLimit from "express-rate-limit"; import { buildTaxLotPreview, @@ -9,6 +9,10 @@ import { } from "../services/export"; import { sendError } from "../utils/errorResponse"; import { validateWalletAddress } from "../middleware/validation"; +import { + validateExportParams, + type ExportValidationResult, +} from "../services/portfolio/exportValidation"; type ExportPrismaClient = { userTransaction: { @@ -55,6 +59,40 @@ const exportLimiter = rateLimit({ message: "Too many export requests. Please try again later.", }); +/** + * Middleware: validate export query parameters (date windows, asset filters, + * and output format) before the handler runs any DB work. + * + * On success the parsed, normalised values are attached to `res.locals.exportParams` + * so handlers can read them without re-parsing. + */ +function validateExportQuery( + req: Request, + res: Response, + next: NextFunction, +): void { + const result: ExportValidationResult = validateExportParams({ + startDate: req.query.startDate as string | undefined, + endDate: req.query.endDate as string | undefined, + assets: req.query.assets as string | undefined, + format: req.query.format as string | undefined, + }); + + if (!result.valid) { + sendError( + res, + 400, + "INVALID_EXPORT_PARAMS", + "One or more export parameters are invalid.", + result.errors, + ); + return; + } + + res.locals.exportParams = result.parsed; + next(); +} + async function fetchRawTransactions( address: string, ): Promise<{ @@ -115,15 +153,23 @@ exportRouter.get( "/:address/export/preview", exportLimiter, validateWalletAddress, + validateExportQuery, async (req: Request, res: Response) => { const { address } = req.params; + const { assets } = res.locals.exportParams as { + assets: string[] | undefined; + startDate: Date | undefined; + endDate: Date | undefined; + format: "csv" | "json"; + }; try { const fetched = await fetchRawTransactions(address); if (fetched.status === "error") { sendError(res, fetched.httpCode, fetched.errorCode, fetched.message); return; } - const preview = buildTaxLotPreview(fetched.rawTxs); + const previewOptions = assets ? { supportedTokens: assets } : {}; + const preview = buildTaxLotPreview(fetched.rawTxs, previewOptions); res.json(preview); } catch (error) { console.error( @@ -156,8 +202,15 @@ exportRouter.get( "/:address/export", exportLimiter, validateWalletAddress, + validateExportQuery, async (req: Request, res: Response) => { const { address } = req.params; + const { assets } = res.locals.exportParams as { + assets: string[] | undefined; + startDate: Date | undefined; + endDate: Date | undefined; + format: "csv" | "json"; + }; try { const fetched = await fetchRawTransactions(address); @@ -166,7 +219,8 @@ exportRouter.get( return; } - const preview = buildTaxLotPreview(fetched.rawTxs); + const previewOptions = assets ? { supportedTokens: assets } : {}; + const preview = buildTaxLotPreview(fetched.rawTxs, previewOptions); if (!preview.canDownload) { sendError( res, diff --git a/server/src/services/portfolio/exportValidation.ts b/server/src/services/portfolio/exportValidation.ts new file mode 100644 index 000000000..a7299cc86 --- /dev/null +++ b/server/src/services/portfolio/exportValidation.ts @@ -0,0 +1,273 @@ +/** + * Portfolio Export Validation (#1051) + * + * Validates date windows and asset filters supplied as query parameters + * to the portfolio export endpoints. All errors are collected up-front so + * the client receives a complete list of what needs fixing in a single + * response rather than one error at a time. + */ + +// ── Constants ──────────────────────────────────────────────────────────────── + +/** + * Maximum date range allowed for a single export request. + * Prevents unbounded DB queries and downstream report timeouts. + */ +export const EXPORT_MAX_WINDOW_DAYS = 366; + +/** + * Canonical set of asset symbols the export pipeline supports. + * Symbols are stored and compared in uppercase. + */ +export const EXPORT_SUPPORTED_ASSETS = new Set([ + "USDC", + "XLM", + "USDT", + "BTC", + "ETH", + "AQUA", + "BLND", + "SORO", +]); + +// ── Typed error shapes ──────────────────────────────────────────────────────── + +export type ExportValidationCode = + | "MISSING_START_DATE" + | "MISSING_END_DATE" + | "INVALID_START_DATE" + | "INVALID_END_DATE" + | "DATE_WINDOW_REVERSED" + | "DATE_WINDOW_TOO_LARGE" + | "DATE_IN_FUTURE" + | "UNSUPPORTED_ASSET" + | "INVALID_ASSETS_PARAM" + | "INVALID_FORMAT"; + +export interface ExportValidationError { + /** Machine-readable code — stable across releases for frontend switch-cases. */ + code: ExportValidationCode; + /** Human-readable description suitable for direct display. */ + message: string; + /** Optional structured payload for the frontend to render rich error UI. */ + details?: Record; +} + +export interface ExportValidationResult { + valid: boolean; + errors: ExportValidationError[]; + /** + * Parsed and normalised query values, present only when `valid` is true. + * Use these to avoid re-parsing in the route handler. + */ + parsed?: { + startDate: Date | undefined; + endDate: Date | undefined; + assets: string[] | undefined; + format: "csv" | "json"; + }; +} + +// ── Supported output formats ────────────────────────────────────────────────── + +const SUPPORTED_FORMATS = new Set(["csv", "json"]); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Returns true when the string can be parsed as a valid, finite ISO-8601 date. */ +function isValidIsoDate(value: string): boolean { + const d = new Date(value); + return Number.isFinite(d.getTime()); +} + +// ── Core validator ──────────────────────────────────────────────────────────── + +export interface ExportQueryParams { + startDate?: string; + endDate?: string; + /** Comma-separated list of uppercase asset symbols, e.g. "USDC,XLM". */ + assets?: string; + /** Output format; defaults to "csv". */ + format?: string; +} + +/** + * Validate portfolio export query parameters. + * + * Rules applied: + * - `startDate` and `endDate` must both be absent, or both be present. + * - When present, each must be a parseable ISO-8601 string. + * - `startDate` must be strictly before `endDate`. + * - Neither date may lie in the future. + * - The range must not exceed {@link EXPORT_MAX_WINDOW_DAYS} days. + * - `assets`, when provided, must be a comma-separated list where every + * symbol is in {@link EXPORT_SUPPORTED_ASSETS}. + * - `format`, when provided, must be "csv" or "json". + * + * The function never throws — it returns a typed result instead. + */ +export function validateExportParams( + params: ExportQueryParams, +): ExportValidationResult { + const errors: ExportValidationError[] = []; + const now = new Date(); + + const { startDate: rawStart, endDate: rawEnd, assets: rawAssets, format: rawFormat } = params; + + const hasStart = rawStart !== undefined && rawStart !== ""; + const hasEnd = rawEnd !== undefined && rawEnd !== ""; + + // ── Date presence checks ─────────────────────────────────────────────────── + + if (hasStart && !hasEnd) { + errors.push({ + code: "MISSING_END_DATE", + message: "endDate is required when startDate is provided.", + }); + } + + if (!hasStart && hasEnd) { + errors.push({ + code: "MISSING_START_DATE", + message: "startDate is required when endDate is provided.", + }); + } + + // ── Date format & range checks ───────────────────────────────────────────── + + let startDate: Date | undefined; + let endDate: Date | undefined; + + if (hasStart) { + if (!isValidIsoDate(rawStart!)) { + errors.push({ + code: "INVALID_START_DATE", + message: `startDate "${rawStart}" is not a valid ISO-8601 date.`, + details: { provided: rawStart }, + }); + } else { + startDate = new Date(rawStart!); + if (startDate > now) { + errors.push({ + code: "DATE_IN_FUTURE", + message: `startDate (${startDate.toISOString()}) cannot be in the future.`, + details: { provided: startDate.toISOString(), now: now.toISOString() }, + }); + } + } + } + + if (hasEnd) { + if (!isValidIsoDate(rawEnd!)) { + errors.push({ + code: "INVALID_END_DATE", + message: `endDate "${rawEnd}" is not a valid ISO-8601 date.`, + details: { provided: rawEnd }, + }); + } else { + endDate = new Date(rawEnd!); + if (endDate > now) { + errors.push({ + code: "DATE_IN_FUTURE", + message: `endDate (${endDate.toISOString()}) cannot be in the future.`, + details: { provided: endDate.toISOString(), now: now.toISOString() }, + }); + } + } + } + + // ── Cross-field date checks (only when both dates are individually valid) ─── + + if (startDate !== undefined && endDate !== undefined) { + if (startDate >= endDate) { + errors.push({ + code: "DATE_WINDOW_REVERSED", + message: `startDate (${startDate.toISOString()}) must be strictly before endDate (${endDate.toISOString()}).`, + details: { + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }, + }); + } else { + const windowDays = Math.ceil( + (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24), + ); + if (windowDays > EXPORT_MAX_WINDOW_DAYS) { + errors.push({ + code: "DATE_WINDOW_TOO_LARGE", + message: `Date range (${windowDays} days) exceeds the maximum of ${EXPORT_MAX_WINDOW_DAYS} days.`, + details: { windowDays, maxWindowDays: EXPORT_MAX_WINDOW_DAYS }, + }); + } + } + } + + // ── Asset filter checks ──────────────────────────────────────────────────── + + let parsedAssets: string[] | undefined; + + if (rawAssets !== undefined && rawAssets !== "") { + // Reject obviously malformed values (e.g. leading/trailing commas produce empty segments) + const segments = rawAssets.split(",").map((s) => s.trim()); + + if (segments.some((s) => s === "")) { + errors.push({ + code: "INVALID_ASSETS_PARAM", + message: + 'The "assets" parameter must be a comma-separated list of asset symbols with no empty entries (e.g. "USDC,XLM").', + details: { provided: rawAssets }, + }); + } else { + const uppercased = segments.map((s) => s.toUpperCase()); + const unknown = uppercased.filter((s) => !EXPORT_SUPPORTED_ASSETS.has(s)); + + if (unknown.length > 0) { + errors.push({ + code: "UNSUPPORTED_ASSET", + message: `Unsupported asset filter(s): ${unknown.join(", ")}. Supported assets are: ${[...EXPORT_SUPPORTED_ASSETS].join(", ")}.`, + details: { + unsupported: unknown, + supported: [...EXPORT_SUPPORTED_ASSETS], + }, + }); + } else { + // Deduplicate while preserving order + parsedAssets = [...new Set(uppercased)]; + } + } + } + + // ── Format check ───────────────────────────────────────────────────────── + + let parsedFormat: "csv" | "json" = "csv"; + + if (rawFormat !== undefined && rawFormat !== "") { + const lower = rawFormat.toLowerCase(); + if (!SUPPORTED_FORMATS.has(lower)) { + errors.push({ + code: "INVALID_FORMAT", + message: `Unsupported format "${rawFormat}". Supported formats are: csv, json.`, + details: { provided: rawFormat, supported: ["csv", "json"] }, + }); + } else { + parsedFormat = lower as "csv" | "json"; + } + } + + // ── Result ──────────────────────────────────────────────────────────────── + + if (errors.length > 0) { + return { valid: false, errors }; + } + + return { + valid: true, + errors: [], + parsed: { + startDate, + endDate, + assets: parsedAssets, + format: parsedFormat, + }, + }; +}