-
Notifications
You must be signed in to change notification settings - Fork 0
[] Add getAppMeta/getAppLicenses to lib/api.ts, create app/about/page.tsx and app/licenses/page.tsx (with tests) #298
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import { render, screen, waitFor } from "@testing-library/react"; | ||
| import { beforeEach, describe, expect, it, vi } from "vitest"; | ||
|
|
||
| const mockGetAppMeta = vi.fn(); | ||
|
|
||
| vi.mock("@/lib/api", () => ({ | ||
| getAppMeta: (...args: unknown[]) => mockGetAppMeta(...args), | ||
| })); | ||
|
|
||
| import AboutPage from "@/app/about/page"; | ||
| import { BRANDING } from "@/lib/branding"; | ||
|
|
||
| const fixedMeta = { | ||
| app_name: "OpenGit", | ||
| version: "1.2.3", | ||
| git_commit: "abc1234", | ||
| build_date: "2025-01-01T00:00:00Z", | ||
| license: "Apache-2.0", | ||
| source_url: "https://example.org/repo", | ||
| }; | ||
|
|
||
| describe("AboutPage", () => { | ||
| beforeEach(() => { | ||
| mockGetAppMeta.mockReset(); | ||
| vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:8080"); | ||
| }); | ||
|
|
||
| it("shows loading state before promise resolves", () => { | ||
| mockGetAppMeta.mockReturnValue(new Promise(() => {})); | ||
|
|
||
| render(<AboutPage />); | ||
|
|
||
| expect(screen.getByText("Loading...")).toBeInTheDocument(); | ||
| expect(screen.getByText("dev")).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("renders app name, version, and link to licenses", async () => { | ||
| mockGetAppMeta.mockResolvedValue(fixedMeta); | ||
|
|
||
| render(<AboutPage />); | ||
|
|
||
| await waitFor(() => { | ||
| expect(screen.getByRole("heading", { name: BRANDING.appName })).toBeInTheDocument(); | ||
| expect(screen.getByText("1.2.3")).toBeInTheDocument(); | ||
| expect(screen.getByRole("link", { name: "Apache-2.0" })).toHaveAttribute( | ||
| "href", | ||
| "/licenses", | ||
| ); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import { render, screen, waitFor } from "@testing-library/react"; | ||
| import { beforeEach, describe, expect, it, vi } from "vitest"; | ||
|
|
||
| const mockGetAppLicenses = vi.fn(); | ||
|
|
||
| vi.mock("@/lib/api", () => ({ | ||
| getAppLicenses: (...args: unknown[]) => mockGetAppLicenses(...args), | ||
| })); | ||
|
|
||
| import LicensesPage from "@/app/licenses/page"; | ||
|
|
||
| describe("LicensesPage", () => { | ||
| beforeEach(() => { | ||
| mockGetAppLicenses.mockReset(); | ||
| vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:8080"); | ||
| }); | ||
|
|
||
| it("renders third-party license entries", async () => { | ||
| mockGetAppLicenses.mockResolvedValue({ | ||
| app_license: "Apache-2.0", | ||
| third_party: [ | ||
| { | ||
| name: "react", | ||
| version: "18.0.0", | ||
| license: "MIT", | ||
| url: "https://react.dev", | ||
| }, | ||
| { | ||
| name: "next", | ||
| version: "14.0.0", | ||
| license: "MIT", | ||
| url: "https://nextjs.org", | ||
| }, | ||
| ], | ||
| }); | ||
|
|
||
| render(<LicensesPage />); | ||
|
|
||
| await waitFor(() => { | ||
| expect(screen.getByText("react")).toBeInTheDocument(); | ||
| expect(screen.getByText("next")).toBeInTheDocument(); | ||
| }); | ||
| }); | ||
|
|
||
| it("shows fallback text when third_party is empty", async () => { | ||
| mockGetAppLicenses.mockResolvedValue({ | ||
| app_license: "Apache-2.0", | ||
| third_party: [], | ||
| }); | ||
|
|
||
| render(<LicensesPage />); | ||
|
|
||
| await waitFor(() => { | ||
| expect( | ||
| screen.getByText("ライセンス情報を取得できませんでした"), | ||
| ).toBeInTheDocument(); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,43 +1,34 @@ | ||
| import { render, screen } from "@testing-library/react"; | ||
| import { render, screen, waitFor } from "@testing-library/react"; | ||
| import { beforeEach, describe, expect, it, vi } from "vitest"; | ||
|
|
||
| const mockGetAppMeta = vi.fn(); | ||
|
|
||
| vi.mock("@/lib/api", () => ({ | ||
| getAppMeta: (...args: unknown[]) => mockGetAppMeta(...args), | ||
| })); | ||
|
|
||
| import AboutPage from "@/app/about/page"; | ||
|
|
||
| describe("AboutPage", () => { | ||
| beforeEach(() => { | ||
| mockGetAppMeta.mockReset(); | ||
| vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:8080"); | ||
|
|
||
| vi.stubGlobal( | ||
| "fetch", | ||
| vi.fn(async (input: RequestInfo | URL) => { | ||
| const url = String(input); | ||
| if (url.endsWith("/api/v1/version")) { | ||
| return { | ||
| ok: true, | ||
| status: 200, | ||
| json: async () => ({ | ||
| data: { | ||
| version: "1.0.0", | ||
| commit: "abc1234", | ||
| buildDate: "2025-01-01T00:00:00Z", | ||
| }, | ||
| }), | ||
| }; | ||
| } | ||
| return { | ||
| ok: false, | ||
| status: 404, | ||
| json: async () => ({}), | ||
| }; | ||
| }), | ||
| ); | ||
| mockGetAppMeta.mockResolvedValue({ | ||
| app_name: "OpenGit", | ||
| version: "1.0.0", | ||
| git_commit: "abc1234", | ||
| build_date: "2025-01-01T00:00:00Z", | ||
| license: "Apache-2.0", | ||
| source_url: "https://example.org/repo", | ||
| }); | ||
| }); | ||
|
|
||
| it("renders project name and includes a link", async () => { | ||
| const page = await AboutPage(); | ||
| render(page); | ||
| render(<AboutPage />); | ||
|
|
||
| expect(screen.getByText("OpenGit")).toBeInTheDocument(); | ||
| await waitFor(() => { | ||
| expect(screen.getByText("OpenGit")).toBeInTheDocument(); | ||
| }); | ||
| expect(document.querySelector("a")).not.toBeNull(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| "use client"; | ||
|
|
||
| import { useEffect, useState } from "react"; | ||
|
|
||
| import { getAppLicenses } from "@/lib/api"; | ||
| import type { AppLicenses } from "@/lib/api-types"; | ||
|
|
||
| export default function LicensesPage() { | ||
| const [licenses, setLicenses] = useState<AppLicenses | null>(null); | ||
| const [loading, setLoading] = useState(true); | ||
|
|
||
| useEffect(() => { | ||
| getAppLicenses(process.env.NEXT_PUBLIC_API_BASE_URL ?? "") | ||
| .then(setLicenses) | ||
| .finally(() => setLoading(false)); | ||
| }, []); | ||
|
|
||
| if (loading) { | ||
| return ( | ||
| <div className="mx-auto max-w-4xl px-6 py-12"> | ||
| <p className="text-muted-foreground">Loading...</p> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <div className="mx-auto max-w-4xl px-6 py-12"> | ||
| <h1 className="mb-6 text-3xl font-bold">Licenses</h1> | ||
|
|
||
| <section className="mb-8"> | ||
| <h2 className="mb-2 text-xl font-semibold">Project License</h2> | ||
| <p>{licenses?.app_license ?? ""}</p> | ||
| </section> | ||
|
|
||
| {licenses?.third_party.length === 0 ? ( | ||
| <p>ライセンス情報を取得できませんでした</p> | ||
| ) : ( | ||
| <section> | ||
| <h2 className="mb-4 text-xl font-semibold">Third-Party Licenses</h2> | ||
| <table className="w-full border-collapse text-left"> | ||
| <thead> | ||
| <tr className="border-b"> | ||
| <th className="py-2 pr-4">Name</th> | ||
| <th className="py-2 pr-4">Version</th> | ||
| <th className="py-2 pr-4">License</th> | ||
| <th className="py-2">URL</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| {licenses?.third_party.map((entry) => ( | ||
| <tr key={`${entry.name}-${entry.version}`} className="border-b"> | ||
| <td className="py-2 pr-4">{entry.name}</td> | ||
| <td className="py-2 pr-4">{entry.version}</td> | ||
| <td className="py-2 pr-4">{entry.license}</td> | ||
| <td className="py-2"> | ||
| <a | ||
| href={entry.url} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="text-primary underline" | ||
| > | ||
| {entry.url} | ||
| </a> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [high] Open Redirect / 任意 URL インジェクション: APIレスポンスの entry.url を href に直接使用 CWE-601: URL Redirection to Untrusted Site ('Open Redirect') / CWE-20: Improper Input Validation 該当箇所: <a
href={entry.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
{entry.url}
</a>
推奨対応: function isSafeUrl(url: string): boolean {
try {
const { protocol } = new URL(url);
return protocol === "https:" || protocol === "http:";
} catch {
return false;
}
}レンダリング前に ```tsx
{isSafeUrl(entry.url) ? (
<a href={entry.url} target="_blank" rel="noopener noreferrer" className="text-primary underline">
{entry.url}
</a>
) : (
<span>{entry.url}</span>
)} |
||
| </td> | ||
| </tr> | ||
| ))} | ||
| </tbody> | ||
| </table> | ||
| </section> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 [high] licenses/page.tsx がアンマウント後に state を更新する可能性がある
該当箇所:
about/page.tsxではcancelledフラグで cleanup しているのに、licenses/page.tsxには cleanup 関数が存在しない。ページから素早く離脱した場合、Promiseが解決されたタイミングでアンマウント済みのコンポーネントにsetLicenses/setLoadingが呼ばれ、React の警告 (Can't perform a React state update on an unmounted component) が発生する(React 18 strict mode では開発時に表示される)。about/page.tsxと同様にcancelledフラグを導入すべき。code.null_safety.unmounted_state_update| confidence: 0.88