@aiquants/auth-react-router 0.6.0 → 0.7.0
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.
- package/README.md +22 -0
- package/dist/admin.d.mts +46 -7
- package/dist/admin.d.ts +46 -7
- package/dist/admin.js +1 -1
- package/dist/admin.js.map +1 -1
- package/dist/admin.mjs +1 -1
- package/dist/admin.mjs.map +1 -1
- package/package.json +3 -1
- package/src/admin/server.ts +47 -6
- package/src/admin/ui.tsx +32 -17
- package/src/admin/server.spec.ts +0 -300
- package/src/admin/views.spec.tsx +0 -307
- package/src/server/authenticate-in-loader.spec.ts +0 -152
- package/src/server/authenticate.spec.ts +0 -146
- package/src/server/google-strategy-impl.spec.ts +0 -116
- package/src/server/google-strategy.spec.ts +0 -271
- package/src/server/handlers.spec.ts +0 -154
- package/src/server/session.spec.ts +0 -165
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the ported GoogleStrategy (byte-parity with @coji/remix-auth-google).
|
|
3
|
-
* 移植した GoogleStrategy のテスト (@coji/remix-auth-google との byte 一致)。
|
|
4
|
-
*/
|
|
5
|
-
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
6
|
-
import type { GoogleProfile } from "./google-strategy-impl"
|
|
7
|
-
import { GoogleStrategy, GoogleStrategyDefaultName, GoogleStrategyDefaultScopes } from "./google-strategy-impl"
|
|
8
|
-
|
|
9
|
-
// authorizationParams / accessType 等は protected/private のため、テスト用サブクラスで公開する
|
|
10
|
-
class ProbeStrategy extends GoogleStrategy<{ id: string }> {
|
|
11
|
-
exposeAuthorizationParams(params: URLSearchParams, request?: Request): URLSearchParams {
|
|
12
|
-
return this.authorizationParams(params, request)
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const noopVerify = async () => ({ id: "x" })
|
|
17
|
-
|
|
18
|
-
const makeProbe = (over?: Partial<ConstructorParameters<typeof GoogleStrategy>[0]>) =>
|
|
19
|
-
new ProbeStrategy(
|
|
20
|
-
{
|
|
21
|
-
clientId: "cid",
|
|
22
|
-
clientSecret: "csec",
|
|
23
|
-
redirectURI: "http://localhost/auth/api/google/callback",
|
|
24
|
-
...over,
|
|
25
|
-
},
|
|
26
|
-
noopVerify,
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
afterEach(() => {
|
|
30
|
-
vi.unstubAllGlobals()
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
describe("constants", () => {
|
|
34
|
-
it("keeps the original default scopes (full URL form)", () => {
|
|
35
|
-
expect(GoogleStrategyDefaultScopes).toEqual(["openid", "https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.email"])
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
it("uses 'google' as the strategy name", () => {
|
|
39
|
-
expect(GoogleStrategyDefaultName).toBe("google")
|
|
40
|
-
expect(makeProbe().name).toBe("google")
|
|
41
|
-
})
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
describe("authorizationParams", () => {
|
|
45
|
-
it("applies the original defaults (access_type=online, include_granted_scopes=false, no prompt)", () => {
|
|
46
|
-
const params = makeProbe().exposeAuthorizationParams(new URLSearchParams())
|
|
47
|
-
expect(params.get("access_type")).toBe("online")
|
|
48
|
-
expect(params.get("include_granted_scopes")).toBe("false")
|
|
49
|
-
expect(params.get("prompt")).toBeNull()
|
|
50
|
-
expect(params.get("hd")).toBeNull()
|
|
51
|
-
expect(params.get("login_hint")).toBeNull()
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
it("reflects the quants config (offline / include_granted_scopes=true / prompt=consent)", () => {
|
|
55
|
-
const params = makeProbe({ accessType: "offline", includeGrantedScopes: true, prompt: "consent" }).exposeAuthorizationParams(new URLSearchParams())
|
|
56
|
-
expect(params.get("access_type")).toBe("offline")
|
|
57
|
-
expect(params.get("include_granted_scopes")).toBe("true")
|
|
58
|
-
expect(params.get("prompt")).toBe("consent")
|
|
59
|
-
expect(params.get("scope")).toBe("openid https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email")
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
it("formats multiple scopes cleanly with space separation including custom scopes like drive.readonly", () => {
|
|
63
|
-
const customScopes = ["openid", "email", "https://www.googleapis.com/auth/drive.readonly"] as const
|
|
64
|
-
// biome-ignore lint/suspicious/noExplicitAny: Custom scopes for testing
|
|
65
|
-
const params = makeProbe({ scopes: customScopes as any }).exposeAuthorizationParams(new URLSearchParams())
|
|
66
|
-
expect(params.get("scope")).toBe("openid email https://www.googleapis.com/auth/drive.readonly")
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
it("sets hd and login_hint only when provided", () => {
|
|
70
|
-
const params = makeProbe({ hd: "example.com", loginHint: "user@example.com" }).exposeAuthorizationParams(new URLSearchParams())
|
|
71
|
-
expect(params.get("hd")).toBe("example.com")
|
|
72
|
-
expect(params.get("login_hint")).toBe("user@example.com")
|
|
73
|
-
})
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
describe("userProfile", () => {
|
|
77
|
-
const rawUserinfo = {
|
|
78
|
-
sub: "107791905418691074770",
|
|
79
|
-
name: "宏治 テスト",
|
|
80
|
-
given_name: "宏治",
|
|
81
|
-
family_name: "テスト",
|
|
82
|
-
email: "user@example.com",
|
|
83
|
-
email_verified: true,
|
|
84
|
-
picture: "https://example.com/p.png",
|
|
85
|
-
locale: "ja",
|
|
86
|
-
hd: "example.com",
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
it("maps the userinfo (v3) response to GoogleProfile (sub→id, picture→photos, _json=raw)", async () => {
|
|
90
|
-
const fetchMock = vi.fn(async () => new Response(JSON.stringify(rawUserinfo), { status: 200 }))
|
|
91
|
-
vi.stubGlobal("fetch", fetchMock)
|
|
92
|
-
|
|
93
|
-
const profile: GoogleProfile = await GoogleStrategy.userProfile({ accessToken: () => "at" })
|
|
94
|
-
|
|
95
|
-
expect(profile.id).toBe("107791905418691074770")
|
|
96
|
-
expect(profile.displayName).toBe("宏治 テスト")
|
|
97
|
-
expect(profile.name).toEqual({ familyName: "テスト", givenName: "宏治" })
|
|
98
|
-
expect(profile.emails).toEqual([{ value: "user@example.com" }])
|
|
99
|
-
expect(profile.photos).toEqual([{ value: "https://example.com/p.png" }])
|
|
100
|
-
// _json は生レスポンスをそのまま保持 (email_verified / hd を含む — verify コールバックが依存)
|
|
101
|
-
expect(profile._json).toEqual(rawUserinfo)
|
|
102
|
-
|
|
103
|
-
// Bearer トークンを userinfo v3 へ送っている
|
|
104
|
-
const [url, init] = fetchMock.mock.calls[0]
|
|
105
|
-
expect(url).toBe("https://www.googleapis.com/oauth2/v3/userinfo")
|
|
106
|
-
expect((init as RequestInit).headers).toEqual({ Authorization: "Bearer at" })
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
it("throws when the userinfo response is not ok", async () => {
|
|
110
|
-
vi.stubGlobal(
|
|
111
|
-
"fetch",
|
|
112
|
-
vi.fn(async () => new Response("nope", { status: 401, statusText: "Unauthorized" })),
|
|
113
|
-
)
|
|
114
|
-
await expect(GoogleStrategy.userProfile({ accessToken: () => "at" })).rejects.toThrow("Failed to fetch user profile: Unauthorized")
|
|
115
|
-
})
|
|
116
|
-
})
|
|
@@ -1,271 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the Google verify callback (order, error mapping, profile shape).
|
|
3
|
-
* Google verify コールバックのテスト (検証順序・エラー写像・プロファイル形状)。
|
|
4
|
-
*
|
|
5
|
-
* GoogleStrategy をコンストラクタ捕捉でフェイク化し、verify コールバックを直接起動する。
|
|
6
|
-
*/
|
|
7
|
-
import type { MyGoogleProfile } from "@aiquants/auth-core"
|
|
8
|
-
import { defaultAuthMessages } from "@aiquants/auth-core"
|
|
9
|
-
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
10
|
-
import type { AuthServerConfig, LoggerPort } from "./ports"
|
|
11
|
-
|
|
12
|
-
type VerifyFn = (args: { request: Request; tokens: { data: Record<string, unknown> } }) => Promise<MyGoogleProfile>
|
|
13
|
-
|
|
14
|
-
const captured: { options?: Record<string, unknown>; verify?: VerifyFn } = {}
|
|
15
|
-
const userProfileMock = vi.hoisted(() => vi.fn())
|
|
16
|
-
|
|
17
|
-
vi.mock("./google-strategy-impl", () => {
|
|
18
|
-
/** コンストラクタ引数を捕捉するフェイク GoogleStrategy。 */
|
|
19
|
-
class FakeGoogleStrategy {
|
|
20
|
-
static userProfile = userProfileMock
|
|
21
|
-
name = "google"
|
|
22
|
-
constructor(options: Record<string, unknown>, verify: VerifyFn) {
|
|
23
|
-
captured.options = options
|
|
24
|
-
captured.verify = verify
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
return { GoogleStrategy: FakeGoogleStrategy }
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
const { createGoogleAuthenticator } = await import("./google-strategy")
|
|
31
|
-
|
|
32
|
-
const silentLogger: LoggerPort = { info: () => {}, warn: () => {}, error: () => {} }
|
|
33
|
-
|
|
34
|
-
const googleProfile = () => ({
|
|
35
|
-
id: "107791905418691074770",
|
|
36
|
-
displayName: "宏治",
|
|
37
|
-
name: { familyName: "F", givenName: "G" },
|
|
38
|
-
emails: [{ value: "user@example.com" }],
|
|
39
|
-
photos: [{ value: "photo-url" }],
|
|
40
|
-
_json: {
|
|
41
|
-
sub: "107791905418691074770",
|
|
42
|
-
email: "user@example.com",
|
|
43
|
-
picture: "https://example.com/p.png",
|
|
44
|
-
email_verified: true,
|
|
45
|
-
hd: "example.com",
|
|
46
|
-
},
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
const tokens = (over?: Record<string, unknown>) => ({
|
|
50
|
-
data: { access_token: "at", refresh_token: "rt", expires_in: 3600, scope: "openid email profile", id_token: "idt", ...over },
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
const makeConfig = (over?: Partial<AuthServerConfig>): AuthServerConfig => ({
|
|
54
|
-
google: { clientId: "cid", clientSecret: "csec", redirectURI: "http://localhost/cb" },
|
|
55
|
-
session: { secrets: ["s"] },
|
|
56
|
-
isEmailAllowed: () => true,
|
|
57
|
-
logger: silentLogger,
|
|
58
|
-
...over,
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
/** verify を起動して throw された Response の Location を返すヘルパー。 */
|
|
62
|
-
const expectLoginRedirect = async (promise: Promise<unknown>): Promise<string> => {
|
|
63
|
-
try {
|
|
64
|
-
await promise
|
|
65
|
-
expect.unreachable("should have thrown a redirect")
|
|
66
|
-
} catch (error) {
|
|
67
|
-
expect(error).toBeInstanceOf(Response)
|
|
68
|
-
const location = (error as Response).headers.get("Location")
|
|
69
|
-
expect(location).not.toBeNull()
|
|
70
|
-
return location as string
|
|
71
|
-
}
|
|
72
|
-
return ""
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
beforeEach(() => {
|
|
76
|
-
userProfileMock.mockReset()
|
|
77
|
-
userProfileMock.mockResolvedValue(googleProfile())
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
describe("createGoogleAuthenticator options", () => {
|
|
81
|
-
it("passes injected google options with original defaults", () => {
|
|
82
|
-
createGoogleAuthenticator(makeConfig())
|
|
83
|
-
expect(captured.options).toMatchObject({
|
|
84
|
-
clientId: "cid",
|
|
85
|
-
clientSecret: "csec",
|
|
86
|
-
redirectURI: "http://localhost/cb",
|
|
87
|
-
accessType: "offline",
|
|
88
|
-
includeGrantedScopes: true,
|
|
89
|
-
prompt: "consent",
|
|
90
|
-
})
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
it("passes custom scopes through (sanledge 差分の吸収)", () => {
|
|
94
|
-
createGoogleAuthenticator(makeConfig({ google: { clientId: "c", clientSecret: "s", redirectURI: "u", scopes: ["openid", "email", "profile", "directory.readonly"] } }))
|
|
95
|
-
expect(captured.options?.scopes).toEqual(["openid", "email", "profile", "directory.readonly"])
|
|
96
|
-
})
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
describe("verify callback", () => {
|
|
100
|
-
it("runs backend verify → allowlist → signup in order and returns the profile with photo", async () => {
|
|
101
|
-
const order: string[] = []
|
|
102
|
-
const config = makeConfig({
|
|
103
|
-
backendAuth: {
|
|
104
|
-
verify: vi.fn(async () => {
|
|
105
|
-
order.push("verify")
|
|
106
|
-
return true
|
|
107
|
-
}),
|
|
108
|
-
signup: vi.fn(async () => {
|
|
109
|
-
order.push("signup")
|
|
110
|
-
return {}
|
|
111
|
-
}),
|
|
112
|
-
},
|
|
113
|
-
isEmailAllowed: (email) => {
|
|
114
|
-
order.push(`allowlist:${email}`)
|
|
115
|
-
return true
|
|
116
|
-
},
|
|
117
|
-
})
|
|
118
|
-
createGoogleAuthenticator(config)
|
|
119
|
-
|
|
120
|
-
const before = Date.now()
|
|
121
|
-
const user = await captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() })
|
|
122
|
-
|
|
123
|
-
// 検証順序: backend verify → allowlist → backend signup (原本と同一)
|
|
124
|
-
expect(order).toEqual(["verify", "allowlist:user@example.com", "signup"])
|
|
125
|
-
expect(config.backendAuth?.verify).toHaveBeenCalledWith("google", "at")
|
|
126
|
-
expect(config.backendAuth?.signup).toHaveBeenCalledWith("google", "at")
|
|
127
|
-
// プロファイル形状: _json/photos を含まず photo=picture、payload 付き
|
|
128
|
-
expect(user).toMatchObject({
|
|
129
|
-
id: "107791905418691074770",
|
|
130
|
-
displayName: "宏治",
|
|
131
|
-
emails: [{ value: "user@example.com" }],
|
|
132
|
-
accessToken: "at",
|
|
133
|
-
refreshToken: "rt",
|
|
134
|
-
provider: "google",
|
|
135
|
-
photo: "https://example.com/p.png",
|
|
136
|
-
})
|
|
137
|
-
expect(user && "_json" in user).toBe(false)
|
|
138
|
-
expect(user && "photos" in user).toBe(false)
|
|
139
|
-
expect(user?.expirationDateMs).toBeGreaterThanOrEqual(before + 3600 * 1000)
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
it("redirects with emailNotVerified when a field is missing AND email_verified is false (原本セマンティクス)", async () => {
|
|
143
|
-
// 原本の分岐は「必須フィールド欠落 (undefined) の場合」に限り email_verified===false を
|
|
144
|
-
// 判別してメッセージを出し分ける (email_verified: false 単独では発火しない) — bug-for-bug 保存
|
|
145
|
-
userProfileMock.mockResolvedValue({ ...googleProfile(), _json: { ...googleProfile()._json, email: undefined, email_verified: false } })
|
|
146
|
-
createGoogleAuthenticator(makeConfig())
|
|
147
|
-
const location = await expectLoginRedirect(captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() }) as Promise<unknown>)
|
|
148
|
-
expect(location).toBe(`/auth/login?error=${encodeURIComponent(defaultAuthMessages.emailNotVerified)}`)
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
it("redirects with googleFailed when the refresh token is missing", async () => {
|
|
152
|
-
createGoogleAuthenticator(makeConfig())
|
|
153
|
-
const location = await expectLoginRedirect(captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens({ refresh_token: undefined }) }) as Promise<unknown>)
|
|
154
|
-
expect(location).toBe(`/auth/login?error=${encodeURIComponent(defaultAuthMessages.googleFailed)}`)
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
it("redirects with backendFailed when backend verify returns false", async () => {
|
|
158
|
-
createGoogleAuthenticator(makeConfig({ backendAuth: { verify: async () => false, signup: async () => ({}) } }))
|
|
159
|
-
const location = await expectLoginRedirect(captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() }) as Promise<unknown>)
|
|
160
|
-
expect(location).toBe(`/auth/login?error=${encodeURIComponent(defaultAuthMessages.backendFailed)}`)
|
|
161
|
-
})
|
|
162
|
-
|
|
163
|
-
it("redirects with backendUnreachable on getaddrinfo ENOTFOUND cause", async () => {
|
|
164
|
-
createGoogleAuthenticator(
|
|
165
|
-
makeConfig({
|
|
166
|
-
backendAuth: {
|
|
167
|
-
verify: async () => {
|
|
168
|
-
throw new Error("Network or Interface Error", { cause: new Error("getaddrinfo ENOTFOUND api.example.com") })
|
|
169
|
-
},
|
|
170
|
-
signup: async () => ({}),
|
|
171
|
-
},
|
|
172
|
-
}),
|
|
173
|
-
)
|
|
174
|
-
const location = await expectLoginRedirect(captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() }) as Promise<unknown>)
|
|
175
|
-
expect(location).toBe(`/auth/login?error=${encodeURIComponent(defaultAuthMessages.backendUnreachable)}`)
|
|
176
|
-
})
|
|
177
|
-
|
|
178
|
-
it("redirects with notAllowed when the allowlist rejects the email", async () => {
|
|
179
|
-
const signup = vi.fn(async () => ({}))
|
|
180
|
-
createGoogleAuthenticator(makeConfig({ isEmailAllowed: () => false, backendAuth: { verify: async () => true, signup } }))
|
|
181
|
-
const location = await expectLoginRedirect(captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() }) as Promise<unknown>)
|
|
182
|
-
expect(location).toBe(`/auth/login?error=${encodeURIComponent(defaultAuthMessages.notAllowed)}`)
|
|
183
|
-
// allowlist 拒否時は signup に到達しない (fail-closed)
|
|
184
|
-
expect(signup).not.toHaveBeenCalled()
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
// ⚠️ 回帰ガード: `isEmailAllowed` は非同期実装を許すため、呼び出し側の `await` が外れると
|
|
188
|
-
// `!Promise` が常に false となり **許可判定を素通り (fail-open)** する。型検査では検出できない。
|
|
189
|
-
it("REGRESSION GUARD: an ASYNC deny predicate must still block (missing await would fail OPEN)", async () => {
|
|
190
|
-
const signup = vi.fn(async () => ({}))
|
|
191
|
-
createGoogleAuthenticator(makeConfig({ isEmailAllowed: async () => false, backendAuth: { verify: async () => true, signup } }))
|
|
192
|
-
const location = await expectLoginRedirect(captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() }) as Promise<unknown>)
|
|
193
|
-
expect(location).toBe(`/auth/login?error=${encodeURIComponent(defaultAuthMessages.notAllowed)}`)
|
|
194
|
-
expect(signup).not.toHaveBeenCalled()
|
|
195
|
-
})
|
|
196
|
-
|
|
197
|
-
it("an ASYNC allow predicate lets the login through (await resolves the value, not the Promise)", async () => {
|
|
198
|
-
const allowlist = vi.fn(async () => true)
|
|
199
|
-
createGoogleAuthenticator(makeConfig({ isEmailAllowed: allowlist }))
|
|
200
|
-
const user = await captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() })
|
|
201
|
-
expect(user?.photo).toBe("https://example.com/p.png")
|
|
202
|
-
expect(allowlist).toHaveBeenCalledWith("user@example.com")
|
|
203
|
-
})
|
|
204
|
-
|
|
205
|
-
it("a REJECTING allowlist (e.g. database unreachable) fails CLOSED", async () => {
|
|
206
|
-
const signup = vi.fn(async () => ({}))
|
|
207
|
-
createGoogleAuthenticator(
|
|
208
|
-
makeConfig({
|
|
209
|
-
isEmailAllowed: async () => {
|
|
210
|
-
throw new Error("ECONNREFUSED sql")
|
|
211
|
-
},
|
|
212
|
-
backendAuth: { verify: async () => true, signup },
|
|
213
|
-
}),
|
|
214
|
-
)
|
|
215
|
-
const location = await expectLoginRedirect(captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() }) as Promise<unknown>)
|
|
216
|
-
// 許可されない以上ログインは成立しない (メッセージは汎用エラー)
|
|
217
|
-
expect(location).toContain("/auth/login?error=")
|
|
218
|
-
expect(signup).not.toHaveBeenCalled()
|
|
219
|
-
})
|
|
220
|
-
|
|
221
|
-
it("REGRESSION GUARD: a DEACTIVATED account cannot sign in even when the allowlist permits it", async () => {
|
|
222
|
-
const signup = vi.fn(async () => ({}))
|
|
223
|
-
createGoogleAuthenticator(makeConfig({ isEmailAllowed: () => true, isUserActive: async () => false, backendAuth: { verify: async () => true, signup } }))
|
|
224
|
-
const location = await expectLoginRedirect(captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() }) as Promise<unknown>)
|
|
225
|
-
expect(location).toBe(`/auth/login?error=${encodeURIComponent(defaultAuthMessages.notAllowed)}`)
|
|
226
|
-
expect(signup).not.toHaveBeenCalled()
|
|
227
|
-
})
|
|
228
|
-
|
|
229
|
-
it("an ACTIVE account signs in normally, and the port receives the profile", async () => {
|
|
230
|
-
const isUserActive = vi.fn(async () => true)
|
|
231
|
-
createGoogleAuthenticator(makeConfig({ isUserActive }))
|
|
232
|
-
const user = await captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() })
|
|
233
|
-
expect(user?.photo).toBe("https://example.com/p.png")
|
|
234
|
-
expect(isUserActive).toHaveBeenCalledWith(expect.objectContaining({ emails: [{ value: "user@example.com" }] }))
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
it("omitting isUserActive keeps the previous behaviour", async () => {
|
|
238
|
-
createGoogleAuthenticator(makeConfig({}))
|
|
239
|
-
const user = await captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() })
|
|
240
|
-
expect(user?.photo).toBe("https://example.com/p.png")
|
|
241
|
-
})
|
|
242
|
-
|
|
243
|
-
it("redirects with unexpected on generic signup failure", async () => {
|
|
244
|
-
createGoogleAuthenticator(
|
|
245
|
-
makeConfig({
|
|
246
|
-
backendAuth: {
|
|
247
|
-
verify: async () => true,
|
|
248
|
-
signup: async () => {
|
|
249
|
-
throw new Error("boom")
|
|
250
|
-
},
|
|
251
|
-
},
|
|
252
|
-
}),
|
|
253
|
-
)
|
|
254
|
-
const location = await expectLoginRedirect(captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() }) as Promise<unknown>)
|
|
255
|
-
expect(location).toBe(`/auth/login?error=${encodeURIComponent(defaultAuthMessages.unexpected)}`)
|
|
256
|
-
})
|
|
257
|
-
|
|
258
|
-
it("skips backend verify/signup when backendAuth is omitted but still enforces the allowlist", async () => {
|
|
259
|
-
const allowlist = vi.fn(() => true)
|
|
260
|
-
createGoogleAuthenticator(makeConfig({ isEmailAllowed: allowlist }))
|
|
261
|
-
const user = await captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() })
|
|
262
|
-
expect(user?.photo).toBe("https://example.com/p.png")
|
|
263
|
-
expect(allowlist).toHaveBeenCalledWith("user@example.com")
|
|
264
|
-
})
|
|
265
|
-
|
|
266
|
-
it("honors message overrides", async () => {
|
|
267
|
-
createGoogleAuthenticator(makeConfig({ isEmailAllowed: () => false, messages: { notAllowed: "立入禁止" } }))
|
|
268
|
-
const location = await expectLoginRedirect(captured.verify?.({ request: new Request("http://localhost/"), tokens: tokens() }) as Promise<unknown>)
|
|
269
|
-
expect(location).toBe(`/auth/login?error=${encodeURIComponent("立入禁止")}`)
|
|
270
|
-
})
|
|
271
|
-
})
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the auth route handlers (login/logout/callback).
|
|
3
|
-
* 認証ルートハンドラ (login/logout/callback) のテスト。
|
|
4
|
-
*/
|
|
5
|
-
import type { MyGoogleProfile } from "@aiquants/auth-core"
|
|
6
|
-
import type { Authenticator } from "remix-auth"
|
|
7
|
-
import { describe, expect, it } from "vitest"
|
|
8
|
-
import { createAuthHandlers } from "./handlers"
|
|
9
|
-
import type { AuthServerConfig, LoggerPort } from "./ports"
|
|
10
|
-
import { createAuthSessionStorage } from "./session"
|
|
11
|
-
|
|
12
|
-
const silentLogger: LoggerPort = { info: () => {}, warn: () => {}, error: () => {} }
|
|
13
|
-
|
|
14
|
-
const config: AuthServerConfig = {
|
|
15
|
-
google: { clientId: "cid", clientSecret: "csec", redirectURI: "http://localhost/cb" },
|
|
16
|
-
session: { secrets: ["test-secret"] },
|
|
17
|
-
isEmailAllowed: () => true,
|
|
18
|
-
logger: silentLogger,
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const googleProfile: MyGoogleProfile = {
|
|
22
|
-
id: "107791905418691074770",
|
|
23
|
-
displayName: "宏治",
|
|
24
|
-
name: { familyName: "F", givenName: "G" },
|
|
25
|
-
emails: [{ value: "u@example.com" }],
|
|
26
|
-
photo: "https://example.com/p.png",
|
|
27
|
-
accessToken: "at",
|
|
28
|
-
refreshToken: "rt",
|
|
29
|
-
expirationDateMs: 1750000000000,
|
|
30
|
-
provider: "google",
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const fakeAuthenticator = (result: MyGoogleProfile | Error): Authenticator<MyGoogleProfile> =>
|
|
34
|
-
({
|
|
35
|
-
authenticate: async () => {
|
|
36
|
-
if (result instanceof Error) throw result
|
|
37
|
-
return result
|
|
38
|
-
},
|
|
39
|
-
}) as unknown as Authenticator<MyGoogleProfile>
|
|
40
|
-
|
|
41
|
-
const setup = (authResult: MyGoogleProfile | Error = googleProfile) => {
|
|
42
|
-
const storage = createAuthSessionStorage(config.session)
|
|
43
|
-
const handlers = createAuthHandlers(config, storage, fakeAuthenticator(authResult), silentLogger)
|
|
44
|
-
return { storage, handlers }
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
describe("loginLoader", () => {
|
|
48
|
-
it("returns the error query param for unauthenticated users", async () => {
|
|
49
|
-
const { handlers } = setup()
|
|
50
|
-
const data = await handlers.loginLoader({ request: new Request("http://localhost/auth/login?error=oops") })
|
|
51
|
-
expect(data).toEqual({ error: "oops" })
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
it("redirects authenticated users to the post-login path", async () => {
|
|
55
|
-
const { handlers, storage } = setup()
|
|
56
|
-
const cookie = await storage.saveSession(new Request("http://localhost/"), googleProfile)
|
|
57
|
-
try {
|
|
58
|
-
await handlers.loginLoader({ request: new Request("http://localhost/auth/login", { headers: { Cookie: cookie } }) })
|
|
59
|
-
expect.unreachable("should have thrown a redirect")
|
|
60
|
-
} catch (error) {
|
|
61
|
-
expect((error as Response).headers.get("Location")).toBe("/")
|
|
62
|
-
}
|
|
63
|
-
})
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
describe("loginAction", () => {
|
|
67
|
-
it("authenticates on the Google form action", async () => {
|
|
68
|
-
const { handlers } = setup()
|
|
69
|
-
const form = new FormData()
|
|
70
|
-
form.set("_action", "Sign In with Google")
|
|
71
|
-
const user = await handlers.loginAction({ request: new Request("http://localhost/auth/login", { method: "POST", body: form }) })
|
|
72
|
-
expect(user).toEqual(googleProfile)
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
it("returns null for unknown actions", async () => {
|
|
76
|
-
const { handlers } = setup()
|
|
77
|
-
const form = new FormData()
|
|
78
|
-
form.set("_action", "Something Else")
|
|
79
|
-
const user = await handlers.loginAction({ request: new Request("http://localhost/auth/login", { method: "POST", body: form }) })
|
|
80
|
-
expect(user).toBeNull()
|
|
81
|
-
})
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
describe("logoutLoader", () => {
|
|
85
|
-
it("destroys the session and redirects with Set-Cookie", async () => {
|
|
86
|
-
const { handlers, storage } = setup()
|
|
87
|
-
const cookie = await storage.saveSession(new Request("http://localhost/"), googleProfile)
|
|
88
|
-
try {
|
|
89
|
-
await handlers.logoutLoader({ request: new Request("http://localhost/auth/logout", { headers: { Cookie: cookie } }) })
|
|
90
|
-
expect.unreachable("should have thrown a redirect")
|
|
91
|
-
} catch (error) {
|
|
92
|
-
const res = error as Response
|
|
93
|
-
expect(res.headers.get("Location")).toBe("/")
|
|
94
|
-
expect(res.headers.get("Set-Cookie")).toContain("__session=")
|
|
95
|
-
}
|
|
96
|
-
})
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
describe("googleCallbackLoader", () => {
|
|
100
|
-
it("saves a byte-compatible SessionProfile (no photo) and redirects home", async () => {
|
|
101
|
-
const { handlers, storage } = setup()
|
|
102
|
-
try {
|
|
103
|
-
await handlers.googleCallbackLoader({ request: new Request("http://localhost/auth/api/google/callback?code=x") })
|
|
104
|
-
expect.unreachable("should have thrown a redirect")
|
|
105
|
-
} catch (error) {
|
|
106
|
-
const res = error as Response
|
|
107
|
-
expect(res.headers.get("Location")).toBe("/")
|
|
108
|
-
const setCookie = res.headers.get("Set-Cookie")
|
|
109
|
-
expect(setCookie).toContain("__session=")
|
|
110
|
-
|
|
111
|
-
// 保存されたセッションを読み戻し、photo を含まない byte 互換形状を検証
|
|
112
|
-
const user = await storage.getSessionUser(new Request("http://localhost/", { headers: { Cookie: setCookie ?? "" } }))
|
|
113
|
-
expect(user).toEqual({
|
|
114
|
-
id: googleProfile.id,
|
|
115
|
-
displayName: googleProfile.displayName,
|
|
116
|
-
name: googleProfile.name,
|
|
117
|
-
emails: googleProfile.emails,
|
|
118
|
-
accessToken: "at",
|
|
119
|
-
refreshToken: "rt",
|
|
120
|
-
expirationDateMs: 1750000000000,
|
|
121
|
-
provider: "google",
|
|
122
|
-
role: undefined,
|
|
123
|
-
})
|
|
124
|
-
expect(user && "photo" in user).toBe(false)
|
|
125
|
-
}
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
it("redirects to the login page on authenticator errors", async () => {
|
|
129
|
-
const { handlers } = setup(new Error("access_denied"))
|
|
130
|
-
try {
|
|
131
|
-
await handlers.googleCallbackLoader({ request: new Request("http://localhost/auth/api/google/callback") })
|
|
132
|
-
expect.unreachable("should have thrown a redirect")
|
|
133
|
-
} catch (error) {
|
|
134
|
-
expect((error as Response).headers.get("Location")).toBe("/auth/login")
|
|
135
|
-
}
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
it("re-throws Response errors from the authenticator as-is", async () => {
|
|
139
|
-
const redirectResponse = new Response(null, { status: 302, headers: { Location: "/auth/login?error=x" } })
|
|
140
|
-
const storage = createAuthSessionStorage(config.session)
|
|
141
|
-
const authenticator = {
|
|
142
|
-
authenticate: async () => {
|
|
143
|
-
throw redirectResponse
|
|
144
|
-
},
|
|
145
|
-
} as unknown as Authenticator<MyGoogleProfile>
|
|
146
|
-
const handlers = createAuthHandlers(config, storage, authenticator, silentLogger)
|
|
147
|
-
try {
|
|
148
|
-
await handlers.googleCallbackLoader({ request: new Request("http://localhost/auth/api/google/callback") })
|
|
149
|
-
expect.unreachable("should have thrown the response")
|
|
150
|
-
} catch (error) {
|
|
151
|
-
expect(error).toBe(redirectResponse)
|
|
152
|
-
}
|
|
153
|
-
})
|
|
154
|
-
})
|