Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions pr_description.md
Original file line number Diff line number Diff line change
@@ -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.
118 changes: 118 additions & 0 deletions src/components/SearchForm.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("react")>();
return {
...actual,
useTransition: () => useTransitionMock(),
};
});

describe("SearchForm", () => {
beforeEach(() => {
vi.clearAllMocks();
useTransitionMock.mockReturnValue([false, (cb: () => void) => cb()]);
});

it("renders the input and search button", () => {
render(<SearchForm />);
expect(screen.getByPlaceholderText("GitHub username")).toBeDefined();
expect(screen.getByRole("button", { name: "Search" })).toBeDefined();
});

it("updates input value when typing", () => {
render(<SearchForm />);
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(<SearchForm />);
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(<SearchForm />);
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(<SearchForm />);
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(<SearchForm />);
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(<SearchForm />);
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(<SearchForm />);
const button = screen.getByRole("button", { name: "Loading..." }) as HTMLButtonElement;
expect(button.disabled).toBe(true);
});
});
Loading