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
45 changes: 44 additions & 1 deletion src/app/api/card/[username]/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi, beforeEach } from "vitest";


vi.mock("@/lib/apiUtils", () => ({
getAuthenticatedUser: vi.fn(),
}));

vi.mock("@/lib/cardDataFetcher", () => ({
fetchCardData: vi.fn(),
Expand All @@ -10,7 +15,33 @@ vi.mock("@/lib/cardRenderer", () => ({
renderErrorCardResponse: vi.fn(async ({ status, cacheControl }) => new Response("error", { status, headers: { "Cache-Control": cacheControl, "Content-Type": "image/png" } })),
}));


describe("GET /api/card/[username] authentication", () => {
it("returns 401 and correct message when user is not authenticated", async () => {
const { getAuthenticatedUser } = await import("@/lib/apiUtils");
const { renderErrorCardResponse } = await import("@/lib/cardRenderer");

vi.mocked(getAuthenticatedUser).mockResolvedValueOnce(null);

const { GET } = await import("./route");
const req = new Request("http://localhost/api/card/unauthuser");
await GET(req, { params: Promise.resolve({ username: "unauthuser" }) });

expect(renderErrorCardResponse).toHaveBeenCalledWith(expect.objectContaining({
message: "Unauthorized",
status: 401,
cacheControl: "public, s-maxage=60, stale-while-revalidate=120",
fontUrl: "http://localhost:3000/fonts/NotoSans-Regular.ttf"
}));
});
});

describe("GET /api/card/[username] cache headers", () => {
beforeEach(async () => {
const { getAuthenticatedUser } = await import("@/lib/apiUtils");
vi.mocked(getAuthenticatedUser).mockResolvedValue({ username: "testuser", token: "token" });
});

it("uses long cache header on success", async () => {
const { fetchCardData } = await import("@/lib/cardDataFetcher");
vi.mocked(fetchCardData).mockResolvedValueOnce({
Expand Down Expand Up @@ -54,7 +85,13 @@ describe("GET /api/card/[username] cache headers", () => {
});
});


describe("GET /api/card/[username] error responses", () => {
beforeEach(async () => {
const { getAuthenticatedUser } = await import("@/lib/apiUtils");
vi.mocked(getAuthenticatedUser).mockResolvedValue({ username: "testuser", token: "token" });
});

const runErrorTest = async (mockData: null | Error, username: string, expectedMessage: string, expectedStatus: number) => {
const { fetchCardData } = await import("@/lib/cardDataFetcher");
const { renderErrorCardResponse } = await import("@/lib/cardRenderer");
Expand Down Expand Up @@ -86,7 +123,13 @@ describe("GET /api/card/[username] error responses", () => {
});
});


describe("GET /api/card/[username] rate limiting", () => {
beforeEach(async () => {
const { getAuthenticatedUser } = await import("@/lib/apiUtils");
vi.mocked(getAuthenticatedUser).mockResolvedValue({ username: "testuser", token: "token" });
});

it("should rate limit requests", async () => {
const { GET } = await import("./route");
const { fetchCardData } = await import("@/lib/cardDataFetcher");
Expand Down
15 changes: 14 additions & 1 deletion src/app/api/card/[username]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { RateLimiter } from "@/lib/rateLimit";
import { fetchCardData } from "@/lib/cardDataFetcher";
import { parseCardQueryParams, renderCardResponse, renderErrorCardResponse } from "@/lib/cardRenderer";
import { getClientIp } from "@/lib/rateLimit";
import { getAuthenticatedUser } from "@/lib/apiUtils";

export const runtime = "edge";
const rateLimiter = new RateLimiter(50, 60 * 1000); // 50 requests per minute


Expand All @@ -20,6 +20,19 @@ export async function GET(
const allowedOrigin = process.env.APP_URL || "http://localhost:3000";
const fontUrl = `${allowedOrigin}/fonts/NotoSans-Regular.ttf`;


const user = await getAuthenticatedUser();
if (!user) {
return renderErrorCardResponse({
message: "Unauthorized",
options,
status: 401,
cacheControl: ERROR_CACHE,
fontUrl,
allowedOrigin,
});
}
Comment on lines +24 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security 401レスポンスへのパブリックキャッシュ適用が危険

ERROR_CACHE = "public, s-maxage=60, stale-while-revalidate=120" を 401 レスポンスにそのまま使用しています。Vercel の Edge Network など CDN は s-maxage が明示的に指定されている場合に 401 レスポンスをキャッシュする場合があります。認証していないリクエストが先に届くと、その CDN キャッシュが 60 秒間残り、同じ URL を後からリクエストした認証済みユーザーにも 401 が返ってしまいます。認証の成否はリクエストごとの Session 依存なので、401 には no-store または private を使うべきです。

Suggested change
const user = await getAuthenticatedUser();
if (!user) {
return renderErrorCardResponse({
message: "Unauthorized",
options,
status: 401,
cacheControl: ERROR_CACHE,
fontUrl,
allowedOrigin,
});
}
const user = await getAuthenticatedUser();
if (!user) {
return renderErrorCardResponse({
message: "Unauthorized",
options,
status: 401,
cacheControl: "no-store",
fontUrl,
allowedOrigin,
});
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/api/card/[username]/route.ts
Line: 24-34

Comment:
**401レスポンスへのパブリックキャッシュ適用が危険**

`ERROR_CACHE = "public, s-maxage=60, stale-while-revalidate=120"` を 401 レスポンスにそのまま使用しています。Vercel の Edge Network など CDN は `s-maxage` が明示的に指定されている場合に 401 レスポンスをキャッシュする場合があります。認証していないリクエストが先に届くと、その CDN キャッシュが 60 秒間残り、同じ URL を後からリクエストした認証済みユーザーにも 401 が返ってしまいます。認証の成否はリクエストごとの Session 依存なので、401 には `no-store` または `private` を使うべきです。

```suggestion
    const user = await getAuthenticatedUser();
    if (!user) {
        return renderErrorCardResponse({
            message: "Unauthorized",
            options,
            status: 401,
            cacheControl: "no-store",
            fontUrl,
            allowedOrigin,
        });
    }
```

How can I resolve this? If you propose a fix, please make it concise.


const ip = getClientIp(request);
const rateLimitResult = await rateLimiter.check(ip);

Expand Down
4 changes: 4 additions & 0 deletions test-results/.last-run.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}
Comment on lines +1 to +4

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The test-results/.last-run.json file is a temporary test execution artifact and should not be committed to the repository. Please remove this file and add test-results/ to your .gitignore file to prevent it from being tracked in the future.

Comment on lines +1 to +4

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 テスト失敗状態のファイルがコミットされています

test-results/.last-run.json"status": "failed" はテスト実行が失敗した状態でコミットされていることを示しています。failedTests 配列は空ですが、テストランナー自体が正常に完了していない可能性があります。また、このファイルは .gitignore に含まれておらず、今後も生成のたびにコミット対象になる恐れがあります。.gitignoretest-results/ を追加することを推奨します。

Prompt To Fix With AI
This is a comment left during a code review.
Path: test-results/.last-run.json
Line: 1-4

Comment:
**テスト失敗状態のファイルがコミットされています**

`test-results/.last-run.json``"status": "failed"` はテスト実行が失敗した状態でコミットされていることを示しています。`failedTests` 配列は空ですが、テストランナー自体が正常に完了していない可能性があります。また、このファイルは `.gitignore` に含まれておらず、今後も生成のたびにコミット対象になる恐れがあります。`.gitignore``test-results/` を追加することを推奨します。

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Loading