@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiquants/auth-react-router",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "React Router v7 auth adapter for @aiquants/auth-core: Google OAuth strategy factory (remix-auth), cookie session storage with DI test backdoor, per-request loader guard with token refresh + photo cache, auth route handlers, and a <GoogleForm> login button.",
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -29,6 +29,8 @@
29
29
  "files": [
30
30
  "dist",
31
31
  "src",
32
+ "!src/**/*.spec.ts",
33
+ "!src/**/*.spec.tsx",
32
34
  "README.md",
33
35
  "LICENSE"
34
36
  ],
@@ -1,5 +1,4 @@
1
1
  import type { AuthAllowlistEntry, AuthGroup, AuthGroupMember, AuthUser } from "@aiquants/auth-core"
2
- import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"
3
2
  import { type PartialUserAdminLabels, resolveUserAdminLabels } from "./labels"
4
3
 
5
4
  /**
@@ -143,6 +142,14 @@ export function createInMemoryUserAdminStore(initial?: { users?: AuthUser[]; gro
143
142
  * independent of any particular authorization library.
144
143
  * 操作が行う CRUD 種別。特定の認可ライブラリに依存しないよう素の文字列 union で表現する。
145
144
  */
145
+ /**
146
+ * The only route-args field this module reads. React Router's full `LoaderFunctionArgs` satisfies it, so
147
+ * route wiring is unchanged — but declaring the minimum keeps callers (and tests) from fabricating fields
148
+ * that are never looked at.
149
+ * このモジュールが実際に読むルート引数の全て。React Router の完全な引数はこれを満たすため結線は不変。
150
+ */
151
+ type RouteArgs = { request: Request }
152
+
146
153
  export type UserAdminAccess = "read" | "create" | "update" | "delete"
147
154
 
148
155
  /**
@@ -183,6 +190,20 @@ const INTENT_ACCESS = new Map<string, UserAdminAccess>([
183
190
  ["removeAllowlistEntry", "delete"],
184
191
  ])
185
192
 
193
+ /**
194
+ * Neutral per-resource permission summary the admin shell renders in its header badge.
195
+ * 管理シェルのヘッダーバッジが描画する、リソース単位の中立な権限サマリ。
196
+ *
197
+ * この形はこのパッケージ自身の契約であり、特定の認可ライブラリの型を import しない
198
+ * (`AGENTS.md` のクロスパッケージ・ハードコード禁止)。`canRead` / `canWrite` / `canDelete` を持つ
199
+ * ビューであれば構造的にそのまま渡せる。
200
+ */
201
+ export type UserAdminPermission = {
202
+ canRead: boolean
203
+ canWrite: boolean
204
+ canDelete: boolean
205
+ }
206
+
186
207
  export type UserAdminAppConfig = {
187
208
  store: UserAdminStore
188
209
  /** セクション単位の部分上書き。既定は英語 (`defaultUserAdminLabels`)。 */
@@ -192,8 +213,21 @@ export type UserAdminAppConfig = {
192
213
  * 必須。省略を許すとユーザー名簿と全更新操作が無防備に露出するため、型で強制する。
193
214
  */
194
215
  guards: UserAdminGuards
195
- /** ヘッダー注釈に自分の実効権限を表示するためのリクエスト別権限取得関数。 */
196
- getMyPermissions?: (request: Request) => Promise<Record<string, unknown>>
216
+ /**
217
+ * Per-request self-permission lookup for the header annotation. Resolving to a map of
218
+ * {@link UserAdminPermission} shows the badge; rejecting shows an explicit "unknown" badge — never a
219
+ * permissive one. Any authz library whose view carries `canRead` / `canWrite` / `canDelete` satisfies it.
220
+ * ヘッダー注釈に自分の実効権限を表示するためのリクエスト別権限取得関数。失敗時は「権限不明」表示。
221
+ */
222
+ getMyPermissions?: (request: Request) => Promise<Record<string, UserAdminPermission | undefined>>
223
+ /**
224
+ * Display names for the resource keys the badge shows, e.g. `{ authz_admin: "ユーザー管理 UI" }`.
225
+ * バッジに出すリソースキーの表示名。未指定のキーはキー文字列をそのまま表示。
226
+ *
227
+ * 表示名をこのパッケージ側に埋め込むと、別パッケージのリソースキーを名指しすることになる
228
+ * (`AGENTS.md` のクロスパッケージ・ハードコード禁止)。中立な差し込み口として受け取る。
229
+ */
230
+ resourceLabels?: Record<string, string>
197
231
  }
198
232
 
199
233
  /**
@@ -240,7 +274,7 @@ function optionalText(formData: FormData, field: string): string | undefined {
240
274
  export function createUserAdminApp(config: UserAdminAppConfig) {
241
275
  const labels = resolveUserAdminLabels(config.labels)
242
276
 
243
- async function loader({ request }: LoaderFunctionArgs) {
277
+ async function loader({ request }: RouteArgs) {
244
278
  // 読取ガード: 拒否は throw されるためここで処理が打ち切られる
245
279
  await config.guards.requireAccess(request, { action: "read", intent: "loader" })
246
280
  const url = new URL(request.url)
@@ -252,10 +286,17 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
252
286
  config.store.listGroups(),
253
287
  config.store.listGroupMembers(),
254
288
  config.store.listAllowlist(),
255
- config.getMyPermissions ? config.getMyPermissions(request).catch(() => ({})) : Promise.resolve({}),
289
+ // 取得できない場合は null (= 権限不明)。空マップに倒すと「付与ゼロ」と区別できなくなる
290
+ config.getMyPermissions
291
+ ? config.getMyPermissions(request).catch((e) => {
292
+ console.error("[@aiquants/auth-react-router] getMyPermissions failed; rendering the permission badge as unknown:", e)
293
+ return null
294
+ })
295
+ : Promise.resolve(null),
256
296
  ])
257
297
 
258
298
  return {
299
+ resourceLabels: config.resourceLabels,
259
300
  segment: ["groups", "group_members", "allowlist"].includes(segment) ? segment : "users",
260
301
  users,
261
302
  groups,
@@ -266,7 +307,7 @@ export function createUserAdminApp(config: UserAdminAppConfig) {
266
307
  }
267
308
  }
268
309
 
269
- async function action({ request }: ActionFunctionArgs) {
310
+ async function action({ request }: RouteArgs) {
270
311
  // intent の判定に FormData が要るため、ここでだけ本文を先に読む。
271
312
  // 未知 intent は「何も実行しない」ため、ガード前に読んでも権限判断には影響しない。
272
313
  const formData = await request.formData()
package/src/admin/ui.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import type React from "react"
2
2
  import { Link, useLoaderData } from "react-router"
3
3
  import { defaultUserAdminLabels, type UserAdminLabels } from "./labels"
4
+ import type { UserAdminPermission } from "./server"
4
5
 
5
6
  export type UserAdminShellProps = {
6
7
  children?: React.ReactNode
@@ -8,8 +9,8 @@ export type UserAdminShellProps = {
8
9
  }
9
10
 
10
11
  /**
11
- * Shell layout for user administration pages with header annotation delegation.
12
- * ヘッダー注釈委譲を備えたユーザー管理ページのシェルレイアウト。
12
+ * Render one resource's permission summary as a compact pill.
13
+ * 1 リソース分の権限サマリをコンパクトなタグとして描画するコンポーネント。
13
14
  */
14
15
  function GroupPill({ label, summary }: { label: string; summary: { text: string; color: string; bgColor: string; borderColor: string } }) {
15
16
  return (
@@ -33,11 +34,19 @@ function GroupPill({ label, summary }: { label: string; summary: { text: string;
33
34
  )
34
35
  }
35
36
 
37
+ /**
38
+ * Shell layout for user administration pages with header annotation delegation.
39
+ * ヘッダー注釈委譲を備えたユーザー管理ページのシェルレイアウト。
40
+ */
36
41
  export function UserAdminShell({ children, renderHeader }: UserAdminShellProps) {
37
- const data = useLoaderData() as { segment?: string; labels?: UserAdminLabels; myPermissions?: Record<string, unknown> } | undefined
42
+ const data = useLoaderData() as { segment?: string; labels?: UserAdminLabels; myPermissions?: Record<string, UserAdminPermission | undefined> | null; resourceLabels?: Record<string, string> } | undefined
38
43
  const segment = data?.segment ?? "users"
39
44
  const labels = data?.labels ?? defaultUserAdminLabels
40
- const myPermissions = data?.myPermissions ?? {}
45
+ // null = 未結線または取得失敗。空 {} も「分からない」扱いで、いずれも許可として描画しない
46
+ const myPermissions = data?.myPermissions ?? null
47
+ // リソースキーの表示名は呼び出し側が差し込む (キーをこのパッケージに埋め込まない)
48
+ const resourceLabels = data?.resourceLabels ?? {}
49
+ const labelFor = (resourceKey: string) => (Object.hasOwn(resourceLabels, resourceKey) ? resourceLabels[resourceKey] : resourceKey)
41
50
 
42
51
  const tabs = [
43
52
  { key: "users", label: labels.tabs.users, href: "/users" },
@@ -46,23 +55,25 @@ export function UserAdminShell({ children, renderHeader }: UserAdminShellProps)
46
55
  { key: "allowlist", label: labels.tabs.allowlist, href: "/users/allowlist" },
47
56
  ]
48
57
 
49
- const permEntries = Object.entries(myPermissions)
50
- const displayEntries = permEntries.length > 0 ? permEntries : [["user_admin", { read: true, write: true, delete: true, allowedActions: ["read", "create", "update", "delete"] }]]
51
-
52
- const getSummary = (perm: unknown) => {
53
- const p = perm as { read?: boolean; write?: boolean; delete?: boolean; allowedActions?: string[] } | undefined
54
- const allowed = p?.allowedActions ?? []
55
- const canWrite = p?.write ?? (allowed.includes("create") || allowed.includes("update") || allowed.includes("delete"))
56
- const canRead = p?.read ?? allowed.includes("read")
58
+ const permEntries = Object.entries(myPermissions ?? {})
57
59
 
58
- if (canWrite) {
60
+ /**
61
+ * Pick the badge text and colours for one resource, never rendering "unknown" as "allowed".
62
+ * 1 リソース分のバッジ文言と配色を決める処理。**未知を許可として描かない** ことが要件。
63
+ *
64
+ * 以前は権限マップが空のとき「編集可」の固定値を描いていたため、ポート未結線・取得失敗・
65
+ * 付与ゼロのいずれでも「全操作できます」と表示されていた (実際には操作するとガードで拒否される)。
66
+ */
67
+ const getSummary = (perm: UserAdminPermission | undefined) => {
68
+ if (perm?.canWrite) {
59
69
  return { text: "編集可", color: "#047857", bgColor: "#ecfdf5", borderColor: "#a7f3d0" }
60
70
  }
61
- if (canRead) {
71
+ if (perm?.canRead) {
62
72
  return { text: "閲覧のみ", color: "#1d4ed8", bgColor: "#eff6ff", borderColor: "#bfdbfe" }
63
73
  }
64
74
  return { text: "権限なし", color: "#64748b", bgColor: "#f1f5f9", borderColor: "#cbd5e1" }
65
75
  }
76
+ const unavailableSummary = { text: "権限不明", color: "#92400e", bgColor: "#fffbeb", borderColor: "#fde68a" }
66
77
 
67
78
  const annotation = (
68
79
  <div style={{ display: "flex", alignItems: "center", gap: "0.4rem" }} data-testid="user-admin-header-annotation">
@@ -71,9 +82,13 @@ export function UserAdminShell({ children, renderHeader }: UserAdminShellProps)
71
82
  <div style={{ display: "inline-flex", alignItems: "center", gap: "0.25rem", fontSize: "0.68rem", color: "#475569" }} data-testid="user-admin-permissions-badge">
72
83
  <span style={{ fontWeight: 600, color: "#334155" }}>あなたの権限:</span>
73
84
  <span style={{ display: "inline-flex", alignItems: "center", gap: "0.18rem" }}>
74
- {displayEntries.map(([resKey, perm], idx) => (
75
- <GroupPill key={String(resKey || idx)} label={resKey === "user_admin" ? "ユーザー管理UI" : String(resKey)} summary={getSummary(perm)} />
76
- ))}
85
+ {permEntries.length === 0 ? (
86
+ <span data-testid="user-admin-permission-unavailable">
87
+ <GroupPill label={labels.heading} summary={unavailableSummary} />
88
+ </span>
89
+ ) : (
90
+ permEntries.map(([resKey, perm]) => <GroupPill key={resKey} label={labelFor(resKey)} summary={getSummary(perm)} />)
91
+ )}
77
92
  </span>
78
93
  </div>
79
94
  </div>
@@ -1,300 +0,0 @@
1
- import { describe, expect, it } from "vitest"
2
- import { createInMemoryUserAdminStore, createUserAdminApp, type UserAdminGuards } from "./server"
3
-
4
- /** テスト用の常時許可ガード(ガード自体の検証は専用ケースで行う)。 */
5
- const allowAll = (): UserAdminGuards => ({ requireAccess: () => {} })
6
-
7
- /** intent + フィールドを FormData に載せて action を叩くヘルパ。 */
8
- function runActionOn(app: ReturnType<typeof createUserAdminApp>) {
9
- return (intent: string, fields: Record<string, string>) => {
10
- const formData = new FormData()
11
- formData.append("intent", intent)
12
- for (const [key, val] of Object.entries(fields)) formData.append(key, val)
13
- return app.action({
14
- request: new Request("http://localhost/users", { method: "POST", body: formData }),
15
- params: {},
16
- context: {},
17
- } as never)
18
- }
19
- }
20
-
21
- describe("UserAdminStore & createUserAdminApp", () => {
22
- it("manages users correctly in memory store and throws on non-existent ids", async () => {
23
- const store = createInMemoryUserAdminStore({ users: [] })
24
-
25
- const created = await store.createUser({ displayName: "テスト 太郎", email: "test@example.com" })
26
- expect(created.id).toBe(1)
27
- expect(created.displayName).toBe("テスト 太郎")
28
- expect(created.isActive).toBe(true)
29
-
30
- const updated = await store.updateUser(1, { displayName: "テスト 次郎", email: "updated@example.com" })
31
- expect(updated.displayName).toBe("テスト 次郎")
32
- expect(updated.email).toBe("updated@example.com")
33
-
34
- const toggled = await store.toggleUserActive(1, false)
35
- expect(toggled.isActive).toBe(false)
36
-
37
- await expect(store.updateUser(999, { displayName: "a", email: "b" })).rejects.toThrow("User not found: 999")
38
- await expect(store.toggleUserActive(999, true)).rejects.toThrow("User not found: 999")
39
-
40
- const list = await store.listUsers()
41
- expect(list).toHaveLength(1)
42
- })
43
-
44
- it("manages groups and memberships correctly and throws on non-existent group ids", async () => {
45
- const store = createInMemoryUserAdminStore({ users: [{ id: 10, displayName: "U10", email: "u10@ex.com", isActive: true }], groups: [], groupMembers: [] })
46
-
47
- const group = await store.createGroup({ groupKey: "qa-team", name: "QAチーム", description: "品質保証" })
48
- expect(group.id).toBe(1)
49
- expect(group.groupKey).toBe("qa-team")
50
-
51
- const updatedGroup = await store.updateGroup(1, { name: "QA・テストチーム", description: "品質管理全般" })
52
- expect(updatedGroup.name).toBe("QA・テストチーム")
53
-
54
- await expect(store.updateGroup(999, { name: "x" })).rejects.toThrow("Group not found: 999")
55
-
56
- const member1 = await store.addGroupMember(1, 10)
57
- expect(member1.groupId).toBe(1)
58
- expect(member1.userId).toBe(10)
59
-
60
- // Idempotent duplicate add
61
- const member2 = await store.addGroupMember(1, 10)
62
- expect(member2).toEqual(member1)
63
-
64
- const members = await store.listGroupMembers()
65
- expect(members).toHaveLength(1)
66
-
67
- await store.removeGroupMember(1, 10)
68
- expect(await store.listGroupMembers()).toHaveLength(0)
69
-
70
- await store.deleteGroup(1)
71
- expect(await store.listGroups()).toHaveLength(0)
72
- })
73
-
74
- it("manages allowlist entries correctly in memory store", async () => {
75
- const store = createInMemoryUserAdminStore({ allowlist: [] })
76
-
77
- const entry = await store.addAllowlistEntry({ pattern: "@domain.com", type: "domain", description: "許可ドメイン" })
78
- expect(entry.id).toBe(1)
79
- expect(entry.pattern).toBe("@domain.com")
80
-
81
- expect(await store.listAllowlist()).toHaveLength(1)
82
-
83
- await store.removeAllowlistEntry(1)
84
- expect(await store.listAllowlist()).toHaveLength(0)
85
- })
86
-
87
- it("handles all loader route segments via createUserAdminApp", async () => {
88
- const store = createInMemoryUserAdminStore()
89
- const app = createUserAdminApp({ store, guards: allowAll() })
90
-
91
- const segments = ["users", "groups", "group_members", "allowlist", "unknown_path"]
92
- for (const seg of segments) {
93
- const loaderRes = await app.loader({
94
- request: new Request(`http://localhost/users/${seg}`),
95
- params: {},
96
- context: {},
97
- })
98
- if (seg === "unknown_path") {
99
- expect(loaderRes.segment).toBe("users")
100
- } else {
101
- expect(loaderRes.segment).toBe(seg)
102
- }
103
- }
104
- })
105
-
106
- it("handles all action intents and PERSISTS each mutation to the store", async () => {
107
- const store = createInMemoryUserAdminStore()
108
- const app = createUserAdminApp({ store, guards: allowAll() })
109
- const run = runActionOn(app)
110
-
111
- // createUser — 戻り値だけでなく実際の永続結果を検証する
112
- expect(await run("createUser", { displayName: "John", email: "john@example.com" })).toEqual({ ok: true })
113
- const created = (await store.listUsers()).find((u) => u.email === "john@example.com")
114
- expect(created).toMatchObject({ displayName: "John", isActive: true })
115
-
116
- expect(await run("updateUser", { userId: String(created?.id), displayName: "John Updated", email: "john.updated@example.com" })).toEqual({ ok: true })
117
- expect((await store.listUsers()).find((u) => u.id === created?.id)).toMatchObject({ displayName: "John Updated", email: "john.updated@example.com" })
118
-
119
- expect(await run("toggleUserActive", { userId: String(created?.id), isActive: "false" })).toEqual({ ok: true })
120
- expect((await store.listUsers()).find((u) => u.id === created?.id)?.isActive).toBe(false)
121
-
122
- expect(await run("createGroup", { groupKey: "sec-team", name: "Security", description: "SecOps" })).toEqual({ ok: true })
123
- const group = (await store.listGroups()).find((g) => g.groupKey === "sec-team")
124
- expect(group).toMatchObject({ name: "Security", description: "SecOps" })
125
-
126
- expect(await run("updateGroup", { groupId: String(group?.id), name: "Security Team", description: "Updated" })).toEqual({ ok: true })
127
- expect((await store.listGroups()).find((g) => g.id === group?.id)).toMatchObject({ name: "Security Team", description: "Updated" })
128
-
129
- expect(await run("addGroupMember", { groupId: String(group?.id), userId: String(created?.id) })).toEqual({ ok: true })
130
- expect((await store.listGroupMembers()).some((m) => m.groupId === group?.id && m.userId === created?.id)).toBe(true)
131
-
132
- expect(await run("removeGroupMember", { groupId: String(group?.id), userId: String(created?.id) })).toEqual({ ok: true })
133
- expect((await store.listGroupMembers()).some((m) => m.groupId === group?.id && m.userId === created?.id)).toBe(false)
134
-
135
- expect(await run("addAllowlistEntry", { pattern: "@sec.com", type: "domain", description: "Sec" })).toEqual({ ok: true })
136
- expect(await run("addAllowlistEntry", { pattern: "user@sec.com", type: "email" })).toEqual({ ok: true })
137
- const allowlist = await store.listAllowlist()
138
- expect(allowlist.find((a) => a.pattern === "@sec.com")?.type).toBe("domain")
139
- expect(allowlist.find((a) => a.pattern === "user@sec.com")?.type).toBe("email")
140
-
141
- const removable = allowlist.find((a) => a.pattern === "@sec.com")
142
- expect(await run("removeAllowlistEntry", { entryId: String(removable?.id) })).toEqual({ ok: true })
143
- expect((await store.listAllowlist()).some((a) => a.pattern === "@sec.com")).toBe(false)
144
-
145
- // unknown intent
146
- expect(await run("unknownIntent", {})).toEqual({ ok: false, error: "Unknown intent: unknownIntent" })
147
- })
148
-
149
- it("deleteGroup cascades: the group's memberships are removed too (no orphan rows)", async () => {
150
- const store = createInMemoryUserAdminStore({
151
- users: [{ id: 10, displayName: "U10", email: "u10@ex.com", isActive: true }],
152
- groups: [{ id: 5, groupKey: "g5", name: "G5", isActive: true }],
153
- groupMembers: [{ id: 1, groupId: 5, userId: 10 }],
154
- })
155
- const app = createUserAdminApp({ store, guards: allowAll() })
156
-
157
- expect(await runActionOn(app)("deleteGroup", { groupId: "5" })).toEqual({ ok: true })
158
- expect((await store.listGroups()).some((g) => g.id === 5)).toBe(false)
159
- // 参照整合性: グループ削除で所属行も消えること (孤立行ゼロ)
160
- expect((await store.listGroupMembers()).some((m) => m.groupId === 5)).toEqual(false)
161
- })
162
-
163
- it("denies every read and write when the guards throw (fail-closed mount)", async () => {
164
- const store = createInMemoryUserAdminStore()
165
- const denied = new Error("403")
166
- const app = createUserAdminApp({
167
- store,
168
- guards: {
169
- requireAccess: () => {
170
- throw denied
171
- },
172
- },
173
- })
174
-
175
- await expect(app.loader({ request: new Request("http://localhost/users"), params: {}, context: {} } as never)).rejects.toThrow("403")
176
-
177
- const before = await store.listUsers()
178
- // ガード拒否はそのまま伝播する ({ok:false} に握り潰さない)
179
- await expect(runActionOn(app)("createUser", { displayName: "Attacker", email: "attacker@evil.example" })).rejects.toThrow("403")
180
- // 拒否時にストアが一切変化しないこと
181
- expect(await store.listUsers()).toEqual(before)
182
- })
183
-
184
- it("guards run BEFORE the body is read, and receive the incoming request", async () => {
185
- const store = createInMemoryUserAdminStore()
186
- const seen: string[] = []
187
- const app = createUserAdminApp({
188
- store,
189
- guards: {
190
- requireAccess: (request, ctx) => {
191
- seen.push(`${ctx.action}:${ctx.intent}:${new URL(request.url).pathname}`)
192
- },
193
- },
194
- })
195
-
196
- await app.loader({ request: new Request("http://localhost/users/groups"), params: {}, context: {} } as never)
197
- await runActionOn(app)("createUser", { displayName: "A", email: "a@ex.com" })
198
- expect(seen).toEqual(["read:loader:/users/groups", "create:createUser:/users"])
199
- })
200
-
201
- it("requires the CRUD verb each intent actually performs (delete is not covered by update)", async () => {
202
- const store = createInMemoryUserAdminStore()
203
- const asked: Array<{ action: string; intent: string }> = []
204
- const app = createUserAdminApp({ store, guards: { requireAccess: (_r, ctx) => void asked.push({ ...ctx }) } })
205
- const run = runActionOn(app)
206
-
207
- await run("createUser", { displayName: "A", email: "a@ex.com" })
208
- await run("updateGroup", { groupId: "1", name: "N" })
209
- await run("deleteGroup", { groupId: "1" })
210
- await run("addGroupMember", { groupId: "2", userId: "1" })
211
- await run("removeGroupMember", { groupId: "2", userId: "1" })
212
- await run("addAllowlistEntry", { pattern: "@x.com", type: "domain" })
213
- await run("removeAllowlistEntry", { entryId: "1" })
214
-
215
- expect(asked).toEqual([
216
- { action: "create", intent: "createUser" },
217
- { action: "update", intent: "updateGroup" },
218
- { action: "delete", intent: "deleteGroup" },
219
- { action: "create", intent: "addGroupMember" },
220
- { action: "delete", intent: "removeGroupMember" },
221
- { action: "create", intent: "addAllowlistEntry" },
222
- { action: "delete", intent: "removeAllowlistEntry" },
223
- ])
224
- })
225
-
226
- it("an update-only principal cannot delete (destructive intents are blocked, update ones pass)", async () => {
227
- const store = createInMemoryUserAdminStore({ groups: [{ id: 1, groupKey: "g", name: "G", isActive: true }], users: [], groupMembers: [], allowlist: [] })
228
- // update だけを許す主体を模す
229
- const app = createUserAdminApp({
230
- store,
231
- guards: {
232
- requireAccess: (_r, { action }) => {
233
- if (action !== "update" && action !== "read") throw new Error(`forbidden: ${action}`)
234
- },
235
- },
236
- })
237
- const run = runActionOn(app)
238
-
239
- await expect(run("deleteGroup", { groupId: "1" })).rejects.toThrow("forbidden: delete")
240
- expect((await store.listGroups()).some((g) => g.id === 1)).toBe(true) // 削除されていない
241
- await expect(run("createUser", { displayName: "A", email: "a@ex.com" })).rejects.toThrow("forbidden: create")
242
- // update 相当は通る
243
- expect(await run("updateGroup", { groupId: "1", name: "G2" })).toEqual({ ok: true })
244
- })
245
-
246
- it("an unknown intent never reaches the guard nor the store", async () => {
247
- const store = createInMemoryUserAdminStore()
248
- let called = 0
249
- const app = createUserAdminApp({ store, guards: { requireAccess: () => void called++ } })
250
- expect(await runActionOn(app)("__proto__", {})).toEqual({ ok: false, error: "Unknown intent: __proto__" })
251
- expect(await runActionOn(app)("constructor", {})).toEqual({ ok: false, error: "Unknown intent: constructor" })
252
- expect(called).toBe(0)
253
- })
254
-
255
- it("rejects malformed ids and blank required fields instead of writing garbage", async () => {
256
- const store = createInMemoryUserAdminStore({ users: [], groups: [], groupMembers: [], allowlist: [] })
257
- const run = runActionOn(createUserAdminApp({ store, guards: allowAll() }))
258
-
259
- // 欠落 / 非数値 / 0 以下の ID は書き込む前に弾き、理由を UI へ返す
260
- expect(await run("updateUser", { displayName: "x", email: "y" })).toMatchObject({ ok: false, error: expect.stringMatching(/userId/) })
261
- expect(await run("deleteGroup", { groupId: "abc" })).toMatchObject({ ok: false, error: expect.stringMatching(/groupId/) })
262
- expect(await run("addGroupMember", { groupId: "1", userId: "0" })).toMatchObject({ ok: false, error: expect.stringMatching(/userId/) })
263
- expect(await run("removeAllowlistEntry", { entryId: "-1" })).toMatchObject({ ok: false, error: expect.stringMatching(/entryId/) })
264
- // 必須文字列の空送信も弾く
265
- expect(await run("createUser", { displayName: " ", email: "a@ex.com" })).toMatchObject({ ok: false, error: expect.stringMatching(/displayName/) })
266
- expect(await run("createGroup", { groupKey: "k", name: "" })).toMatchObject({ ok: false, error: expect.stringMatching(/name/) })
267
- // 明示的な true/false 以外の isActive も拒否 (破壊側を既定にしない)
268
- expect(await run("toggleUserActive", { userId: "1" })).toMatchObject({ ok: false, error: expect.stringMatching(/isActive/) })
269
-
270
- expect(await store.listUsers()).toHaveLength(0)
271
- expect(await store.listGroups()).toHaveLength(0)
272
- expect(await store.listGroupMembers()).toHaveLength(0)
273
- expect(await store.listAllowlist()).toHaveLength(0)
274
- })
275
- })
276
-
277
- describe("createUserAdminApp — ストア由来のエラーを UI へ届ける", () => {
278
- it("ストアが投げた業務エラー (ロックアウト理由など) を {ok:false,error} で返す", async () => {
279
- const store = createInMemoryUserAdminStore()
280
- store.deleteGroup = async () => {
281
- throw new Error("グループの削除は実行できません: 最後の管理者を失うためです")
282
- }
283
- const app = createUserAdminApp({ store, guards: { requireAccess: () => {} } })
284
- expect(await runActionOn(app)("deleteGroup", { groupId: "1" })).toEqual({ ok: false, error: "グループの削除は実行できません: 最後の管理者を失うためです" })
285
- })
286
-
287
- it("ガードの拒否 (Response) は握り潰さずそのまま伝播する", async () => {
288
- const store = createInMemoryUserAdminStore()
289
- const denied = new Response("Forbidden", { status: 403 })
290
- const app = createUserAdminApp({
291
- store,
292
- guards: {
293
- requireAccess: () => {
294
- throw denied
295
- },
296
- },
297
- })
298
- await expect(runActionOn(app)("deleteGroup", { groupId: "1" })).rejects.toBe(denied)
299
- })
300
- })