Skip to content

Commit 374d806

Browse files
committed
feat: validate pair asset codes
1 parent 82bb76f commit 374d806

7 files changed

Lines changed: 268 additions & 48 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ The frontend communicates with the StableRoute API backend.
4848
- **`/api/v1/events`**: Retrieves system event audit logs (`GET`).
4949
- **`/api/v1/webhooks`**: Creates (`POST`), lists (`GET`), and revokes (`DELETE` at `/api/v1/webhooks/:id`) webhook subscriptions.
5050

51+
### Asset Codes
52+
53+
Stellar asset codes entered through the new-pair form are trimmed, validated as
54+
1-12 ASCII letters or numbers, uppercased before submission, and compared after
55+
normalization so duplicate pairs such as `usdc` and `USDC` cannot be registered.
56+
5157
## Prerequisites
5258

5359
- Node.js 18+

src/app/api-keys/page.test.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@ describe("ApiKeysPage", () => {
2121
it("renders api keys in a single polite live region", async () => {
2222
globalThis.fetch = jest.fn().mockResolvedValueOnce({
2323
ok: true,
24-
json: async () => ({
25-
items: [{ prefix: "sk_abc", label: "Production", createdAt: Date.now() }],
26-
}),
24+
text: async () =>
25+
JSON.stringify({
26+
items: [{ prefix: "sk_abc", label: "Production", createdAt: Date.now() }],
27+
}),
2728
} as unknown as Response);
2829

2930
render(<ApiKeysPage />);
@@ -38,7 +39,7 @@ describe("ApiKeysPage", () => {
3839
it("announces empty state via live region", async () => {
3940
globalThis.fetch = jest.fn().mockResolvedValueOnce({
4041
ok: true,
41-
json: async () => ({ items: [] }),
42+
text: async () => JSON.stringify({ items: [] }),
4243
} as unknown as Response);
4344

4445
render(<ApiKeysPage />);
@@ -59,7 +60,7 @@ describe("ApiKeysPage", () => {
5960
it("has exactly one aria-live=polite region", async () => {
6061
globalThis.fetch = jest.fn().mockResolvedValueOnce({
6162
ok: true,
62-
json: async () => ({ items: [] }),
63+
text: async () => JSON.stringify({ items: [] }),
6364
} as unknown as Response);
6465

6566
render(<ApiKeysPage />);

src/app/events/page.test.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@ describe("EventsPage", () => {
2121
it("renders events in a single polite live region", async () => {
2222
globalThis.fetch = jest.fn().mockResolvedValueOnce({
2323
ok: true,
24-
json: async () => ({
25-
items: [{ id: "evt1", ts: Date.now(), type: "pair.registered", payload: {} }],
26-
}),
24+
text: async () =>
25+
JSON.stringify({
26+
items: [{ id: "evt1", ts: Date.now(), type: "pair.registered", payload: {} }],
27+
}),
2728
} as unknown as Response);
2829

2930
render(<EventsPage />);
@@ -38,7 +39,7 @@ describe("EventsPage", () => {
3839
it("announces empty state via live region", async () => {
3940
globalThis.fetch = jest.fn().mockResolvedValueOnce({
4041
ok: true,
41-
json: async () => ({ items: [] }),
42+
text: async () => JSON.stringify({ items: [] }),
4243
} as unknown as Response);
4344

4445
render(<EventsPage />);
@@ -59,7 +60,7 @@ describe("EventsPage", () => {
5960
it("has exactly one aria-live=polite region", async () => {
6061
globalThis.fetch = jest.fn().mockResolvedValueOnce({
6162
ok: true,
62-
json: async () => ({ items: [] }),
63+
text: async () => JSON.stringify({ items: [] }),
6364
} as unknown as Response);
6465

6566
render(<EventsPage />);

src/app/pairs/new/page.test.tsx

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2+
import NewPairPage from "./page";
3+
4+
const mockPush = jest.fn();
5+
6+
jest.mock("next/navigation", () => ({
7+
useRouter: () => ({ push: mockPush }),
8+
}));
9+
10+
describe("NewPairPage", () => {
11+
let originalFetch: typeof globalThis.fetch;
12+
13+
beforeEach(() => {
14+
originalFetch = globalThis.fetch;
15+
mockPush.mockReset();
16+
});
17+
18+
afterEach(() => {
19+
globalThis.fetch = originalFetch;
20+
});
21+
22+
function submitPair(source: string, destination: string) {
23+
fireEvent.change(screen.getByLabelText("Source"), {
24+
target: { value: source },
25+
});
26+
fireEvent.change(screen.getByLabelText("Destination"), {
27+
target: { value: destination },
28+
});
29+
fireEvent.submit(screen.getByRole("button", { name: /Register pair/i }).closest("form")!);
30+
}
31+
32+
it("normalizes lowercase and surrounding whitespace before submit", async () => {
33+
const mockFetch = jest.fn().mockResolvedValueOnce({
34+
ok: true,
35+
text: async () => "{}",
36+
} as unknown as Response);
37+
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
38+
39+
render(<NewPairPage />);
40+
submitPair(" usdc ", " eurc ");
41+
42+
await waitFor(() => {
43+
expect(mockPush).toHaveBeenCalledWith("/pairs");
44+
});
45+
const requestInit = mockFetch.mock.calls[0][1] as RequestInit;
46+
expect(requestInit.method).toBe("POST");
47+
expect(JSON.parse(requestInit.body as string)).toEqual({
48+
source: "USDC",
49+
destination: "EURC",
50+
});
51+
});
52+
53+
it.each(["USD-C", "USD C", "ABCDEFGHIJKLM"])(
54+
"rejects invalid source asset code %s with accessible field errors",
55+
async (code) => {
56+
const mockFetch = jest.fn();
57+
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
58+
59+
render(<NewPairPage />);
60+
submitPair(code, "EURC");
61+
62+
const sourceInput = document.getElementById("source");
63+
await waitFor(() => {
64+
expect(screen.getByRole("alert")).toHaveTextContent(/ASCII letters or numbers/i);
65+
});
66+
expect(sourceInput).toHaveAttribute("aria-invalid", "true");
67+
expect(sourceInput).toHaveAttribute("aria-describedby", "source-err");
68+
expect(mockFetch).not.toHaveBeenCalled();
69+
}
70+
);
71+
72+
it("rejects pairs that are identical after trimming and uppercasing", async () => {
73+
const mockFetch = jest.fn();
74+
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
75+
76+
render(<NewPairPage />);
77+
submitPair(" usdc ", "USDC");
78+
79+
const destinationInput = document.getElementById("destination");
80+
await waitFor(() => {
81+
expect(screen.getByRole("alert")).toHaveTextContent(/must differ/i);
82+
});
83+
expect(destinationInput).toHaveAttribute("aria-invalid", "true");
84+
expect(destinationInput).toHaveAttribute("aria-describedby", "destination-err");
85+
expect(mockFetch).not.toHaveBeenCalled();
86+
});
87+
88+
it("validates empty fields with inline errors instead of native browser validation", async () => {
89+
const mockFetch = jest.fn();
90+
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
91+
92+
render(<NewPairPage />);
93+
fireEvent.submit(screen.getByRole("button", { name: /Register pair/i }).closest("form")!);
94+
95+
await waitFor(() => {
96+
expect(screen.getAllByRole("alert")).toHaveLength(2);
97+
});
98+
expect(document.getElementById("source")).toHaveAttribute("aria-invalid", "true");
99+
expect(document.getElementById("destination")).toHaveAttribute("aria-invalid", "true");
100+
expect(mockFetch).not.toHaveBeenCalled();
101+
});
102+
103+
it("clears an identical-pair error when source changes", async () => {
104+
const mockFetch = jest.fn();
105+
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
106+
107+
render(<NewPairPage />);
108+
submitPair("USDC", "USDC");
109+
110+
await waitFor(() => {
111+
expect(screen.getByRole("alert")).toHaveTextContent(/must differ/i);
112+
});
113+
fireEvent.change(screen.getByLabelText("Source"), {
114+
target: { value: "XLM" },
115+
});
116+
117+
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
118+
});
119+
120+
it("surfaces backend errors without losing the normalized request body", async () => {
121+
const mockFetch = jest.fn().mockResolvedValueOnce({
122+
ok: false,
123+
text: async () =>
124+
JSON.stringify({
125+
error: "invalid_request",
126+
message: "Pair already exists",
127+
}),
128+
} as unknown as Response);
129+
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
130+
131+
render(<NewPairPage />);
132+
submitPair("xlm", "usdc");
133+
134+
await waitFor(() => {
135+
expect(screen.getByRole("alert")).toHaveTextContent(/Pair already exists/i);
136+
});
137+
const requestInit = mockFetch.mock.calls[0][1] as RequestInit;
138+
expect(JSON.parse(requestInit.body as string)).toEqual({
139+
source: "XLM",
140+
destination: "USDC",
141+
});
142+
});
143+
});

src/app/pairs/new/page.tsx

Lines changed: 91 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,72 @@
11
"use client";
22

3-
import { useState } from "react";
3+
import { type FormEvent, useState } from "react";
44
import { useRouter } from "next/navigation";
5+
import { TextField } from "@/components/TextField";
56
import { apiPost } from "@/lib/apiClient";
67

8+
const ASSET_CODE_RE = /^[A-Za-z0-9]{1,12}$/;
9+
const ASSET_CODE_ERROR = "Use 1-12 ASCII letters or numbers.";
10+
11+
type FormErrors = {
12+
source?: string;
13+
destination?: string;
14+
form?: string;
15+
};
16+
17+
/**
18+
* Trims and normalizes Stellar asset codes after validating the raw code
19+
* characters are ASCII alphanumeric only.
20+
*/
21+
function normalizeAssetCode(value: string): string | null {
22+
const trimmed = value.trim();
23+
return ASSET_CODE_RE.test(trimmed) ? trimmed.toUpperCase() : null;
24+
}
25+
726
export default function NewPairPage() {
827
const router = useRouter();
928
const [source, setSource] = useState("");
1029
const [destination, setDestination] = useState("");
11-
const [error, setError] = useState<string | null>(null);
30+
const [errors, setErrors] = useState<FormErrors>({});
1231
const [loading, setLoading] = useState(false);
1332

14-
const onSubmit = async (e: React.FormEvent) => {
33+
const onSubmit = async (e: FormEvent) => {
1534
e.preventDefault();
16-
setError(null);
17-
if (source === destination) {
18-
setError("Source and destination must differ.");
35+
const normalizedSource = normalizeAssetCode(source);
36+
const normalizedDestination = normalizeAssetCode(destination);
37+
const nextErrors: FormErrors = {};
38+
39+
if (!normalizedSource) {
40+
nextErrors.source = ASSET_CODE_ERROR;
41+
}
42+
if (!normalizedDestination) {
43+
nextErrors.destination = ASSET_CODE_ERROR;
44+
}
45+
if (
46+
normalizedSource &&
47+
normalizedDestination &&
48+
normalizedSource === normalizedDestination
49+
) {
50+
nextErrors.destination = "Source and destination must differ.";
51+
}
52+
53+
if (Object.keys(nextErrors).length > 0 || !normalizedSource || !normalizedDestination) {
54+
setErrors(nextErrors);
1955
return;
2056
}
57+
58+
setErrors({});
59+
setSource(normalizedSource);
60+
setDestination(normalizedDestination);
2161
setLoading(true);
2262
try {
23-
await apiPost("/api/v1/pairs", { source, destination });
63+
await apiPost("/api/v1/pairs", {
64+
source: normalizedSource,
65+
destination: normalizedDestination,
66+
});
2467
router.push("/pairs");
2568
} catch (err) {
26-
setError((err as Error).message);
69+
setErrors({ form: (err as Error).message });
2770
} finally {
2871
setLoading(false);
2972
}
@@ -36,35 +79,53 @@ export default function NewPairPage() {
3679
className="mx-auto flex min-h-[60vh] max-w-xl flex-col gap-6 p-8 focus:outline-none"
3780
>
3881
<h1 className="text-3xl font-semibold tracking-tight">New pair</h1>
39-
<form onSubmit={onSubmit} className="flex flex-col gap-3">
40-
<label className="flex flex-col gap-1 text-sm">
41-
<span>Source</span>
42-
<input
43-
required
44-
maxLength={12}
45-
value={source}
46-
onChange={(e) => setSource(e.target.value)}
47-
className="rounded-md border border-neutral-300 px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-neutral-700 dark:bg-neutral-900"
48-
/>
49-
</label>
50-
<label className="flex flex-col gap-1 text-sm">
51-
<span>Destination</span>
52-
<input
53-
required
54-
maxLength={12}
55-
value={destination}
56-
onChange={(e) => setDestination(e.target.value)}
57-
className="rounded-md border border-neutral-300 px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-neutral-700 dark:bg-neutral-900"
58-
/>
59-
</label>
82+
<form onSubmit={onSubmit} className="flex flex-col gap-3" noValidate>
83+
<TextField
84+
id="source"
85+
label="Source"
86+
required
87+
value={source}
88+
onChange={(e) => {
89+
setSource(e.target.value);
90+
setErrors((current) => ({
91+
...current,
92+
source: undefined,
93+
destination:
94+
current.destination === "Source and destination must differ."
95+
? undefined
96+
: current.destination,
97+
form: undefined,
98+
}));
99+
}}
100+
error={errors.source}
101+
/>
102+
<TextField
103+
id="destination"
104+
label="Destination"
105+
required
106+
value={destination}
107+
onChange={(e) => {
108+
setDestination(e.target.value);
109+
setErrors((current) => ({
110+
...current,
111+
destination: undefined,
112+
form: undefined,
113+
}));
114+
}}
115+
error={errors.destination}
116+
/>
60117
<button
61118
type="submit"
62119
disabled={loading}
63120
className="self-start rounded-full bg-black px-5 py-2 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
64121
>
65122
{loading ? "Saving…" : "Register pair"}
66123
</button>
67-
{error && <p role="alert" className="text-sm text-rose-600">{error}</p>}
124+
{errors.form && (
125+
<p role="alert" className="text-sm text-rose-600">
126+
{errors.form}
127+
</p>
128+
)}
68129
</form>
69130
</main>
70131
);

0 commit comments

Comments
 (0)