diff --git a/README.md b/README.md index 2eca963..f43cb63 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ Backend endpoints are taken from the companion documentation page `src/app/docs/ | `/services/:serviceId/agents` | Agents for a given service | `GET /api/v1/services/:serviceId/agents` | | `/services/:serviceId/edit` | Edit service | _(reads service + submits via service update endpoints in code)_ | | `/services/new` | Create service | `POST /api/v1/services` | -| `/settings` | User/app settings | _(calls settings endpoints in code)_ | +| `/settings` | User/app settings (theme configuration and Connection section displaying the resolved API base URL) | _(static UI settings surface)_ | | `/stats` | Statistics | _(calls stats endpoints in code)_ | | `/usage` | Usage totals & settlement workflow | `POST /api/v1/usage`, `GET /api/v1/usage/:agent/:serviceId`, `POST /api/v1/settle` | | `/webhooks` | Webhooks management | _(calls webhooks endpoints in code)_ | @@ -294,6 +294,8 @@ When rendering links: - Any external link rendered with `target="_blank"` must include `rel="noopener noreferrer"`. - Any `href` derived from backend/user data must be validated with `safeHref()` from `src/lib/url.ts`. Unsafe schemes like `javascript:` and `data:` are rejected. +- Links on the `/docs` page (relative OpenAPI and external GitHub reference link) are validated through `safeHref()`, falling back to plain text if validation fails. + ## Route map (frontend) | Path | Notes | diff --git a/src/app/docs/page.test.tsx b/src/app/docs/page.test.tsx index 1cfa03e..334372f 100644 --- a/src/app/docs/page.test.tsx +++ b/src/app/docs/page.test.tsx @@ -1,5 +1,6 @@ import { render, screen, act, fireEvent, cleanup } from "@testing-library/react"; import DocsPage from "./page"; +import * as urlUtils from "@/lib/url"; function mockClipboard(writeText = jest.fn().mockResolvedValue(undefined)) { Object.defineProperty(navigator, "clipboard", { @@ -193,18 +194,76 @@ describe("DocsPage", () => { it("renders the OpenAPI JSON link", () => { render(); - expect( - screen.getByRole("link", { name: /GET \/api\/v1\/openapi\.json/i }), - ).toHaveAttribute("href", "/api/v1/openapi.json"); + const link = screen.getByRole("link", { name: /GET \/api\/v1\/openapi\.json/i }); + expect(link).toHaveAttribute("href", "/api/v1/openapi.json"); + expect(link).not.toHaveAttribute("target"); + expect(link).not.toHaveAttribute("rel"); }); it("renders the dashboard API integration reference link", () => { render(); - expect( - screen.getByRole("link", { name: /dashboard API integration reference/i }), - ).toHaveAttribute( + const link = screen.getByRole("link", { + name: /dashboard API integration reference/i, + }); + expect(link).toHaveAttribute( "href", "https://github.com/Agentpay-Org/Agentpay-frontend/blob/main/docs/api-integration.md", ); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + describe("link safety validation fallbacks", () => { + let safeHrefSpy: jest.SpyInstance; + const originalSafeHref = urlUtils.safeHref; + + beforeEach(() => { + safeHrefSpy = jest.spyOn(urlUtils, "safeHref"); + }); + + afterEach(() => { + safeHrefSpy.mockRestore(); + }); + + it("renders relative OpenAPI link as plain text if validation fails", () => { + safeHrefSpy.mockImplementation((href) => { + if (href === "/api/v1/openapi.json") { + return { ok: false }; + } + return originalSafeHref(href); + }); + + render(); + + // The link should not be present + expect( + screen.queryByRole("link", { name: /GET \/api\/v1\/openapi\.json/i }), + ).not.toBeInTheDocument(); + + // But the text should still be rendered as plain text + expect(screen.getByText(/GET \/api\/v1\/openapi\.json/i)).toBeInTheDocument(); + }); + + it("renders external reference link as plain text if validation fails", () => { + safeHrefSpy.mockImplementation((href) => { + if ( + href === + "https://github.com/Agentpay-Org/Agentpay-frontend/blob/main/docs/api-integration.md" + ) { + return { ok: false }; + } + return originalSafeHref(href); + }); + + render(); + + // The link should not be present + expect( + screen.queryByRole("link", { name: /dashboard API integration reference/i }), + ).not.toBeInTheDocument(); + + // But the text should still be rendered as plain text + expect(screen.getByText(/dashboard API integration reference/i)).toBeInTheDocument(); + }); }); }); diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx index 6769a87..562b2f7 100644 --- a/src/app/docs/page.tsx +++ b/src/app/docs/page.tsx @@ -1,5 +1,8 @@ import { PageShell } from "@/components/PageShell"; import { messages } from "@/lib/messages"; +import { resolveApiBase } from "@/lib/resolveApiBase"; +import { CurlBlock } from "@/components/CurlBlock"; +import { safeHref } from "@/lib/url"; export const metadata = { title: "Docs — AgentPay" }; @@ -39,24 +42,39 @@ export default function DocsPage() { }, ]; + const openApiLink = safeHref("/api/v1/openapi.json"); + const referenceLink = safeHref( + "https://github.com/Agentpay-Org/Agentpay-frontend/blob/main/docs/api-integration.md", + ); + return (

{messages.docs.heading}

{messages.docs.introCompanionPrefix} - - {messages.docs.introOpenApi} - + {openApiLink.ok ? ( + + {messages.docs.introOpenApi} + + ) : ( + messages.docs.introOpenApi + )} {messages.docs.introCompanionSuffix}

{messages.docs.referencePrefix} - - {messages.docs.referenceLink} - + {referenceLink.ok ? ( + + {messages.docs.referenceLink} + + ) : ( + messages.docs.referenceLink + )} {messages.docs.referenceSuffix}

diff --git a/src/app/settings/page.test.tsx b/src/app/settings/page.test.tsx new file mode 100644 index 0000000..7380837 --- /dev/null +++ b/src/app/settings/page.test.tsx @@ -0,0 +1,89 @@ +import { render, screen, act, fireEvent, cleanup } from "@testing-library/react"; +import SettingsPage from "./page"; + +const mockMatchMedia = (matches: boolean) => { + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: jest.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); +}; + +function mockClipboard(writeText = jest.fn().mockResolvedValue(undefined)) { + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + return writeText; +} + +beforeEach(() => { + jest.useFakeTimers(); + mockClipboard(); + mockMatchMedia(false); +}); + +afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + cleanup(); + delete process.env.NEXT_PUBLIC_AGENTPAY_API_BASE; +}); + +describe("SettingsPage", () => { + it("renders the settings headings and description text", () => { + render(); + + expect(screen.getByRole("heading", { name: "Settings" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Appearance" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Connection" })).toBeInTheDocument(); + expect( + screen.getByText("Resolved API base URL of the AgentPay backend.") + ).toBeInTheDocument(); + }); + + it("uses the default API base URL (http://localhost:3001) when env is not set", () => { + render(); + + expect(screen.getByText("http://localhost:3001")).toBeInTheDocument(); + }); + + it("uses an overridden API base URL when NEXT_PUBLIC_AGENTPAY_API_BASE is set", () => { + process.env.NEXT_PUBLIC_AGENTPAY_API_BASE = "https://api.custombackend.com"; + render(); + + expect(screen.getByText("https://api.custombackend.com")).toBeInTheDocument(); + }); + + it("renders the copy button and handles the copy behavior with aria-live feedback", async () => { + const writeText = mockClipboard(); + process.env.NEXT_PUBLIC_AGENTPAY_API_BASE = "https://api.custombackend.com"; + render(); + + const copyBtn = screen.getByRole("button", { name: "Copy" }); + expect(copyBtn).toBeInTheDocument(); + expect(copyBtn).toHaveAttribute("aria-live", "polite"); + + await act(async () => { + fireEvent.click(copyBtn); + }); + + expect(writeText).toHaveBeenCalledWith("https://api.custombackend.com"); + expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(1500); + }); + + expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument(); + }); +}); diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 9f6f742..5e0a6f5 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,13 +1,19 @@ import { ThemeToggle } from "@/components/ThemeToggle"; import { PageShell } from "@/components/PageShell"; import { messages } from "@/lib/messages"; +import { resolveApiBase } from "@/lib/resolveApiBase"; +import { KeyValueGrid } from "@/components/KeyValueGrid"; +import { CopyButton } from "@/components/CopyButton"; export const metadata = { title: "Settings — AgentPay" }; export default function SettingsPage() { + const apiBase = resolveApiBase(); + return (

{messages.settings.heading}

+

{messages.settings.appearance.heading}

@@ -15,6 +21,28 @@ export default function SettingsPage() {

+ +
+

{messages.settings.connection.heading}

+

+ {messages.settings.connection.description} +

+
+ + {apiBase} + +
+ ), + }, + ]} + /> + +
); } diff --git a/src/components/__tests__/Header.test.tsx b/src/components/__tests__/Header.test.tsx index 54d7962..e3ba573 100644 --- a/src/components/__tests__/Header.test.tsx +++ b/src/components/__tests__/Header.test.tsx @@ -53,10 +53,11 @@ describe("Header", () => { mockPathname.mockReturnValue("/"); render(
); + // Open mobile menu so mobile links are rendered + fireEvent.click(getMobileToggle()); + // Exactly one link has aria-current="page" - const activeLinks = screen.getAllByRole("link").filter( - (link) => link.getAttribute("aria-current") === "page" - ); + const activeLinks = Array.from(document.querySelectorAll('[aria-current="page"]')); // Home link is active twice (desktop + mobile) expect(activeLinks.length).toBe(2); expect(activeLinks[0]).toHaveTextContent("Home"); @@ -67,9 +68,7 @@ describe("Header", () => { mockPathname.mockReturnValue("/unknown-route-123"); render(
); - const activeLinks = screen.getAllByRole("link").filter( - (link) => link.getAttribute("aria-current") === "page" - ); + const activeLinks = Array.from(document.querySelectorAll('[aria-current="page"]')); expect(activeLinks.length).toBe(0); }); @@ -80,10 +79,11 @@ describe("Header", () => { // Open the secondary menu to expose secondary links fireEvent.click(screen.getByRole("button", { name: /more/i })); + // Open mobile menu so mobile links are rendered + fireEvent.click(getMobileToggle()); + // The active links should strictly be the desktop "Services" and the mobile "Services" - const activeLinks = screen.getAllByRole("link", { hidden: true }).filter( - (link) => link.getAttribute("aria-current") === "page" - ); + const activeLinks = Array.from(document.querySelectorAll('[aria-current="page"]')); expect(activeLinks.length).toBe(2); expect(activeLinks[0]).toHaveTextContent("Services"); diff --git a/src/lib/__tests__/__snapshots__/messages.test.ts.snap b/src/lib/__tests__/__snapshots__/messages.test.ts.snap index d214e31..c7a3ff8 100644 --- a/src/lib/__tests__/__snapshots__/messages.test.ts.snap +++ b/src/lib/__tests__/__snapshots__/messages.test.ts.snap @@ -38,6 +38,11 @@ exports[`messages catalog matches the committed catalog snapshot 1`] = ` "description": "Choose a colour scheme. System follows your OS preference.", "heading": "Appearance", }, + "connection": { + "description": "Resolved API base URL of the AgentPay backend.", + "heading": "Connection", + "label": "API Base URL", + }, "heading": "Settings", }, } diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 8335cd9..6a04570 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -58,6 +58,11 @@ export const messages = { heading: "Appearance", description: "Choose a colour scheme. System follows your OS preference.", }, + connection: { + heading: "Connection", + description: "Resolved API base URL of the AgentPay backend.", + label: "API Base URL", + }, }, } as const;