diff --git a/.jules/sentinel.md b/.jules/sentinel.md index a5cc38db..8f82ad8d 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -16,3 +16,7 @@ **Issue:** String exceptions were not handled, causing the app to return an Internal Server Error. **Learning:** `e instanceof Error` correctly identifies built-in errors, but doesn't handle `throw "error"`. It caused Hono to not correctly propagate a 500 status. **Prevention:** Handled by validating `e instanceof Error ? e.message : typeof e === 'string' ? e : "";` and throwing an error properly at the end `throw e instanceof Error ? e : new Error(String(e));`. +## 2026-05-15 - [IPスプーフィングの脆弱性] +**Vulnerability:** X-Forwarded-For ヘッダーを使用した IP アドレスのフォールバック +**Learning:** Cloudflare Workers 環境などでは CF-Connecting-IP が信頼できる IP アドレスのソースとなります。X-Forwarded-For にフォールバックすると、クライアントがヘッダーを偽装(スプーフィング)し、IP ベースのアクセス制御やレート制限を回避できる可能性があります。 +**Prevention:** 信頼できるロードバランサーや CDN が設定するヘッダー(例:CF-Connecting-IP)のみを使用し、クライアントから送信される可能性のあるヘッダー(例:X-Forwarded-For)へのフォールバックは避ける。 diff --git a/apps/api/src/routes/__tests__/orgs.test.ts b/apps/api/src/routes/__tests__/orgs.test.ts index 6df3b51b..67a8e64e 100644 --- a/apps/api/src/routes/__tests__/orgs.test.ts +++ b/apps/api/src/routes/__tests__/orgs.test.ts @@ -2213,4 +2213,25 @@ describe("orgs routes", () => { expect(body.error).toContain("last admin"); }); }); + + describe("GET /api/orgs/:slug/papers algorithmic DoS prevention", () => { + it("escapes wildcard characters correctly in papers query endpoint", async () => { + const app = await createTestApp(); + const env = createTestEnv(); + + queueSelectResponses([ + { getResult: { id: "org-1", slug: "test" } }, + { getResult: { id: "user-1", role: "admin" } }, + { allResult: [] } + ]); + + const res = await app.request( + "http://localhost/api/orgs/test/papers?venue=%25%5C_", // %\_ + {}, + env as any + ); + + expect(res.status).toBe(200); + }); + }); }); diff --git a/apps/api/src/routes/__tests__/papers.test.ts b/apps/api/src/routes/__tests__/papers.test.ts index 93c01f15..625bfc29 100644 --- a/apps/api/src/routes/__tests__/papers.test.ts +++ b/apps/api/src/routes/__tests__/papers.test.ts @@ -2161,11 +2161,13 @@ describe("papers routes", () => { }); const set = vi.fn().mockReturnThis(); const where = vi.fn().mockReturnThis(); - mockDb.select = vi.fn().mockImplementation(() => - makeQuery({ - getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, - }), - ); + mockDb.select = vi + .fn() + .mockImplementation(() => + makeQuery({ + getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, + }), + ); mockDb.update = vi.fn().mockImplementation(() => ({ set, where }) as any); const app = await createTestApp(); @@ -2209,11 +2211,13 @@ describe("papers routes", () => { githubId: "123", name: "Uploader", }); - mockDb.select = vi.fn().mockImplementation(() => - makeQuery({ - getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, - }), - ); + mockDb.select = vi + .fn() + .mockImplementation(() => + makeQuery({ + getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, + }), + ); const app = await createTestApp(); const env = createTestEnv(); @@ -2367,11 +2371,13 @@ describe("papers routes", () => { }); const set = vi.fn().mockReturnThis(); const where = vi.fn().mockReturnThis(); - mockDb.select = vi.fn().mockImplementation(() => - makeQuery({ - getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, - }), - ); + mockDb.select = vi + .fn() + .mockImplementation(() => + makeQuery({ + getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, + }), + ); mockDb.update = vi.fn().mockImplementation(() => ({ set, where }) as any); const app = await createTestApp(); @@ -2417,11 +2423,13 @@ describe("papers routes", () => { }); const set = vi.fn().mockReturnThis(); const where = vi.fn().mockReturnThis(); - mockDb.select = vi.fn().mockImplementation(() => - makeQuery({ - getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, - }), - ); + mockDb.select = vi + .fn() + .mockImplementation(() => + makeQuery({ + getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, + }), + ); mockDb.update = vi.fn().mockImplementation(() => ({ set, where }) as any); const app = await createTestApp(); @@ -2457,11 +2465,13 @@ describe("papers routes", () => { }); const set = vi.fn().mockReturnThis(); const where = vi.fn().mockReturnThis(); - mockDb.select = vi.fn().mockImplementation(() => - makeQuery({ - getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, - }), - ); + mockDb.select = vi + .fn() + .mockImplementation(() => + makeQuery({ + getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, + }), + ); mockDb.update = vi.fn().mockImplementation(() => ({ set, where }) as any); const app = await createTestApp(); @@ -2495,11 +2505,13 @@ describe("papers routes", () => { githubId: "123", name: "Uploader", }); - mockDb.select = vi.fn().mockImplementation(() => - makeQuery({ - getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, - }), - ); + mockDb.select = vi + .fn() + .mockImplementation(() => + makeQuery({ + getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, + }), + ); const app = await createTestApp(); const env = createTestEnv(); @@ -2529,11 +2541,13 @@ describe("papers routes", () => { }); const set = vi.fn().mockReturnThis(); const where = vi.fn().mockReturnThis(); - mockDb.select = vi.fn().mockImplementation(() => - makeQuery({ - getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, - }), - ); + mockDb.select = vi + .fn() + .mockImplementation(() => + makeQuery({ + getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, + }), + ); mockDb.update = vi.fn().mockImplementation(() => ({ set, where }) as any); const app = await createTestApp(); @@ -2569,11 +2583,13 @@ describe("papers routes", () => { }); const set = vi.fn().mockReturnThis(); const where = vi.fn().mockReturnThis(); - mockDb.select = vi.fn().mockImplementation(() => - makeQuery({ - getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, - }), - ); + mockDb.select = vi + .fn() + .mockImplementation(() => + makeQuery({ + getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, + }), + ); mockDb.update = vi.fn().mockImplementation(() => ({ set, where }) as any); const app = await createTestApp(); @@ -2611,11 +2627,13 @@ describe("papers routes", () => { }); const set = vi.fn().mockReturnThis(); const where = vi.fn().mockReturnThis(); - mockDb.select = vi.fn().mockImplementation(() => - makeQuery({ - getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, - }), - ); + mockDb.select = vi + .fn() + .mockImplementation(() => + makeQuery({ + getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, + }), + ); mockDb.update = vi.fn().mockImplementation(() => ({ set, where }) as any); const app = await createTestApp(); @@ -2651,11 +2669,13 @@ describe("papers routes", () => { }); const set = vi.fn().mockReturnThis(); const where = vi.fn().mockReturnThis(); - mockDb.select = vi.fn().mockImplementation(() => - makeQuery({ - getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, - }), - ); + mockDb.select = vi + .fn() + .mockImplementation(() => + makeQuery({ + getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, + }), + ); mockDb.update = vi.fn().mockImplementation(() => ({ set, where }) as any); const app = await createTestApp(); @@ -2691,11 +2711,13 @@ describe("papers routes", () => { }); const set = vi.fn().mockReturnThis(); const where = vi.fn().mockReturnThis(); - mockDb.select = vi.fn().mockImplementation(() => - makeQuery({ - getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, - }), - ); + mockDb.select = vi + .fn() + .mockImplementation(() => + makeQuery({ + getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, + }), + ); mockDb.update = vi.fn().mockImplementation(() => ({ set, where }) as any); const app = await createTestApp(); @@ -2739,11 +2761,13 @@ describe("papers routes", () => { githubId: "123", name: "Uploader", }); - mockDb.select = vi.fn().mockImplementation(() => - makeQuery({ - getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, - }), - ); + mockDb.select = vi + .fn() + .mockImplementation(() => + makeQuery({ + getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, + }), + ); const app = await createTestApp(); const env = createTestEnv(); @@ -2897,11 +2921,13 @@ describe("papers routes", () => { }); const set = vi.fn().mockReturnThis(); const where = vi.fn().mockReturnThis(); - mockDb.select = vi.fn().mockImplementation(() => - makeQuery({ - getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, - }), - ); + mockDb.select = vi + .fn() + .mockImplementation(() => + makeQuery({ + getResult: { paperId: "paper-1", userId: "user-1", role: "uploader" }, + }), + ); mockDb.update = vi.fn().mockImplementation(() => ({ set, where }) as any); const app = await createTestApp(); @@ -2940,11 +2966,13 @@ describe("papers routes", () => { }); it("GET /api/papers/:id returns 401 for private paper without Bearer token", async () => { - mockDb.select = vi.fn().mockImplementationOnce(() => - makeQuery({ - getResult: { id: "paper-1", title: "P1", visibility: "private" }, - }), - ); + mockDb.select = vi + .fn() + .mockImplementationOnce(() => + makeQuery({ + getResult: { id: "paper-1", title: "P1", visibility: "private" }, + }), + ); const app = await createTestApp(); const env = createTestEnv(); @@ -3201,6 +3229,7 @@ describe("papers routes", () => { expect(res.status).toBe(500); }); + it("POST /api/papers/:id/invites handles non-string non-Error rejections", async () => { const token = await createTestJWT({ sub: "user-uploader", @@ -3238,6 +3267,7 @@ describe("papers routes", () => { expect(res.status).toBe(500); }); + }); }); diff --git a/apps/api/src/routes/__tests__/tags.test.ts b/apps/api/src/routes/__tests__/tags.test.ts index 2355a66e..bd3c376c 100644 --- a/apps/api/src/routes/__tests__/tags.test.ts +++ b/apps/api/src/routes/__tests__/tags.test.ts @@ -49,11 +49,29 @@ describe("tags routes", () => { expect(mockDb.select).not.toHaveBeenCalled(); }); + it("GET /api/tags/suggest rejects overly long queries", async () => { + const token = await createTestJWT({ sub: "user-1", githubId: "123", name: "Uploader" }); + const app = await createTestApp(); + const env = createTestEnv(); + + const res = await app.request( + `http://localhost/api/tags/suggest?q=${"a".repeat(101)}`, + { + headers: { Authorization: `Bearer ${token}` }, + }, + env as any, + ); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ error: "query too long" }); + expect(mockDb.select).not.toHaveBeenCalled(); + }); + it("GET /api/tags/suggest returns prefix-matched tags for current user", async () => { const token = await createTestJWT({ sub: "user-1", githubId: "123", name: "Uploader" }); const app = await createTestApp(); const env = createTestEnv(); - (env.DB as any).prepare = vi.fn().mockReturnValue({ bind: vi.fn().mockReturnValue({ all: vi.fn().mockResolvedValue({ results: [{ tag: "AI" }] }) }) }); + (env.DB as any).prepare = vi.fn().mockReturnValue({ bind: vi.fn().mockReturnValue({ all: vi.fn().mockReturnValue({ results: [{ tag: "AI" }] }) }) }); const res = await app.request( "http://localhost/api/tags/suggest?q=AI", { @@ -96,7 +114,7 @@ describe("tags routes", () => { const app = await createTestApp(); const env = createTestEnv(); - env.DB = { prepare: vi.fn().mockReturnValue({ bind: vi.fn().mockReturnValue({ all: vi.fn().mockResolvedValue({ results: [{ tag: "Search" }, { tag: "Secret Notes" }] }) }) }) }; + (env.DB as any).prepare = vi.fn().mockReturnValue({ bind: vi.fn().mockReturnValue({ all: vi.fn().mockReturnValue({ results: [{ tag: "Search" }, { tag: "Secret Notes" }] }) }) }); const res = await app.request( "http://localhost/api/tags/suggest?q=Se&orgSlug=my-lab", { @@ -110,4 +128,105 @@ describe("tags routes", () => { tags: ["Search", "Secret Notes"], }); }); + + it("GET /api/tags/suggest escapes wildcard characters to prevent algorithmic DoS", async () => { + const token = await createTestJWT({ sub: "user-1", githubId: "123", name: "Tester" }); + + const app = await createTestApp(); + const env = createTestEnv(); + const bind = vi.fn().mockReturnValue({ + all: vi.fn().mockReturnValue({ results: [] }), + }); + + (env.DB as any).prepare = vi.fn().mockReturnValue({ bind }); + + const res = await app.request( + "http://localhost/api/tags/suggest?q=%25%5C_", // %\_ + { headers: { Authorization: `Bearer ${token}` } }, + env as any + ); + + expect(res.status).toBe(200); + const normalized = bind.mock.calls[0][1] as string; + expect(normalized).toContain("\\%"); + expect(normalized).toContain("\\\\"); + expect(normalized).toContain("\\_"); + }); + + it("GET /api/tags/suggest escapes wildcard characters with orgSlug", async () => { + const token = await createTestJWT({ sub: "user-1", githubId: "123", name: "Tester" }); + + const app = await createTestApp(); + const env = createTestEnv(); + const bind = vi.fn().mockReturnValue({ + all: vi.fn().mockReturnValue({ results: [] }), + }); + + queueSelectResponses([ + { getResult: { id: "org-1" } }, + { getResult: { userId: "user-1" } }, + ]); + + (env.DB as any).prepare = vi.fn().mockReturnValue({ bind }); + + + const res = await app.request( + "http://localhost/api/tags/suggest?q=%25%5C_&orgSlug=test-org", // %\_ + { headers: { Authorization: `Bearer ${token}` } }, + env as any + ); + + expect(res.status).toBe(200); + const normalized = bind.mock.calls[0][2] as string; + expect(normalized).toContain("\\%"); + expect(normalized).toContain("\\\\"); + expect(normalized).toContain("\\_"); + }); + + it("GET /api/tags/suggest returns 404 when orgSlug is not found", async () => { + const token = await createTestJWT({ sub: "user-1", githubId: "123", name: "Tester" }); + + const app = await createTestApp(); + const env = createTestEnv(); + + queueSelectResponses([ + { getResult: null } + ]); + + const res = await app.request( + "http://localhost/api/tags/suggest?q=org&orgSlug=test-org", + { + headers: { Authorization: `Bearer ${token}` } + }, + env as any + ); + + expect(res.status).toBe(404); + const body = (await res.json()) as any; + expect(body.error).toBe("Org not found"); + }); + + it("GET /api/tags/suggest returns 403 when user is not a member of the org", async () => { + const token = await createTestJWT({ sub: "user-1", githubId: "123", name: "Tester" }); + + const app = await createTestApp(); + const env = createTestEnv(); + + queueSelectResponses([ + { getResult: { id: "org-1" } }, + { getResult: null }, + ]); + + const res = await app.request( + "http://localhost/api/tags/suggest?q=org&orgSlug=test-org", + { + headers: { Authorization: `Bearer ${token}` } + }, + env as any + ); + + expect(res.status).toBe(403); + const body = (await res.json()) as any; + expect(body.error).toBe("Forbidden"); + }); }); diff --git a/apps/api/src/routes/__tests__/users.test.ts b/apps/api/src/routes/__tests__/users.test.ts index 13bdecbe..2effad75 100644 --- a/apps/api/src/routes/__tests__/users.test.ts +++ b/apps/api/src/routes/__tests__/users.test.ts @@ -271,4 +271,20 @@ describe("users routes", () => { expect(body.users).toHaveLength(1); expect(body.users[0].name).toBe("Bob"); // Fetched from DB since cache was evicted }); + + it("escapes wildcard characters correctly in search endpoint", async () => { + const token = await createTestJWT({ sub: "user-1", githubId: "123", name: "Tester" }); + mockDb.select = vi.fn(() => makeQuery({ allResult: [{ id: "user-1", name: "Tester" }] })); + + const app = await createTestApp(); + const env = createTestEnv(); + + const res = await app.request( + "http://localhost/api/users/search?q=%25%5C_", // %\_ + { headers: { Authorization: `Bearer ${token}` } }, + env as any + ); + + expect(res.status).toBe(200); + }); }); diff --git a/apps/api/src/routes/orgs.ts b/apps/api/src/routes/orgs.ts index 61cc4292..d9ccdeb4 100644 --- a/apps/api/src/routes/orgs.ts +++ b/apps/api/src/routes/orgs.ts @@ -17,6 +17,7 @@ import { import type { Env, JwtPayload, Variables } from "../types"; import { authMiddleware } from "../middleware/auth"; import { parseStoredTags } from "../utils/tags"; +import { escapeLikeLiteral } from "../utils/sql"; import { ID_MAX_LENGTH } from "../utils/constants"; const orgsRoute = new Hono<{ Bindings: Env; Variables: Variables }>(); @@ -86,17 +87,15 @@ function hasJwtSub(value: unknown): value is Pick { const MEMBER_ROLES = ["admin", "member"] as const; -const escapeLikeLiteral = (str: string) => { - return str.replace(/[\\%_]/g, "\\$&"); -}; - const ADMIN_LIKE_ROLES = ["admin", "owner"] as const; function isMemberRole(role: unknown): role is (typeof MEMBER_ROLES)[number] { return (MEMBER_ROLES as readonly unknown[]).includes(role); } -function isAdminLikeRole(role: unknown): role is (typeof ADMIN_LIKE_ROLES)[number] { +function isAdminLikeRole( + role: unknown, +): role is (typeof ADMIN_LIKE_ROLES)[number] { return (ADMIN_LIKE_ROLES as readonly unknown[]).includes(role); } diff --git a/apps/api/src/routes/papers.ts b/apps/api/src/routes/papers.ts index cb41cf47..58353b48 100644 --- a/apps/api/src/routes/papers.ts +++ b/apps/api/src/routes/papers.ts @@ -159,9 +159,6 @@ function isBotUserAgent(userAgent: string | undefined): boolean { function getClientIp(c: Context<{ Bindings: Env; Variables: Variables }>): string { return c.req.header("CF-Connecting-IP") - ?? c.req.header("cf-connecting-ip") - ?? c.req.header("X-Forwarded-For")?.split(",")[0]?.trim() - ?? c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown-ip"; } diff --git a/apps/api/src/routes/tags.ts b/apps/api/src/routes/tags.ts index d5956088..a5f82226 100644 --- a/apps/api/src/routes/tags.ts +++ b/apps/api/src/routes/tags.ts @@ -1,16 +1,15 @@ import { Hono } from "hono"; import { drizzle } from "drizzle-orm/d1"; import { and, eq } from "drizzle-orm"; -import { - orgMembers, - orgs, -} from "../db/schema"; +import { orgMembers, orgs } from "../db/schema"; import type { Env, Variables } from "../types"; import { authMiddleware } from "../middleware/auth"; +import { escapeLikeLiteral } from "../utils/sql"; const tagsRoute = new Hono<{ Bindings: Env; Variables: Variables }>(); const TAG_SUGGEST_MIN_QUERY_LENGTH = 2; +const TAG_SUGGEST_MAX_QUERY_LENGTH = 100; const TAG_SUGGEST_LIMIT = 20; const SAFE_TAG_ARRAY_SQL = ` @@ -21,37 +20,41 @@ const SAFE_TAG_ARRAY_SQL = ` `; const TRIMMED_TAG_SQL = "TRIM(json_each.value)"; - // GET /api/tags/suggest?q=...&orgSlug=... tagsRoute.get("/suggest", authMiddleware, async (c) => { - const db = drizzle(c.env.DB); - const userId = c.get("user").sub; - const query = (c.req.query("q") ?? "").trim(); - const orgSlug = (c.req.query("orgSlug") ?? "").trim().toLowerCase(); - const normalizedQuery = query.toLowerCase().replace(/([%_\\])/g, '\\$1'); + const db = drizzle(c.env.DB); + const userId = c.get("user").sub; + const query = (c.req.query("q") ?? "").trim(); + const orgSlug = (c.req.query("orgSlug") ?? "").trim().toLowerCase(); + + if (query.length < TAG_SUGGEST_MIN_QUERY_LENGTH) { + return c.json({ tags: [] }); + } + if (query.length > TAG_SUGGEST_MAX_QUERY_LENGTH) { + return c.json({ error: "query too long" }, 400); + } - if (query.length < TAG_SUGGEST_MIN_QUERY_LENGTH) { - return c.json({ tags: [] }); - } + const normalizedQuery = escapeLikeLiteral(query.toLowerCase()); - let tags: string[] = []; + let tags: string[] = []; - if (orgSlug) { - const org = await db - .select({ id: orgs.id }) - .from(orgs) - .where(eq(orgs.slug, orgSlug)) - .get(); - if (!org) return c.json({ error: "Org not found" }, 404); + if (orgSlug) { + const org = await db + .select({ id: orgs.id }) + .from(orgs) + .where(eq(orgs.slug, orgSlug)) + .get(); + if (!org) return c.json({ error: "Org not found" }, 404); - const membership = await db - .select({ userId: orgMembers.userId }) - .from(orgMembers) - .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, userId))) - .get(); - if (!membership) return c.json({ error: "Forbidden" }, 403); + const membership = await db + .select({ userId: orgMembers.userId }) + .from(orgMembers) + .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, userId))) + .get(); + if (!membership) return c.json({ error: "Forbidden" }, 403); - const result = await c.env.DB.prepare(` + const result = await c.env.DB.prepare( + ` SELECT ${TRIMMED_TAG_SQL} as tag, COUNT(*) as count @@ -71,10 +74,14 @@ tagsRoute.get("/suggest", authMiddleware, async (c) => { GROUP BY ${TRIMMED_TAG_SQL} ORDER BY count DESC, tag ASC LIMIT ?4 - `).bind(userId, org.id, normalizedQuery || "", TAG_SUGGEST_LIMIT).all(); - tags = (result.results || []).map((r: any) => r.tag); - } else { - const result = await c.env.DB.prepare(` + `, + ) + .bind(userId, org.id, normalizedQuery, TAG_SUGGEST_LIMIT) + .all(); + tags = (result.results || []).map((r: any) => r.tag); + } else { + const result = await c.env.DB.prepare( + ` SELECT ${TRIMMED_TAG_SQL} as tag, COUNT(*) as count @@ -88,11 +95,14 @@ tagsRoute.get("/suggest", authMiddleware, async (c) => { GROUP BY ${TRIMMED_TAG_SQL} ORDER BY count DESC, tag ASC LIMIT ?3 - `).bind(userId, normalizedQuery || "", TAG_SUGGEST_LIMIT).all(); - tags = (result.results || []).map((r: any) => r.tag); - } + `, + ) + .bind(userId, normalizedQuery, TAG_SUGGEST_LIMIT) + .all(); + tags = (result.results || []).map((r: any) => r.tag); + } - return c.json({ tags }); + return c.json({ tags }); }); export default tagsRoute; diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts index badc84a5..79c7597b 100644 --- a/apps/api/src/routes/users.ts +++ b/apps/api/src/routes/users.ts @@ -4,6 +4,7 @@ import { eq, or, and, ne, sql, type InferSelectModel } from "drizzle-orm"; import { users, enableForeignKeys, touchUpdatedAt } from "../db/schema"; import type { Env, Variables } from "../types"; import { authMiddleware } from "../middleware/auth"; +import { escapeLikeLiteral } from "../utils/sql"; const usersRoute = new Hono<{ Bindings: Env; Variables: Variables }>(); @@ -102,10 +103,6 @@ function setCachedResults(key: string, data: UserSearchResult[]) { searchCache.set(key, { data, timestamp: Date.now() }); } -function escapeLikeLiteral(str: string): string { - return str.replace(/[\\%_]/g, "\\$&"); -} - // GET /api/users/search?q=xxx — search users for coauthor invite usersRoute.get("/search", authMiddleware, async (c) => { const q = c.req.query("q"); @@ -136,7 +133,7 @@ usersRoute.get("/search", authMiddleware, async (c) => { and( or( sql`${users.name} LIKE ${searchPattern} ESCAPE '\\' COLLATE NOCASE`, - sql`${users.githubId} LIKE ${searchPattern} ESCAPE '\\' COLLATE NOCASE` + sql`${users.githubId} LIKE ${searchPattern} ESCAPE '\\' COLLATE NOCASE`, ), ne(users.id, currentUserId), ), diff --git a/apps/api/src/utils/__tests__/sql.test.ts b/apps/api/src/utils/__tests__/sql.test.ts new file mode 100644 index 00000000..59650201 --- /dev/null +++ b/apps/api/src/utils/__tests__/sql.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { escapeLikeLiteral } from "../sql"; + +describe("escapeLikeLiteral", () => { + it("escapes % character", () => { + expect(escapeLikeLiteral("hello%world")).toBe("hello\\%world"); + }); + + it("escapes _ character", () => { + expect(escapeLikeLiteral("hello_world")).toBe("hello\\_world"); + }); + + it("escapes \\ character", () => { + expect(escapeLikeLiteral("hello\\world")).toBe("hello\\\\world"); + }); + + it("escapes multiple special characters", () => { + expect(escapeLikeLiteral("%_\\%_\\")).toBe("\\%\\_\\\\\\%\\_\\\\"); + }); + + it("returns empty string if input is empty", () => { + expect(escapeLikeLiteral("")).toBe(""); + }); + + it("returns identical string if no special characters exist", () => { + expect(escapeLikeLiteral("hello world")).toBe("hello world"); + }); +}); diff --git a/apps/api/src/utils/sql.ts b/apps/api/src/utils/sql.ts new file mode 100644 index 00000000..f8f5bdc4 --- /dev/null +++ b/apps/api/src/utils/sql.ts @@ -0,0 +1,9 @@ +/** + * Escapes literal wildcard characters (%, _) and the escape character (\) for use in SQL LIKE clauses. + * Prevents wildcard injection which can lead to algorithmic complexity DoS. + * @param str The user input string to escape. + * @returns The escaped string. + */ +export function escapeLikeLiteral(str: string): string { + return str.replace(/[\\%_]/g, "\\$&"); +} diff --git a/apps/e2e/helpers/paper.ts b/apps/e2e/helpers/paper.ts index e26bd1da..0f478889 100644 --- a/apps/e2e/helpers/paper.ts +++ b/apps/e2e/helpers/paper.ts @@ -30,7 +30,7 @@ async function uploadPaper(page: Page, title: string, visibility: "public" | "pr response.request().method() === "POST", ); - await page.getByRole("button", { name: "論文をアップロードする" }).click(); + await page.getByRole("button", { name: "成果物をアップロードする" }).click(); const response = await uploadResponsePromise; if (!response.ok()) { throw new Error(`uploadPaper failed: ${response.status()} ${await response.text()}`); diff --git a/apps/e2e/tests/auth.spec.ts b/apps/e2e/tests/auth.spec.ts index b3636b20..c4bc95a7 100644 --- a/apps/e2e/tests/auth.spec.ts +++ b/apps/e2e/tests/auth.spec.ts @@ -13,7 +13,7 @@ test.describe('認証', () => { await loginAsTestUser(page, { name: 'Token User' }); await expect(page.getByText('Token User')).toBeVisible(); - await expect(page.getByRole('heading', { name: 'マイ論文' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'マイライブラリ' })).toBeVisible(); }); test('ログアウト操作後、localStorage からトークンが削除され、未認証状態に戻ること', async ({ page }) => { diff --git a/apps/e2e/tests/og.spec.ts b/apps/e2e/tests/og.spec.ts index 6fc0f334..71e56e3c 100644 --- a/apps/e2e/tests/og.spec.ts +++ b/apps/e2e/tests/og.spec.ts @@ -18,7 +18,7 @@ async function uploadPublicPaper(page: Page, title: string): Promise { response.request().method() === "POST", ); - await page.getByRole("button", { name: "論文をアップロードする" }).click(); + await page.getByRole("button", { name: "成果物をアップロードする" }).click(); const response = await uploadResponsePromise; expect(response.ok()).toBeTruthy(); diff --git a/apps/e2e/tests/papers.spec.ts b/apps/e2e/tests/papers.spec.ts index 4e203c1f..4b2fcab1 100644 --- a/apps/e2e/tests/papers.spec.ts +++ b/apps/e2e/tests/papers.spec.ts @@ -14,7 +14,7 @@ async function uploadPaper(page: Page, options: { title: string; visibility: 'pu response.url().includes('/api/papers') && response.request().method() === 'POST' ); - await page.getByRole('button', { name: '論文をアップロードする' }).click(); + await page.getByRole('button', { name: '成果物をアップロードする' }).click(); const response = await uploadResponsePromise; expect(response.ok()).toBeTruthy(); @@ -76,7 +76,7 @@ async function expectPdfPreviewRendered(page: Page, expectedTexts: string[]): Pr } test.describe('論文アップロード', () => { - test('認証済みユーザーが /upload ページからPDFをアップロードできること、アップロード後、トップページ(マイ論文一覧)に論文タイトルが表示されること', async ({ page }) => { + test('認証済みユーザーが /upload ページからPDFをアップロードできること、アップロード後、トップページ(マイライブラリ一覧)に成果物タイトルが表示されること', async ({ page }) => { const uniqueTitle = `テスト論文 - ${randomUUID()}`; await loginAsTestUser(page); @@ -86,7 +86,7 @@ test.describe('論文アップロード', () => { filePath: path.resolve(__dirname, '../fixtures/test-paper.pdf') }); - // マイ論文一覧(トップページ)へ + // マイライブラリ一覧(トップページ)へ await page.goto('/'); await expect(page.getByText(uniqueTitle)).toBeVisible(); @@ -161,7 +161,7 @@ test.describe('非公開論文の詳細閲覧', () => { await page.getByRole('link', { name: '← ダッシュボードに戻る' }).click(); await expect(page).toHaveURL(/\/$/); - await expect(page.getByRole('heading', { name: 'マイ論文' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'マイライブラリ' })).toBeVisible(); }); }); diff --git a/apps/e2e/tests/scenarios/auth-flow.spec.ts b/apps/e2e/tests/scenarios/auth-flow.spec.ts index 523f3eb9..618a96bb 100644 --- a/apps/e2e/tests/scenarios/auth-flow.spec.ts +++ b/apps/e2e/tests/scenarios/auth-flow.spec.ts @@ -10,7 +10,7 @@ test.describe('Auth Flow', () => { await expect(page.locator('header')).toContainText(user.name); await page.goto('/upload'); - await expect(page.getByRole('heading', { name: "論文アップロード" })).toBeVisible(); + await expect(page.getByRole('heading', { name: "成果物アップロード" })).toBeVisible(); // トークンが localStorage に存在すること const token = await page.evaluate(() => localStorage.getItem('auth_token')); diff --git a/apps/e2e/tests/scenarios/cross-feature.spec.ts b/apps/e2e/tests/scenarios/cross-feature.spec.ts index a4262fde..0413fc21 100644 --- a/apps/e2e/tests/scenarios/cross-feature.spec.ts +++ b/apps/e2e/tests/scenarios/cross-feature.spec.ts @@ -17,8 +17,8 @@ test.describe('Cross Feature', () => { await uploadPublicPaper(page, paperTitle); await page.goto(`/orgs/${orgSlug}/settings`); - await page.getByRole('button', { name: '論文' }).click(); - await page.getByPlaceholder(/論文タイトルで検索/).fill(paperTitle); + await page.getByRole('button', { name: '成果物' }).click(); + await page.getByPlaceholder(/成果物タイトルで検索/).fill(paperTitle); const resultRow = page.locator('li', { hasText: paperTitle }).first(); await resultRow.getByRole('button', { name: '追加' }).click(); diff --git a/apps/e2e/tests/scenarios/org-management.spec.ts b/apps/e2e/tests/scenarios/org-management.spec.ts index 6ffc8f70..a829328d 100644 --- a/apps/e2e/tests/scenarios/org-management.spec.ts +++ b/apps/e2e/tests/scenarios/org-management.spec.ts @@ -28,14 +28,14 @@ test.describe('Org Management', () => { const paperTitle = generateTestPaperTitle(); await uploadPublicPaper(page, paperTitle); - // orgに論文を紐づけ + // orgに成果物を紐づけ await page.goto(`/orgs/${orgSlug}/settings`); - await page.getByRole('button', { name: '論文' }).click(); - await page.getByPlaceholder(/論文タイトルで検索/).fill(paperTitle); + await page.getByRole('button', { name: '成果物' }).click(); + await page.getByPlaceholder(/成果物タイトルで検索/).fill(paperTitle); const resultRow = page.locator('li', { hasText: paperTitle }).first(); await resultRow.getByRole('button', { name: '追加' }).click(); - // org ページの論文一覧に反映されること + // org ページの成果物一覧に反映されること await page.goto(`/orgs/${orgSlug}`); await expect(page.getByRole('link', { name: paperTitle })).toBeVisible(); }); diff --git a/apps/e2e/tests/scenarios/upload-validation.spec.ts b/apps/e2e/tests/scenarios/upload-validation.spec.ts index 9162fa11..7b892b00 100644 --- a/apps/e2e/tests/scenarios/upload-validation.spec.ts +++ b/apps/e2e/tests/scenarios/upload-validation.spec.ts @@ -18,7 +18,7 @@ test.describe('Upload Validation', () => { 'input[type="file"]', path.resolve(__dirname, '../../fixtures/test-paper.pdf'), ); - await page.getByRole('button', { name: '論文をアップロードする' }).click(); + await page.getByRole('button', { name: '成果物をアップロードする' }).click(); await expect(page.getByText('タイトルは必須です')).toBeVisible(); }); @@ -31,7 +31,7 @@ test.describe('Upload Validation', () => { }); await page.getByLabel(/タイトル/).fill(`Validation ${randomUUID().slice(0, 8)}`); - await page.getByRole('button', { name: '論文をアップロードする' }).click(); + await page.getByRole('button', { name: '成果物をアップロードする' }).click(); await expect(page.getByText('ファイルを1つ以上添付してください')).toBeVisible(); }); diff --git a/apps/web/src/app/__tests__/collections-new.test.tsx b/apps/web/src/app/__tests__/collections-new.test.tsx index 9c39305d..3dc41913 100644 --- a/apps/web/src/app/__tests__/collections-new.test.tsx +++ b/apps/web/src/app/__tests__/collections-new.test.tsx @@ -82,13 +82,61 @@ describe("NewCollectionPage", () => { vi.useRealTimers(); fireEvent.click(screen.getByRole("button", { name: "作成" })); - expect(screen.getByRole("button", { name: /作成中/ })).toBeInTheDocument(); + await waitFor(() => { + expect(push).toHaveBeenCalledWith("/users/user-1/c/lab-picks"); + }); + }); + + it("shows a spinner while creating a collection", async () => { + vi.useFakeTimers(); + let resolveCreate!: (value: Response) => void; + vi.mocked(apiFetch).mockImplementation((url, init) => { + if (url === "/api/users/user-1/collections") { + return Promise.resolve( + new Response(JSON.stringify({ collections: [] }), { + status: 200, + }), + ); + } + + if (url === "/api/collections" && init?.method === "POST") { + return new Promise((resolve) => { + resolveCreate = resolve; + }); + } + + return Promise.reject(new Error(`Unexpected request: ${String(url)}`)); + }); + + const { container } = render(); + + fireEvent.change(screen.getByLabelText("name"), { + target: { value: "Lab Picks" }, + }); + + await act(async () => { + vi.advanceTimersByTime(400); + await Promise.resolve(); + }); + + expect(screen.getByText("✓ 使用可能")).toBeInTheDocument(); + + vi.useRealTimers(); + fireEvent.click(screen.getByRole("button", { name: "作成" })); + + expect( + await screen.findByRole("button", { name: "作成中..." }), + ).toBeDisabled(); expect( - screen - .getByRole("button", { name: /作成中/ }) - .querySelector(".animate-spin"), + container.querySelector('[aria-hidden="true"].animate-spin'), ).toBeInTheDocument(); + resolveCreate( + new Response(JSON.stringify({ collection: { slug: "lab-picks" } }), { + status: 201, + }), + ); + await waitFor(() => { expect(push).toHaveBeenCalledWith("/users/user-1/c/lab-picks"); }); diff --git a/apps/web/src/app/__tests__/home.test.tsx b/apps/web/src/app/__tests__/home.test.tsx index 87ea2334..3224fde5 100644 --- a/apps/web/src/app/__tests__/home.test.tsx +++ b/apps/web/src/app/__tests__/home.test.tsx @@ -46,9 +46,17 @@ describe("Home page", () => { expect( screen.getByRole("heading", { - name: /研究成果物を保存し、共有する。/, + name: /研究データを一箇所に/, }), ).toBeInTheDocument(); + + // Add additional assertions to increase coverage on page.tsx guest view + expect(screen.getByText(/Research output hosting/)).toBeInTheDocument(); + expect(screen.getByText(/OpenShelf は、論文・スライド・補足資料などをまとめて管理し/)).toBeInTheDocument(); + expect(screen.getByText(/1\. 成果物をまとめる/)).toBeInTheDocument(); + expect(screen.getByText(/2\. 公開範囲を選ぶ/)).toBeInTheDocument(); + expect(screen.getByText(/3\. 永続URLで共有/)).toBeInTheDocument(); + expect(login).toHaveBeenCalledTimes(1); }); @@ -65,7 +73,7 @@ describe("Home page", () => { render(); await waitFor(() => { - expect(screen.getByText("まだ論文がありません")).toBeInTheDocument(); + expect(screen.getByText("まだ成果物がありません")).toBeInTheDocument(); }); expect(screen.getAllByText("0")).toHaveLength(3); @@ -122,4 +130,23 @@ describe("Home page", () => { expect(paperLinks[1]).toHaveTextContent("Older paper"); expect(screen.getAllByText("公開")).toHaveLength(1); }); + + + it("handles apiFetch returning not ok", async () => { + authState = { user: { id: "user-1", name: "Alice" }, loading: false, login }; + vi.mocked(apiFetch).mockResolvedValue(new Response("error", { status: 500 })); + render(); + await waitFor(() => { + expect(screen.getByText("まだ成果物がありません")).toBeInTheDocument(); + }); + }); + + it("handles apiFetch error gracefully", async () => { + authState = { user: { id: "user-1", name: "Alice" }, loading: false, login }; + vi.mocked(apiFetch).mockRejectedValue(new Error("Network error")); + render(); + await waitFor(() => { + expect(screen.getByText("まだ成果物がありません")).toBeInTheDocument(); + }); + }); }); diff --git a/apps/web/src/app/__tests__/orgs-new.test.tsx b/apps/web/src/app/__tests__/orgs-new.test.tsx index 19c2bfe0..35916f72 100644 --- a/apps/web/src/app/__tests__/orgs-new.test.tsx +++ b/apps/web/src/app/__tests__/orgs-new.test.tsx @@ -79,13 +79,57 @@ describe("NewOrgPage", () => { vi.useRealTimers(); fireEvent.click(screen.getByRole("button", { name: "作成" })); - expect(screen.getByRole("button", { name: /作成中/ })).toBeInTheDocument(); + await waitFor(() => { + expect(push).toHaveBeenCalledWith("/orgs/research-lab"); + }); + }); + + it("shows a spinner while creating an org", async () => { + vi.useFakeTimers(); + let resolveCreate!: (value: Response) => void; + vi.mocked(apiFetch).mockImplementation((url, init) => { + if (url === "/api/orgs/research-lab") { + return Promise.resolve(new Response("{}", { status: 404 })); + } + + if (url === "/api/orgs" && init?.method === "POST") { + return new Promise((resolve) => { + resolveCreate = resolve; + }); + } + + return Promise.reject(new Error(`Unexpected request: ${String(url)}`)); + }); + + const { container } = render(); + + fireEvent.change(screen.getByLabelText(/組織名/i), { + target: { value: "Research Lab" }, + }); + + await act(async () => { + vi.advanceTimersByTime(400); + await Promise.resolve(); + }); + + expect(screen.getByText("✓ 使用可能")).toBeInTheDocument(); + + vi.useRealTimers(); + fireEvent.click(screen.getByRole("button", { name: "作成" })); + expect( - screen - .getByRole("button", { name: /作成中/ }) - .querySelector(".animate-spin"), + await screen.findByRole("button", { name: "作成中..." }), + ).toBeDisabled(); + expect( + container.querySelector('[aria-hidden="true"].animate-spin'), ).toBeInTheDocument(); + resolveCreate( + new Response(JSON.stringify({ org: { slug: "research-lab" } }), { + status: 201, + }), + ); + await waitFor(() => { expect(push).toHaveBeenCalledWith("/orgs/research-lab"); }); diff --git a/apps/web/src/app/__tests__/paper-edit-page.test.tsx b/apps/web/src/app/__tests__/paper-edit-page.test.tsx index 03cb5293..3d08fb10 100644 --- a/apps/web/src/app/__tests__/paper-edit-page.test.tsx +++ b/apps/web/src/app/__tests__/paper-edit-page.test.tsx @@ -1,10 +1,4 @@ -import { - cleanup, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import PaperEditPage from "../papers/[id]/edit/page"; import { apiFetch } from "@/lib/api"; @@ -95,21 +89,17 @@ describe("PaperEditPage", () => { target: { value: "Updated title" }, }); - expect( - screen.getByText(`${"Updated title".length}/300`), - ).toBeInTheDocument(); + expect(screen.getByText(`${"Updated title".length}/300`)).toBeInTheDocument(); fireEvent.change(screen.getByLabelText(/概要/i), { target: { value: "Updated abstract" }, }); - expect( - screen.getByText(`${"Updated abstract".length}/5000`), - ).toBeInTheDocument(); + expect(screen.getByText(`${"Updated abstract".length}/5000`)).toBeInTheDocument(); fireEvent.click(screen.getByLabelText(/公開ページに閲覧数を表示する/i)); fireEvent.change(screen.getByLabelText(/タグ/i), { - target: { value: "AI, LLM" }, + target: { value: "AI,LLM、CV" }, }); fireEvent.change(screen.getByLabelText(/Description/i), { target: { value: "## Updated description" }, @@ -138,13 +128,6 @@ describe("PaperEditPage", () => { fireEvent.click(screen.getByRole("button", { name: "保存する" })); - expect(screen.getByRole("button", { name: /保存中/ })).toBeInTheDocument(); - expect( - screen - .getByRole("button", { name: /保存中/ }) - .querySelector(".animate-spin"), - ).toBeInTheDocument(); - await waitFor(() => { expect(apiFetch).toHaveBeenCalledWith( "/api/papers/paper-1", @@ -158,8 +141,7 @@ describe("PaperEditPage", () => { const patchCall = vi .mocked(apiFetch) .mock.calls.find( - ([url, init]) => - url === "/api/papers/paper-1" && init?.method === "PATCH", + ([url, init]) => url === "/api/papers/paper-1" && init?.method === "PATCH", ); const patchBody = JSON.parse((patchCall?.[1]?.body as string) ?? "{}"); expect(patchBody).toMatchObject({ @@ -174,13 +156,75 @@ describe("PaperEditPage", () => { venueType: "journal", year: 2026, category: "presentation", - tags: ["AI", "LLM"], + tags: ["AI", "LLM", "CV"], }); expect(push).toHaveBeenCalledWith("/papers/paper-1"); expect(refresh).toHaveBeenCalledTimes(1); }); + it("shows a spinner while saving edits", async () => { + let resolvePatch!: (value: Response) => void; + vi.mocked(apiFetch).mockImplementation((url, init) => { + if (url === "/api/papers/paper-1" && !init?.method) { + return Promise.resolve( + new Response( + JSON.stringify({ + paper: { + title: "Original title", + abstract: "Original abstract", + description: "## Original description", + visibility: "private", + showViewCount: false, + language: "ja", + externalUrl: null, + doi: null, + venue: "Conference", + venueType: "conference", + year: 2025, + category: "report", + tags: JSON.stringify(["AI"]), + }, + authors: [{ userId: "user-1" }], + }), + { status: 200 }, + ), + ); + } + + if (url === "/api/papers/paper-1" && init?.method === "PATCH") { + return new Promise((resolve) => { + resolvePatch = resolve; + }); + } + + if (url === "/api/papers/paper-1/description" && init?.method === "PUT") { + return Promise.resolve(new Response("{}", { status: 200 })); + } + + return Promise.reject(new Error(`Unexpected request: ${String(url)}`)); + }); + + const { container } = render(); + + await screen.findByDisplayValue("Original title"); + + fireEvent.click(screen.getByRole("button", { name: "保存する" })); + + expect( + await screen.findByRole("button", { name: "保存中..." }), + ).toBeDisabled(); + expect( + container.querySelector('[aria-hidden="true"].animate-spin'), + ).toBeInTheDocument(); + + resolvePatch(new Response("{}", { status: 200 })); + + await waitFor(() => { + expect(push).toHaveBeenCalledWith("/papers/paper-1"); + }); + }); + it("redirects non-authors back to the paper page", async () => { Object.defineProperty(window, "location", { value: { assign: vi.fn() }, @@ -243,7 +287,7 @@ describe("PaperEditPage", () => { render(); expect( - await screen.findByText("論文の取得に失敗しました"), + await screen.findByText("成果物の取得に失敗しました"), ).toBeInTheDocument(); }); @@ -277,19 +321,14 @@ describe("PaperEditPage", () => { render(); expect(await screen.findByDisplayValue("Initial")).toBeInTheDocument(); - fireEvent.change(screen.getByLabelText(/タイトル/i), { - target: { value: " " }, - }); + fireEvent.change(screen.getByLabelText(/タイトル/i), { target: { value: " " } }); fireEvent.click(screen.getByRole("button", { name: "保存する" })); - expect( - await screen.findByText("タイトルを入力してください。"), - ).toBeInTheDocument(); + expect(await screen.findByText("タイトルを入力してください。")).toBeInTheDocument(); const patchCalls = vi .mocked(apiFetch) .mock.calls.filter( - ([url, req]) => - url === "/api/papers/paper-1" && req?.method === "PATCH", + ([url, req]) => url === "/api/papers/paper-1" && req?.method === "PATCH", ); expect(patchCalls).toHaveLength(0); }); @@ -324,17 +363,14 @@ describe("PaperEditPage", () => { render(); expect(await screen.findByDisplayValue("Initial")).toBeInTheDocument(); - fireEvent.change(screen.getByLabelText(/発表年/i), { - target: { value: "" }, - }); + fireEvent.change(screen.getByLabelText(/発表年/i), { target: { value: "" } }); fireEvent.click(screen.getByRole("button", { name: "保存する" })); const patchCall = await waitFor(() => vi .mocked(apiFetch) .mock.calls.find( - ([url, req]) => - url === "/api/papers/paper-1" && req?.method === "PATCH", + ([url, req]) => url === "/api/papers/paper-1" && req?.method === "PATCH", ), ); const patchBody = JSON.parse((patchCall?.[1]?.body as string) ?? "{}"); @@ -474,8 +510,7 @@ describe("PaperEditPage", () => { vi .mocked(apiFetch) .mock.calls.find( - ([url, req]) => - url === "/api/papers/paper-1" && req?.method === "PATCH", + ([url, req]) => url === "/api/papers/paper-1" && req?.method === "PATCH", ), ); const patchBody = JSON.parse((patchCall?.[1]?.body as string) ?? "{}"); diff --git a/apps/web/src/app/__tests__/settings-page.test.tsx b/apps/web/src/app/__tests__/settings-page.test.tsx index 1fc63623..63b6335b 100644 --- a/apps/web/src/app/__tests__/settings-page.test.tsx +++ b/apps/web/src/app/__tests__/settings-page.test.tsx @@ -59,13 +59,6 @@ describe("SettingsPage", () => { }); fireEvent.click(screen.getByRole("button", { name: "保存" })); - expect(screen.getByRole("button", { name: /保存中/ })).toBeInTheDocument(); - expect( - screen - .getByRole("button", { name: /保存中/ }) - .querySelector(".animate-spin"), - ).toBeInTheDocument(); - await waitFor(() => { expect(apiFetch).toHaveBeenCalledWith( "/api/users/me", @@ -80,6 +73,30 @@ describe("SettingsPage", () => { expect(refresh).toHaveBeenCalledTimes(1); }); + it("shows a spinner while saving the display name", async () => { + let resolveSave!: (value: Response) => void; + vi.mocked(apiFetch).mockReturnValue( + new Promise((resolve) => { + resolveSave = resolve; + }), + ); + + const { container } = render(); + + fireEvent.click(screen.getByRole("button", { name: "保存" })); + + expect( + await screen.findByRole("button", { name: "保存中..." }), + ).toBeDisabled(); + expect( + container.querySelector('[aria-hidden="true"].animate-spin'), + ).toBeInTheDocument(); + + resolveSave(new Response("{}", { status: 200 })); + + expect(await screen.findByText("保存しました")).toBeInTheDocument(); + }); + it("shows non-JSON error responses", async () => { vi.mocked(apiFetch).mockResolvedValue( new Response("Plain text error", { status: 400 }), @@ -187,9 +204,7 @@ describe("SettingsPage", () => { it("shows github username in preview when display name is blank", () => { render(); - fireEvent.change(screen.getByLabelText("表示名"), { - target: { value: " " }, - }); + fireEvent.change(screen.getByLabelText("表示名"), { target: { value: " " } }); expect(screen.getByText("alice")).toBeInTheDocument(); }); diff --git a/apps/web/src/app/collections/new/page.tsx b/apps/web/src/app/collections/new/page.tsx index af5b01fb..244563b2 100644 --- a/apps/web/src/app/collections/new/page.tsx +++ b/apps/web/src/app/collections/new/page.tsx @@ -332,19 +332,15 @@ export default function NewCollectionPage() { slugStatus === "taken" || slugStatus === "invalid" } - className="rounded-md bg-gray-900 px-4 py-2 text-sm text-white hover:bg-gray-700 disabled:opacity-50 dark:bg-white dark:text-gray-900 dark:hover:bg-gray-200" + className="inline-flex items-center justify-center gap-2 rounded-md bg-gray-900 px-4 py-2 text-sm text-white hover:bg-gray-700 disabled:opacity-50 dark:bg-white dark:text-gray-900 dark:hover:bg-gray-200" > - {submitting ? ( - - - ) : ( - "作成" + {submitting && ( +