@aiquants/auth-react-router 0.6.0 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,307 +0,0 @@
1
- import { fireEvent, render, screen, waitFor } from "@testing-library/react"
2
- import { createRoutesStub } from "react-router"
3
- import { describe, expect, it, vi } from "vitest"
4
- import type { UserAdminLabels } from "./labels"
5
- import { defaultUserAdminLabels, resolveUserAdminLabels } from "./labels"
6
- import { createInMemoryUserAdminStore } from "./server"
7
- import { UserAdminShell } from "./ui"
8
- import { AllowlistView, AuthUserAdminAppView, GroupMembersView, GroupsView, UsersView } from "./views"
9
-
10
- /** 1 回のフォーム送信内容(intent + 各フィールド)。 */
11
- type Submission = Record<string, string>
12
-
13
- /**
14
- * Mounts a view under a route stub and records every form submission it makes.
15
- * ビューをルートスタブ配下にマウントし、送信された全フォーム内容を記録する処理。
16
- *
17
- * 記録配列を返すことで「クリックしたら何が送信されたか」をテストで検証できる
18
- * (送信内容を観測しないとハンドラを壊しても気づけないため)。
19
- */
20
- function mount(path: string, Component: React.ComponentType<Record<string, unknown>>, loader: () => unknown, props = {}): Submission[] {
21
- const submissions: Submission[] = []
22
- const action = async ({ request }: { request: Request }) => {
23
- const fd = await request.formData()
24
- const entry: Submission = {}
25
- for (const [k, v] of fd.entries()) entry[k] = String(v)
26
- submissions.push(entry)
27
- return { ok: true }
28
- }
29
- const Stub = createRoutesStub([{ path: "/users/*", Component: () => <Component {...props} />, loader, action }])
30
- render(<Stub initialEntries={[path]} />)
31
- return submissions
32
- }
33
-
34
- describe("UserAdminShell & UI Components", () => {
35
- it("renders default header when renderHeader is omitted and supports tab counts", async () => {
36
- const customLabels = resolveUserAdminLabels({ title: "カスタム管理", heading: "カスタム注釈", tabs: { users: "ユーザー", groups: "グループ", groupMembers: "所属", allowlist: "許可" } })
37
- mount(
38
- "/users",
39
- () => (
40
- <UserAdminShell
41
- title="カスタム管理"
42
- annotation={<span>カスタム注釈</span>}
43
- activeTab="users"
44
- onTabChange={() => {}}
45
- tabs={[
46
- { id: "users", label: "ユーザー", count: 5 },
47
- { id: "groups", label: "グループ" },
48
- ]}>
49
- <div>コンテンツ</div>
50
- </UserAdminShell>
51
- ),
52
- () => ({ segment: "users", labels: customLabels }),
53
- )
54
-
55
- expect(await screen.findByText("カスタム管理")).toBeDefined()
56
- expect(screen.getByText("カスタム注釈")).toBeDefined()
57
- })
58
- })
59
-
60
- describe("AuthUserAdminAppView & Views Coverage Suite", () => {
61
- it("renders AuthUserAdminAppView with custom renderHeader prop and custom labels", async () => {
62
- const store = createInMemoryUserAdminStore()
63
- const customLabels: UserAdminLabels = {
64
- title: "ユーザー & 組織管理",
65
- annotation: "各種設定",
66
- tabs: { users: "ユーザー", groups: "グループ", groupMembers: "所属", allowlist: "許可リスト" },
67
- usersView: { title: "U", addUser: "追加", editUser: "編集", searchPlaceholder: "検索...", displayName: "名前", email: "メール", status: "状態", active: "有効", inactive: "無効", actions: "操作" },
68
- groupsView: { title: "G", addGroup: "グループ追加", editGroup: "グループ編集", groupKey: "キー", name: "名前", description: "説明", membersCount: "名", actions: "操作" },
69
- groupMembersView: { title: "GM", selectGroup: "選択", currentMembers: "メンバー", member: "名", nonMember: "非", actionAdd: "追加", actionRemove: "除外" },
70
- allowlistView: { title: "AL", addEntry: "パターン追加", pattern: "パターン", type: "種別", description: "説明", actions: "操作" },
71
- }
72
-
73
- const data = {
74
- segment: "users",
75
- users: await store.listUsers(),
76
- groups: await store.listGroups(),
77
- groupMembers: await store.listGroupMembers(),
78
- allowlist: await store.listAllowlist(),
79
- labels: customLabels,
80
- }
81
-
82
- mount("/users", AuthUserAdminAppView, () => data, {
83
- renderHeader: ({ title }: { title: string }) => <h1 data-testid="custom-header-heading">{title} (Custom)</h1>,
84
- })
85
-
86
- expect(await screen.findByTestId("custom-header-heading")).toBeDefined()
87
- expect(screen.getByTestId("user-admin-shell")).toBeDefined()
88
- })
89
-
90
- it("handles user create and edit modals, search filtering, and active toggle in UsersView", async () => {
91
- const store = createInMemoryUserAdminStore({
92
- users: [
93
- { id: 1, email: "alpha@example.com", displayName: "Alpha User", isActive: true },
94
- { id: 2, email: "beta@example.com", displayName: "Beta User", isActive: false },
95
- ],
96
- })
97
- const data = {
98
- segment: "users",
99
- users: await store.listUsers(),
100
- groups: [],
101
- groupMembers: [],
102
- allowlist: [],
103
- }
104
-
105
- mount("/users", UsersView, () => data)
106
-
107
- expect(await screen.findByTestId("users-view-section")).toBeDefined()
108
-
109
- // Test search filter
110
- const searchInput = screen.getByTestId("users-search-input")
111
- fireEvent.change(searchInput, { target: { value: "Alpha" } })
112
- expect(screen.getByTestId("user-row-1")).toBeDefined()
113
- expect(screen.queryByTestId("user-row-2")).toBeNull()
114
-
115
- // Clear search filter
116
- fireEvent.change(searchInput, { target: { value: "" } })
117
- expect(screen.getByTestId("user-row-2")).toBeDefined()
118
-
119
- // Open create modal & test cancel
120
- const addBtn = screen.getByTestId("users-add-button")
121
- fireEvent.click(addBtn)
122
- expect(screen.getByTestId("user-create-modal")).toBeDefined()
123
-
124
- // Empty submit guard
125
- const createForm = screen.getByTestId("user-create-modal").querySelector("form")
126
- if (createForm) fireEvent.submit(createForm)
127
-
128
- const cancelCreateBtn = screen.getByText("キャンセル")
129
- fireEvent.click(cancelCreateBtn)
130
- expect(screen.queryByTestId("user-create-modal")).toBeNull()
131
-
132
- // Re-open create modal & submit
133
- fireEvent.click(addBtn)
134
- const createName = screen.getByTestId("create-user-display-name")
135
- const createEmail = screen.getByTestId("create-user-email")
136
- fireEvent.change(createName, { target: { value: "New User" } })
137
- fireEvent.change(createEmail, { target: { value: "new@example.com" } })
138
-
139
- const submitCreate = screen.getByTestId("submit-create-user")
140
- fireEvent.click(submitCreate)
141
-
142
- // Open edit modal & test cancel
143
- const editBtn = screen.getByTestId("edit-user-btn-1")
144
- fireEvent.click(editBtn)
145
- expect(screen.getByTestId("user-edit-modal")).toBeDefined()
146
-
147
- const cancelEditBtn = screen.getByTestId("user-edit-modal").querySelector('button[type="button"]')
148
- if (cancelEditBtn) fireEvent.click(cancelEditBtn)
149
- expect(screen.queryByTestId("user-edit-modal")).toBeNull()
150
-
151
- // Re-open edit modal, test empty submit guard, fill & submit
152
- fireEvent.click(editBtn)
153
- const editNameInput = screen.getByTestId("edit-user-display-name")
154
- fireEvent.change(editNameInput, { target: { value: "" } })
155
- fireEvent.click(screen.getByTestId("submit-edit-user"))
156
-
157
- fireEvent.change(editNameInput, { target: { value: "Alpha Updated" } })
158
- fireEvent.click(screen.getByTestId("submit-edit-user"))
159
-
160
- // Toggle active status
161
- const toggleBtn = screen.getByTestId("toggle-user-btn-1")
162
- fireEvent.click(toggleBtn)
163
- })
164
-
165
- it("handles group create, edit modals, and delete confirmations in GroupsView", async () => {
166
- const store = createInMemoryUserAdminStore({
167
- groups: [{ id: 1, groupKey: "dev-team", name: "開発チーム", description: "開発用", isActive: true }],
168
- })
169
- const data = {
170
- segment: "groups",
171
- users: [],
172
- groups: await store.listGroups(),
173
- groupMembers: [],
174
- allowlist: [],
175
- }
176
-
177
- const submissions = mount("/users/groups", GroupsView, () => data)
178
-
179
- expect(await screen.findByTestId("groups-view-section")).toBeDefined()
180
-
181
- // Create group modal open & test cancel
182
- const addBtn = screen.getByTestId("groups-add-button")
183
- fireEvent.click(addBtn)
184
- expect(screen.getByTestId("group-create-modal")).toBeDefined()
185
-
186
- // Empty submit guard
187
- const groupForm = screen.getByTestId("group-create-modal").querySelector("form")
188
- if (groupForm) fireEvent.submit(groupForm)
189
-
190
- const cancelGroupCreateBtn = screen.getByTestId("group-create-modal").querySelector('button[type="button"]')
191
- if (cancelGroupCreateBtn) fireEvent.click(cancelGroupCreateBtn)
192
- expect(screen.queryByTestId("group-create-modal")).toBeNull()
193
-
194
- // Re-open create group, fill & submit
195
- fireEvent.click(addBtn)
196
- fireEvent.change(screen.getByTestId("create-group-key"), { target: { value: "design" } })
197
- fireEvent.change(screen.getByTestId("create-group-name"), { target: { value: "デザインチーム" } })
198
- fireEvent.change(screen.getByTestId("create-group-description"), { target: { value: "UI/UX" } })
199
-
200
- const newGroupForm = screen.getByTestId("group-create-modal").querySelector("form")
201
- if (newGroupForm) fireEvent.submit(newGroupForm)
202
-
203
- // Edit group modal open & test cancel
204
- fireEvent.click(screen.getByTestId("edit-group-btn-1"))
205
- expect(screen.getByTestId("group-edit-modal")).toBeDefined()
206
-
207
- const cancelGroupEditBtn = screen.getByTestId("group-edit-modal").querySelector('button[type="button"]')
208
- if (cancelGroupEditBtn) fireEvent.click(cancelGroupEditBtn)
209
- expect(screen.queryByTestId("group-edit-modal")).toBeNull()
210
-
211
- // Re-open edit group, fill & submit (including empty name submit guard)
212
- fireEvent.click(screen.getByTestId("edit-group-btn-1"))
213
- const editGroupName = screen.getByTestId("edit-group-name")
214
- fireEvent.change(editGroupName, { target: { value: "" } })
215
- fireEvent.click(screen.getByTestId("submit-edit-group"))
216
-
217
- fireEvent.change(editGroupName, { target: { value: "開発・インフラチーム" } })
218
- fireEvent.change(screen.getByTestId("edit-group-description"), { target: { value: "Dev & Ops" } })
219
- fireEvent.click(screen.getByTestId("submit-edit-group"))
220
-
221
- // 削除確認ダイアログ: キャンセル → 送信されない / OK → 送信される。
222
- // 「キャンセル後の非送信」を単独で待つと非同期送信が届く前に真になってしまうため、
223
- // キャンセル→OK と続けて操作し、最終的な deleteGroup 送信が **1 回だけ** であることで
224
- // ガードの有無を判定する (ガードを外すと 2 回になり落ちる)。
225
- const confirmSpy = vi.spyOn(window, "confirm")
226
- confirmSpy.mockReturnValueOnce(false)
227
- fireEvent.click(screen.getByTestId("delete-group-btn-1"))
228
- confirmSpy.mockReturnValueOnce(true)
229
- fireEvent.click(screen.getByTestId("delete-group-btn-1"))
230
-
231
- await waitFor(() => expect(submissions.filter((s) => s.intent === "deleteGroup")).toHaveLength(1))
232
- expect(submissions.find((s) => s.intent === "deleteGroup")).toEqual(expect.objectContaining({ intent: "deleteGroup", groupId: "1" }))
233
- confirmSpy.mockRestore()
234
- })
235
-
236
- it("handles GroupMembersView toggle buttons and empty group list edge case", async () => {
237
- const store = createInMemoryUserAdminStore({
238
- users: [{ id: 1, email: "u1@example.com", displayName: "User One", isActive: true }],
239
- groups: [{ id: 10, groupKey: "team-a", name: "Team A", isActive: true }],
240
- groupMembers: [{ id: 100, groupId: 10, userId: 1 }],
241
- })
242
- const data = {
243
- segment: "group_members",
244
- users: await store.listUsers(),
245
- groups: await store.listGroups(),
246
- groupMembers: await store.listGroupMembers(),
247
- allowlist: [],
248
- }
249
-
250
- const submissions = mount("/users/group_members", GroupMembersView, () => data)
251
-
252
- expect(await screen.findByTestId("group-members-view-section")).toBeDefined()
253
-
254
- // Select group
255
- fireEvent.click(screen.getByTestId("select-group-btn-10"))
256
-
257
- // Toggle member removal → 既存メンバーなので removeGroupMember が送信されること
258
- fireEvent.click(screen.getByTestId("toggle-member-btn-1"))
259
- await waitFor(() => expect(submissions.at(-1)).toEqual(expect.objectContaining({ intent: "removeGroupMember", groupId: "10", userId: "1" })))
260
- })
261
-
262
- it("renders empty group state message in GroupMembersView when no groups exist", async () => {
263
- const data = {
264
- segment: "group_members",
265
- users: [],
266
- groups: [],
267
- groupMembers: [],
268
- allowlist: [],
269
- }
270
-
271
- mount("/users/group_members", GroupMembersView, () => data)
272
-
273
- expect(await screen.findByTestId("group-members-view-section")).toBeDefined()
274
- expect(screen.getByText(defaultUserAdminLabels.groupMembersView.selectPrompt)).toBeDefined()
275
- })
276
-
277
- it("handles AllowlistView form inputs, submit guards, and entry deletion", async () => {
278
- const store = createInMemoryUserAdminStore({
279
- allowlist: [{ id: 1, pattern: "@company.com", type: "domain", description: "Company Domain" }],
280
- })
281
- const data = {
282
- segment: "allowlist",
283
- users: [],
284
- groups: [],
285
- groupMembers: [],
286
- allowlist: await store.listAllowlist(),
287
- }
288
-
289
- mount("/users/allowlist", AllowlistView, () => data)
290
-
291
- expect(await screen.findByTestId("allowlist-view-section")).toBeDefined()
292
-
293
- // Empty submit guard
294
- const allowlistForm = screen.getByTestId("allowlist-view-section").querySelector("form")
295
- if (allowlistForm) fireEvent.submit(allowlistForm)
296
-
297
- // Add entry form fill
298
- fireEvent.change(screen.getByTestId("allowlist-pattern-input"), { target: { value: "special@domain.com" } })
299
- fireEvent.change(screen.getByTestId("allowlist-type-select"), { target: { value: "email" } })
300
- fireEvent.change(screen.getByTestId("allowlist-desc-input"), { target: { value: "特別ユーザー" } })
301
-
302
- fireEvent.click(screen.getByTestId("submit-allowlist-add"))
303
-
304
- // Delete allowlist entry
305
- fireEvent.click(screen.getByTestId("remove-allowlist-btn-1"))
306
- })
307
- })
@@ -1,152 +0,0 @@
1
- /**
2
- * Tests for the per-request loader guard (warmup, photo cache, refresh cookie).
3
- * 毎リクエストローダーガード (ウォームアップ・写真キャッシュ・リフレッシュ cookie) のテスト。
4
- */
5
- import type { SessionProfile } from "@aiquants/auth-core"
6
- import { describe, expect, it, vi } from "vitest"
7
- import { createAuthenticate } from "./authenticate"
8
- import { createAuthenticateInLoader } from "./authenticate-in-loader"
9
- import type { AuthServerConfig, LoggerPort } from "./ports"
10
- import { createAuthSessionStorage } from "./session"
11
-
12
- const silentLogger: LoggerPort = { info: () => {}, warn: () => {}, error: () => {} }
13
-
14
- const profile: SessionProfile = {
15
- id: "openid-1",
16
- displayName: "User",
17
- name: { familyName: "F", givenName: "G" },
18
- emails: [{ value: "u@example.com" }],
19
- accessToken: "at",
20
- refreshToken: "rt",
21
- expirationDateMs: Date.now() + 3600000,
22
- provider: "google",
23
- }
24
-
25
- const setup = (overrides?: Partial<AuthServerConfig>) => {
26
- const config: AuthServerConfig = {
27
- google: { clientId: "cid", clientSecret: "csec", redirectURI: "http://localhost/cb" },
28
- session: { secrets: ["test-secret"] },
29
- isEmailAllowed: () => true,
30
- logger: silentLogger,
31
- ...overrides,
32
- }
33
- const storage = createAuthSessionStorage(config.session, { mockUser: config.mockUser })
34
- const authFns = createAuthenticate(config, storage, silentLogger)
35
- const authenticateInLoader = createAuthenticateInLoader(config, storage, authFns, silentLogger)
36
- return { config, storage, authenticateInLoader }
37
- }
38
-
39
- describe("authenticateInLoader", () => {
40
- it("awaits the warmup port before anything else", async () => {
41
- const warmup = vi.fn(async () => {})
42
- const { authenticateInLoader } = setup({ warmup })
43
- await authenticateInLoader(new Request("http://localhost/"), { failureRedirect: null })
44
- expect(warmup).toHaveBeenCalledTimes(1)
45
- })
46
-
47
- it("returns {} when unauthenticated and failureRedirect is null", async () => {
48
- const { authenticateInLoader } = setup()
49
- expect(await authenticateInLoader(new Request("http://localhost/"), { failureRedirect: null })).toEqual({})
50
- })
51
-
52
- it("redirects when unauthenticated and failureRedirect is set", async () => {
53
- const { authenticateInLoader } = setup()
54
- try {
55
- await authenticateInLoader(new Request("http://localhost/"), { failureRedirect: "/auth/login" })
56
- expect.unreachable("should have thrown a redirect")
57
- } catch (error) {
58
- expect((error as Response).headers.get("Location")).toBe("/auth/login")
59
- }
60
- })
61
-
62
- it("returns the user with the resolved photo and no cookie when not refreshed", async () => {
63
- const getUserPhoto = vi.fn(async () => "https://example.com/p.png")
64
- const { storage, authenticateInLoader } = setup({ getUserPhoto })
65
- const cookie = await storage.saveSession(new Request("http://localhost/"), profile)
66
-
67
- const result = await authenticateInLoader(new Request("http://localhost/", { headers: { Cookie: cookie } }), { failureRedirect: null })
68
- expect(result.user?.photo).toBe("https://example.com/p.png")
69
- expect(result.user?.accessToken).toBe("at")
70
- expect(result.cookie).toBeUndefined()
71
- })
72
-
73
- it("caches and dedups photo lookups per openid", async () => {
74
- const getUserPhoto = vi.fn(async () => "p1")
75
- const { storage, authenticateInLoader } = setup({ getUserPhoto })
76
- const cookie = await storage.saveSession(new Request("http://localhost/"), profile)
77
- const req = () => new Request("http://localhost/", { headers: { Cookie: cookie } })
78
-
79
- await Promise.all([authenticateInLoader(req(), { failureRedirect: null }), authenticateInLoader(req(), { failureRedirect: null })])
80
- await authenticateInLoader(req(), { failureRedirect: null })
81
- // 同時 2 回 (dedup) + 追加 1 回 (キャッシュヒット) → クエリは 1 回だけ
82
- expect(getUserPhoto).toHaveBeenCalledTimes(1)
83
- })
84
-
85
- it("defaults the photo to an empty string without a port", async () => {
86
- const { storage, authenticateInLoader } = setup()
87
- const cookie = await storage.saveSession(new Request("http://localhost/"), profile)
88
- const result = await authenticateInLoader(new Request("http://localhost/", { headers: { Cookie: cookie } }), { failureRedirect: null })
89
- expect(result.user?.photo).toBe("")
90
- })
91
-
92
- it("saves the session and returns a cookie when the token was refreshed", async () => {
93
- const expired = { ...profile, expirationDateMs: Date.now() - 1000 }
94
- const { storage, authenticateInLoader } = setup()
95
- const cookie = await storage.saveSession(new Request("http://localhost/"), expired)
96
-
97
- vi.stubGlobal(
98
- "fetch",
99
- vi.fn(async () => new Response(JSON.stringify({ access_token: "new-at", expires_in: 3600 }), { status: 200 })),
100
- )
101
- try {
102
- const result = await authenticateInLoader(new Request("http://localhost/", { headers: { Cookie: cookie } }), { failureRedirect: null })
103
- expect(result.user?.accessToken).toBe("new-at")
104
- expect(result.cookie).toContain("__session=")
105
- } finally {
106
- vi.unstubAllGlobals()
107
- }
108
- })
109
-
110
- it("still redirects (without Set-Cookie) when destroySession itself fails", async () => {
111
- const getUserPhoto = vi.fn(async () => {
112
- throw new Error("openids db failure (injected)")
113
- })
114
- const { storage, authenticateInLoader } = setup({ getUserPhoto })
115
- const cookie = await storage.saveSession(new Request("http://localhost/"), profile)
116
- // 破棄も失敗させる (storage プロパティ差し替えは内部呼び出しへ即時反映)
117
- storage.destroySession = vi.fn().mockRejectedValue(new Error("destroy failed (injected)"))
118
-
119
- try {
120
- await authenticateInLoader(new Request("http://localhost/", { headers: { Cookie: cookie } }), { failureRedirect: "/auth/login" })
121
- expect.unreachable("should have thrown a redirect")
122
- } catch (error) {
123
- expect(error).toBeInstanceOf(Response)
124
- const res = error as Response
125
- expect(res.status).toBe(302)
126
- expect(res.headers.get("Location")).toBe("/auth/login")
127
- // destroySession 失敗のため Set-Cookie は付かない (redirect 自体は飲み込まれない)
128
- expect(res.headers.get("Set-Cookie")).toBeNull()
129
- }
130
- })
131
-
132
- it("destroys the session and redirects on refresh failure", async () => {
133
- const expired = { ...profile, expirationDateMs: Date.now() - 1000 }
134
- const { storage, authenticateInLoader } = setup()
135
- const cookie = await storage.saveSession(new Request("http://localhost/"), expired)
136
-
137
- vi.stubGlobal(
138
- "fetch",
139
- vi.fn(async () => new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 })),
140
- )
141
- try {
142
- await authenticateInLoader(new Request("http://localhost/", { headers: { Cookie: cookie } }), { failureRedirect: "/auth/login" })
143
- expect.unreachable("should have thrown a redirect")
144
- } catch (error) {
145
- const res = error as Response
146
- expect(res.headers.get("Location")).toBe("/auth/login")
147
- expect(res.headers.get("Set-Cookie")).toContain("__session=")
148
- } finally {
149
- vi.unstubAllGlobals()
150
- }
151
- })
152
- })
@@ -1,146 +0,0 @@
1
- /**
2
- * Tests for the token guard and Google refresh rotation.
3
- * トークンガードと Google リフレッシュ更新のテスト。
4
- */
5
- import type { SessionProfile } from "@aiquants/auth-core"
6
- import { afterEach, describe, expect, it, vi } from "vitest"
7
- import { createAuthenticate } from "./authenticate"
8
- import type { AuthServerConfig, LoggerPort } from "./ports"
9
- import { createAuthSessionStorage } from "./session"
10
-
11
- const silentLogger: LoggerPort = { info: () => {}, warn: () => {}, error: () => {} }
12
-
13
- const baseConfig: AuthServerConfig = {
14
- google: { clientId: "cid", clientSecret: "csec", redirectURI: "http://localhost/auth/api/google/callback" },
15
- session: { secrets: ["test-secret"] },
16
- isEmailAllowed: () => true,
17
- logger: silentLogger,
18
- }
19
-
20
- const profile: SessionProfile = {
21
- id: "1",
22
- displayName: "User",
23
- name: { familyName: "F", givenName: "G" },
24
- emails: [{ value: "u@example.com" }],
25
- accessToken: "at",
26
- refreshToken: "rt",
27
- expirationDateMs: Date.now() + 3600000,
28
- provider: "google",
29
- }
30
-
31
- const setup = async (user?: SessionProfile, config: AuthServerConfig = baseConfig) => {
32
- const storage = createAuthSessionStorage(config.session, { mockUser: config.mockUser })
33
- const request = new Request("http://localhost/")
34
- const session = await storage.getSession(request)
35
- if (user) {
36
- session.set("user", user)
37
- }
38
- const fns = createAuthenticate(config, storage, silentLogger)
39
- return { storage, request, session, fns }
40
- }
41
-
42
- afterEach(() => {
43
- vi.unstubAllGlobals()
44
- })
45
-
46
- describe("authenticate", () => {
47
- it("returns tokens unchanged while not expired", async () => {
48
- const { fns, request, session } = await setup(profile)
49
- const tokens = await fns.authenticate(request, session)
50
- expect(tokens).toEqual({ accessToken: "at", refreshToken: "rt", expirationDateMs: profile.expirationDateMs })
51
- })
52
-
53
- it("redirects to the login path when the session has no tokens", async () => {
54
- const { fns, request, session } = await setup()
55
- try {
56
- await fns.authenticate(request, session)
57
- expect.unreachable("should have thrown a redirect")
58
- } catch (error) {
59
- expect(error).toBeInstanceOf(Response)
60
- expect((error as Response).headers.get("Location")).toBe("/auth/login")
61
- }
62
- })
63
-
64
- it("honors failureRedirect for missing tokens", async () => {
65
- const { fns, request, session } = await setup()
66
- try {
67
- await fns.authenticate(request, session, { failureRedirect: "/custom" })
68
- expect.unreachable("should have thrown a redirect")
69
- } catch (error) {
70
- expect((error as Response).headers.get("Location")).toBe("/custom")
71
- }
72
- })
73
-
74
- it("refreshes expired tokens via Google and updates the session", async () => {
75
- const expired = { ...profile, expirationDateMs: Date.now() - 1000 }
76
- const { fns, request, session } = await setup(expired)
77
-
78
- vi.stubGlobal(
79
- "fetch",
80
- vi.fn(async () => new Response(JSON.stringify({ access_token: "new-at", expires_in: 3600 }), { status: 200 })),
81
- )
82
-
83
- const tokens = await fns.authenticate(request, session)
84
- expect(tokens.accessToken).toBe("new-at")
85
- expect(tokens.refreshToken).toBe("rt") // Google は通常リフレッシュトークンを再発行しない
86
- expect(tokens.expirationDateMs).toBeGreaterThan(Date.now())
87
- // セッションも更新されている
88
- expect(session.get("user").accessToken).toBe("new-at")
89
- // client_id/secret は注入設定から使われる
90
- const body = JSON.parse((vi.mocked(fetch).mock.calls[0][1] as RequestInit).body as string)
91
- expect(body.client_id).toBe("cid")
92
- expect(body.client_secret).toBe("csec")
93
- expect(body.refresh_token).toBe("rt")
94
- })
95
-
96
- it("destroys the session and redirects on invalid_grant", async () => {
97
- const expired = { ...profile, expirationDateMs: Date.now() - 1000 }
98
- const { fns, request, session } = await setup(expired)
99
-
100
- vi.stubGlobal(
101
- "fetch",
102
- vi.fn(async () => new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 })),
103
- )
104
-
105
- try {
106
- await fns.authenticate(request, session)
107
- expect.unreachable("should have thrown a redirect")
108
- } catch (error) {
109
- expect(error).toBeInstanceOf(Response)
110
- const res = error as Response
111
- expect(res.headers.get("Location")).toBe("/auth/login")
112
- // セッション破棄の Set-Cookie が付与される
113
- expect(res.headers.get("Set-Cookie")).toContain("__session=")
114
- }
115
- })
116
-
117
- it("returns mock tokens when the mockUser port matches", async () => {
118
- const config: AuthServerConfig = {
119
- ...baseConfig,
120
- mockUser: {
121
- enabled: (req) => req.headers.get("Cookie")?.includes("mock_user=true") ?? false,
122
- resolve: () => profile,
123
- },
124
- }
125
- const storage = createAuthSessionStorage(config.session)
126
- const request = new Request("http://localhost/", { headers: { Cookie: "mock_user=true" } })
127
- const session = await storage.getSession(request)
128
- const fns = createAuthenticate(config, storage, silentLogger)
129
-
130
- const tokens = await fns.authenticate(request, session)
131
- expect(tokens.accessToken).toBe("mock-access-token")
132
- expect(tokens.refreshToken).toBe("mock-refresh-token")
133
- })
134
- })
135
-
136
- describe("refreshedAccessToken", () => {
137
- it("rejects unsupported providers", async () => {
138
- const { fns, session } = await setup({ ...profile, provider: "unknown" })
139
- await expect(fns.refreshedAccessToken(session)).rejects.toThrow("Provider not supported")
140
- })
141
-
142
- it("rejects when the provider is missing", async () => {
143
- const { fns, session } = await setup({ ...profile, provider: "" })
144
- await expect(fns.refreshedAccessToken(session)).rejects.toThrow("Provider not found in session")
145
- })
146
- })