diff --git a/pr_description.md b/pr_description.md index 3cb0334..207452a 100644 --- a/pr_description.md +++ b/pr_description.md @@ -1,10 +1,11 @@ -🔒 HTTPヘッダーインジェクションの脆弱性修正 +🧪 [testing improvement] Add comprehensive unit tests for SearchForm component -🎯 **What:** -`src/lib/githubViewer.ts` と `src/lib/github.ts` において、セッショントークンを検証せずに直接 `fetch` 呼び出しの `Authorization` ヘッダーに渡していた脆弱性を修正しました。 - -⚠️ **Risk:** -不適切なトークン検証により、攻撃者がトークン内に `\r\n`(CRLF)などの改行文字を含めることで、HTTPヘッダーインジェクションやSSRF(Server-Side Request Forgery)攻撃を引き起こす可能性がありました。これにより、任意のAPIリクエストが実行されたり、セッションハイジャックのリスクが生じる恐れがありました。 - -🛡️ **Solution:** -APIを呼び出す前に、提供されたトークンが標準のGitHubトークン形式(英数字、ハイフン、アンダースコア、等号のみ)に一致するかを検証する正規表現チェック(`/^[A-Za-z0-9_=-]+$/`)を追加しました。無効なフォーマットの場合は、APIリクエストを行う前に `GitHubApiError` がスローされます。 +🎯 **What:** The `SearchForm` component lacked unit tests, leaving critical user interaction (searching for GitHub users) unverified. +📊 **Coverage:** This PR adds `src/components/SearchForm.test.tsx` utilizing `@testing-library/react` and `vitest`. The test suite now covers: + - Form rendering (input and submit button). + - State updates when typing in the input. + - Form submission logic and conditional disabling of the search button for empty inputs. + - Ensuring whitespace-only submissions are prevented. + - Verification that the username is trimmed and URI encoded properly before calling `router.push`. + - Simulating the React `useTransition` loading state where the button becomes disabled and displays "Loading...". +✨ **Result:** Enhanced test coverage and reliability for user search workflows. Improved testing confidence by mocking `next/navigation` and React hooks effectively. diff --git a/src/components/SearchForm.test.tsx b/src/components/SearchForm.test.tsx new file mode 100644 index 0000000..4584986 --- /dev/null +++ b/src/components/SearchForm.test.tsx @@ -0,0 +1,118 @@ +/** + * @vitest-environment jsdom + */ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import SearchForm from "./SearchForm"; +import * as React from "react"; + +// Mock next/navigation +const pushMock = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: pushMock, + }), +})); + +// Provide a mock implementation that we can spy on later +const useTransitionMock = vi.fn<() => [boolean, (cb: () => void) => void]>(); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTransition: () => useTransitionMock(), + }; +}); + +describe("SearchForm", () => { + beforeEach(() => { + vi.clearAllMocks(); + useTransitionMock.mockReturnValue([false, (cb: () => void) => cb()]); + }); + + it("renders the input and search button", () => { + render(); + expect(screen.getByPlaceholderText("GitHub username")).toBeDefined(); + expect(screen.getByRole("button", { name: "Search" })).toBeDefined(); + }); + + it("updates input value when typing", () => { + render(); + const input = screen.getByPlaceholderText("GitHub username") as HTMLInputElement; + fireEvent.change(input, { target: { value: "johndoe" } }); + expect(input.value).toBe("johndoe"); + }); + + it("disables the search button when input is empty", () => { + render(); + const button = screen.getByRole("button", { name: "Search" }) as HTMLButtonElement; + expect(button.disabled).toBe(true); + }); + + it("enables the search button only when input has non-whitespace text", () => { + render(); + const input = screen.getByPlaceholderText("GitHub username"); + const button = screen.getByRole("button", { name: "Search" }) as HTMLButtonElement; + + fireEvent.change(input, { target: { value: " " } }); + expect(button.disabled).toBe(true); + + fireEvent.change(input, { target: { value: "johndoe" } }); + expect(button.disabled).toBe(false); + }); + + it("calls router.push with the username on form submission", async () => { + render(); + const input = screen.getByPlaceholderText("GitHub username"); + const button = screen.getByRole("button", { name: "Search" }); + + fireEvent.change(input, { target: { value: "johndoe" } }); + fireEvent.click(button); + + await waitFor(() => { + expect(pushMock).toHaveBeenCalledWith("/johndoe"); + }); + }); + + it("trims the username and URI encodes it before submission", async () => { + render(); + const input = screen.getByPlaceholderText("GitHub username"); + const button = screen.getByRole("button", { name: "Search" }); + + // Using a username with spaces and special characters + fireEvent.change(input, { target: { value: " john doe/test " } }); + fireEvent.click(button); + + await waitFor(() => { + // "john doe/test" encoded is "john%20doe%2Ftest" + expect(pushMock).toHaveBeenCalledWith("/john%20doe%2Ftest"); + }); + }); + + it("does not call router.push if input is only whitespace", () => { + render(); + const input = screen.getByPlaceholderText("GitHub username"); + + fireEvent.change(input, { target: { value: " " } }); + + // The button should be disabled, but we can also trigger submit on the form directly + // to ensure the internal check `if (trimmed)` works + const form = input.closest("form")!; + fireEvent.submit(form); + + expect(pushMock).not.toHaveBeenCalled(); + }); + + it("disables button and shows 'Loading...' when isPending is true", () => { + // Mock useTransition to return [true, startTransition] + useTransitionMock.mockReturnValue([ + true, // isPending + vi.fn() as unknown as React.TransitionStartFunction, // startTransition + ]); + + render(); + const button = screen.getByRole("button", { name: "Loading..." }) as HTMLButtonElement; + expect(button.disabled).toBe(true); + }); +});