From 0a64b7b59bc562226c0fbd440a0eee907f8504c9 Mon Sep 17 00:00:00 2001 From: smartcalsystems-sketch Date: Tue, 13 Oct 2026 23:20:18 +0530 Subject: [PATCH] fix: validate ?tab= before trusting it in ContributorDashboardClient (closes #692) `(searchParams.get("tab") as "active" | "completed") || "active"` casts whatever string is in the URL without checking it. /dashboard/contributor ?tab=foo (or a stale/mistyped bookmark) set tab to the literal string "foo", which is neither "active" nor "completed" -- no Tabs entry matched it, so no tab was aria-selected, no tab had tabIndex=0 (unreachable by keyboard), and neither tabpanel rendered. Now only the literal value "completed" selects the completed tab; every other value, including garbage, falls back to "active". --- .../ContributorDashboardClient.test.tsx | 42 ++++++++++++++++++- .../ContributorDashboardClient.tsx | 5 ++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/app/dashboard/contributor/ContributorDashboardClient.test.tsx b/src/app/dashboard/contributor/ContributorDashboardClient.test.tsx index 7519c16..c89db68 100644 --- a/src/app/dashboard/contributor/ContributorDashboardClient.test.tsx +++ b/src/app/dashboard/contributor/ContributorDashboardClient.test.tsx @@ -14,6 +14,7 @@ import { act, render, screen, waitFor } from "@testing-library/react"; import ContributorDashboardClient from "./ContributorDashboardClient"; import { useAuth } from "@/context/AuthContext"; +import { useSearchParams } from "next/navigation"; import { apiRequest, fetchBounties, type FetchResult } from "@/lib/api"; import { mockBounties } from "@/lib/mock-data"; import type { Bounty } from "@/types"; @@ -27,12 +28,13 @@ jest.mock("@/context/AuthContext", () => ({ useAuth: jest.fn() })); jest.mock("next/navigation", () => ({ useRouter: () => ({ replace: jest.fn(), refresh: jest.fn() }), usePathname: () => "/dashboard/contributor", - useSearchParams: () => new URLSearchParams(), + useSearchParams: jest.fn(() => new URLSearchParams()), })); const mockUseAuth = useAuth as jest.MockedFunction; const mockApiRequest = apiRequest as jest.MockedFunction; const mockFetchBounties = fetchBounties as jest.MockedFunction; +const mockUseSearchParams = useSearchParams as jest.MockedFunction; interface Snapshot { totalEarnings: string; @@ -113,6 +115,44 @@ describe("ContributorDashboardClient — data-source badge", () => { }); }); +describe("ContributorDashboardClient — ?tab= param validation (#692)", () => { + it("falls back to the active tab when ?tab= has an unrecognized value", async () => { + mockUseSearchParams.mockReturnValueOnce( + new URLSearchParams("tab=foo") as ReturnType, + ); + mockApiRequest.mockResolvedValue(SNAPSHOT); + setUser({ id: "u1", username: "alice" }); + + render(); + await screen.findByText("Live data"); + + expect(screen.getByRole("tab", { name: /Active/i })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(screen.getByRole("tab", { name: /Completed/i })).toHaveAttribute( + "aria-selected", + "false", + ); + }); + + it("honors a valid ?tab=completed value", async () => { + mockUseSearchParams.mockReturnValueOnce( + new URLSearchParams("tab=completed") as ReturnType, + ); + mockApiRequest.mockResolvedValue(SNAPSHOT); + setUser({ id: "u1", username: "alice" }); + + render(); + await screen.findByText("Live data"); + + expect(screen.getByRole("tab", { name: /Completed/i })).toHaveAttribute( + "aria-selected", + "true", + ); + }); +}); + describe("ContributorDashboardClient — out-of-order response guard", () => { it("keeps the last-started stats request's data when an earlier one resolves later", async () => { const first = deferred(); diff --git a/src/app/dashboard/contributor/ContributorDashboardClient.tsx b/src/app/dashboard/contributor/ContributorDashboardClient.tsx index 5343c3a..b75de74 100644 --- a/src/app/dashboard/contributor/ContributorDashboardClient.tsx +++ b/src/app/dashboard/contributor/ContributorDashboardClient.tsx @@ -71,8 +71,11 @@ export default function ContributorDashboardClient() { const [bounties, setBounties] = useState(mockBounties); const [statsSource, setStatsSource] = useState("loading"); const [bountiesSource, setBountiesSource] = useState("loading"); + // Only "active"/"completed" are valid tab values — anything else (a stale + // bookmark, a mistyped `?tab=foo`) falls back to "active" instead of + // selecting neither tab and rendering no panel at all (#692). const [tab, setTabState] = useState<"active" | "completed">( - (searchParams.get("tab") as "active" | "completed") || "active" + searchParams.get("tab") === "completed" ? "completed" : "active" ); // Explicit fetch status: starts "loading" so cards shimmer rather than // flashing zeroes while the auth check + API call are in flight.