Skip to content

Commit 6f26510

Browse files
committed
Merge branch 'pr-160'
2 parents 52300ae + a8ec18c commit 6f26510

3 files changed

Lines changed: 135 additions & 4 deletions

File tree

docs/quote-validation.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Quote Input Validation
2+
3+
The quote form validates source and destination asset codes before building the
4+
`GET /api/v1/quote` URL.
5+
6+
Asset codes are trimmed and must match the Stellar asset-code shape
7+
`^[A-Za-z0-9]{1,12}$`. Invalid codes, whitespace-only values, identical source
8+
and destination codes, and invalid amounts stop submission before any network
9+
request is issued.
10+
11+
Validated values are still URL-encoded when the request URL is constructed.

src/app/quote/Client.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ type Quote = {
1212

1313
const API_BASE =
1414
process.env.NEXT_PUBLIC_STABLEROUTE_API_BASE ?? "http://localhost:3001";
15+
const ASSET_CODE_PATTERN = /^[A-Za-z0-9]{1,12}$/;
16+
17+
/**
18+
* Returns a trimmed Stellar asset code when it is safe to send to the quote API.
19+
*/
20+
function normalizeAssetCode(value: string): string | null {
21+
const trimmed = value.trim();
22+
return ASSET_CODE_PATTERN.test(trimmed) ? trimmed : null;
23+
}
1524

1625
export default function QuoteClient() {
1726
const [sourceAsset, setSourceAsset] = useState("");
@@ -26,18 +35,26 @@ export default function QuoteClient() {
2635
setError(null);
2736
setQuote(null);
2837

29-
if (sourceAsset === destAsset) {
38+
const normalizedSourceAsset = normalizeAssetCode(sourceAsset);
39+
const normalizedDestAsset = normalizeAssetCode(destAsset);
40+
const normalizedAmount = amount.trim();
41+
42+
if (!normalizedSourceAsset || !normalizedDestAsset) {
43+
setError("Asset codes must be 1-12 letters or numbers.");
44+
return;
45+
}
46+
if (normalizedSourceAsset === normalizedDestAsset) {
3047
setError("Source and destination assets must differ.");
3148
return;
3249
}
33-
if (!/^[1-9][0-9]{0,38}$/.test(amount)) {
50+
if (!/^[1-9][0-9]{0,38}$/.test(normalizedAmount)) {
3451
setError("Amount must be a positive integer (base units).");
3552
return;
3653
}
3754

3855
setLoading(true);
3956
try {
40-
const url = `${API_BASE}/api/v1/quote?source_asset=${encodeURIComponent(sourceAsset)}&dest_asset=${encodeURIComponent(destAsset)}&amount=${encodeURIComponent(amount)}`;
57+
const url = `${API_BASE}/api/v1/quote?source_asset=${encodeURIComponent(normalizedSourceAsset)}&dest_asset=${encodeURIComponent(normalizedDestAsset)}&amount=${encodeURIComponent(normalizedAmount)}`;
4158
const res = await fetch(url);
4259
const body = await res.json();
4360
if (!res.ok) {

src/app/quote/page.test.tsx

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ describe("QuotePage", () => {
2121
});
2222

2323
it("calls the backend and renders the route on success", async () => {
24-
globalThis.fetch = jest.fn().mockResolvedValueOnce({
24+
const mockFetch = jest.fn().mockResolvedValueOnce({
2525
ok: true,
2626
json: async () => ({
2727
source_asset: "USDC",
@@ -31,6 +31,7 @@ describe("QuotePage", () => {
3131
route: ["USDC", "EURC"],
3232
}),
3333
} as unknown as Response);
34+
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
3435

3536
render(<QuotePage />);
3637
fireEvent.change(screen.getByLabelText(/Source asset/i), {
@@ -47,6 +48,9 @@ describe("QuotePage", () => {
4748
await waitFor(() => {
4849
expect(screen.getByRole("status")).toHaveTextContent(/USDC EURC/);
4950
});
51+
expect(mockFetch).toHaveBeenCalledWith(
52+
expect.stringContaining("source_asset=USDC&dest_asset=EURC&amount=1000000"),
53+
);
5054
});
5155

5256
it("blocks submission when source == destination", async () => {
@@ -95,6 +99,105 @@ describe("QuotePage", () => {
9599
expect(mockFetch).not.toHaveBeenCalled();
96100
});
97101

102+
it("blocks submission when an asset code contains unsafe characters", async () => {
103+
const mockFetch = jest.fn();
104+
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
105+
render(<QuotePage />);
106+
107+
fireEvent.change(screen.getByLabelText(/Source asset/i), {
108+
target: { value: "US$C" },
109+
});
110+
fireEvent.change(screen.getByLabelText(/Destination asset/i), {
111+
target: { value: "EURC" },
112+
});
113+
fireEvent.change(screen.getByLabelText(/Amount/i), {
114+
target: { value: "100" },
115+
});
116+
fireEvent.submit(screen.getByLabelText(/Amount/i).closest("form")!);
117+
118+
await waitFor(() => {
119+
expect(screen.getByRole("alert")).toHaveTextContent(/1-12 letters or numbers/i);
120+
});
121+
expect(mockFetch).not.toHaveBeenCalled();
122+
});
123+
124+
it("blocks submission when an asset code is over length", async () => {
125+
const mockFetch = jest.fn();
126+
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
127+
render(<QuotePage />);
128+
129+
fireEvent.change(screen.getByLabelText(/Source asset/i), {
130+
target: { value: "TOO-LONG-ASSET" },
131+
});
132+
fireEvent.change(screen.getByLabelText(/Destination asset/i), {
133+
target: { value: "EURC" },
134+
});
135+
fireEvent.change(screen.getByLabelText(/Amount/i), {
136+
target: { value: "100" },
137+
});
138+
fireEvent.submit(screen.getByLabelText(/Amount/i).closest("form")!);
139+
140+
await waitFor(() => {
141+
expect(screen.getByRole("alert")).toHaveTextContent(/1-12 letters or numbers/i);
142+
});
143+
expect(mockFetch).not.toHaveBeenCalled();
144+
});
145+
146+
it("blocks submission when asset codes are whitespace-only", async () => {
147+
const mockFetch = jest.fn();
148+
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
149+
render(<QuotePage />);
150+
151+
fireEvent.change(screen.getByLabelText(/Source asset/i), {
152+
target: { value: " " },
153+
});
154+
fireEvent.change(screen.getByLabelText(/Destination asset/i), {
155+
target: { value: "EURC" },
156+
});
157+
fireEvent.change(screen.getByLabelText(/Amount/i), {
158+
target: { value: "100" },
159+
});
160+
fireEvent.submit(screen.getByLabelText(/Amount/i).closest("form")!);
161+
162+
await waitFor(() => {
163+
expect(screen.getByRole("alert")).toHaveTextContent(/1-12 letters or numbers/i);
164+
});
165+
expect(mockFetch).not.toHaveBeenCalled();
166+
});
167+
168+
it("trims valid asset codes and amount before issuing the request", async () => {
169+
const mockFetch = jest.fn().mockResolvedValueOnce({
170+
ok: true,
171+
json: async () => ({
172+
source_asset: "USDC",
173+
dest_asset: "EURC",
174+
amount: "100",
175+
estimated_rate: "1.0",
176+
route: ["USDC", "EURC"],
177+
}),
178+
} as unknown as Response);
179+
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
180+
render(<QuotePage />);
181+
182+
fireEvent.change(screen.getByLabelText(/Source asset/i), {
183+
target: { value: " USDC " },
184+
});
185+
fireEvent.change(screen.getByLabelText(/Destination asset/i), {
186+
target: { value: " EURC " },
187+
});
188+
fireEvent.change(screen.getByLabelText(/Amount/i), {
189+
target: { value: " 100 " },
190+
});
191+
fireEvent.click(screen.getByRole("button", { name: /Get quote/i }));
192+
193+
await waitFor(() => {
194+
expect(screen.getByRole("status")).toHaveTextContent(/USDC EURC/);
195+
});
196+
expect(mockFetch).toHaveBeenCalledWith(
197+
expect.stringContaining("source_asset=USDC&dest_asset=EURC&amount=100"),
198+
);
199+
});
200+
98201
it("surfaces a backend invalid_request as a role=alert", async () => {
99202
globalThis.fetch = jest.fn().mockResolvedValueOnce({
100203
ok: false,

0 commit comments

Comments
 (0)