@aiquants/auth-react-router 0.4.0 → 0.5.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 +39 -1
- package/dist/admin.d.mts +268 -0
- package/dist/admin.d.ts +268 -0
- package/dist/admin.js +2 -0
- package/dist/admin.js.map +1 -0
- package/dist/admin.mjs +2 -0
- package/dist/admin.mjs.map +1 -0
- package/dist/client.d.mts +2 -2
- package/dist/client.d.ts +2 -2
- package/dist/server.d.mts +23 -4
- package/dist/server.d.ts +23 -4
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +1 -1
- package/dist/server.mjs.map +1 -1
- package/dist/styles/auth-react-router.standalone.css +1 -1
- package/package.json +10 -3
- package/src/admin/app.ts +4 -0
- package/src/admin/labels.ts +274 -0
- package/src/admin/server.spec.ts +300 -0
- package/src/admin/server.ts +345 -0
- package/src/admin/ui.tsx +71 -0
- package/src/admin/views.spec.tsx +307 -0
- package/src/admin/views.tsx +696 -0
- package/src/admin.ts +5 -0
- package/src/server/auth-server.ts +1 -0
- package/src/server/google-strategy-impl.spec.ts +8 -0
- package/src/server/google-strategy-impl.ts +7 -1
- package/src/server/google-strategy.spec.ts +56 -0
- package/src/server/google-strategy.ts +10 -1
- package/src/server/ports.ts +22 -4
- package/src/server/session.spec.ts +52 -0
- package/src/server/session.ts +7 -2
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Labels and i18n strings for the user administration UI.
|
|
3
|
+
* ユーザー管理 UI で使用するラベルと i18n 文字列の定義。
|
|
4
|
+
*
|
|
5
|
+
* パッケージ既定は **英語** ([defaultUserAdminLabels])。日本語で使う場合はアプリ側が
|
|
6
|
+
* [jaUserAdminLabels] を明示的に注入する (認可管理 `@aiquants/authz-react-router` と同じ規約)。
|
|
7
|
+
* UI 文字列はすべて本モジュールに集約し、コンポーネント側へ直書きしないこと — 直書きすると
|
|
8
|
+
* 日本語以外の利用者がその箇所を差し替えられなくなる。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type UserAdminLabels = {
|
|
12
|
+
title: string
|
|
13
|
+
/** シェル (タブ枠) の見出し。 */
|
|
14
|
+
heading: string
|
|
15
|
+
tabs: {
|
|
16
|
+
users: string
|
|
17
|
+
groups: string
|
|
18
|
+
groupMembers: string
|
|
19
|
+
allowlist: string
|
|
20
|
+
}
|
|
21
|
+
usersView: {
|
|
22
|
+
title: string
|
|
23
|
+
searchPlaceholder: string
|
|
24
|
+
addUser: string
|
|
25
|
+
editUser: string
|
|
26
|
+
id: string
|
|
27
|
+
displayName: string
|
|
28
|
+
email: string
|
|
29
|
+
status: string
|
|
30
|
+
active: string
|
|
31
|
+
inactive: string
|
|
32
|
+
actions: string
|
|
33
|
+
edit: string
|
|
34
|
+
activate: string
|
|
35
|
+
deactivate: string
|
|
36
|
+
empty: string
|
|
37
|
+
save: string
|
|
38
|
+
cancel: string
|
|
39
|
+
}
|
|
40
|
+
groupsView: {
|
|
41
|
+
title: string
|
|
42
|
+
searchPlaceholder: string
|
|
43
|
+
addGroup: string
|
|
44
|
+
editGroup: string
|
|
45
|
+
id: string
|
|
46
|
+
groupKey: string
|
|
47
|
+
name: string
|
|
48
|
+
description: string
|
|
49
|
+
memberCount: string
|
|
50
|
+
status: string
|
|
51
|
+
actions: string
|
|
52
|
+
edit: string
|
|
53
|
+
delete: string
|
|
54
|
+
confirmDelete: string
|
|
55
|
+
noDescription: string
|
|
56
|
+
groupKeyPlaceholder: string
|
|
57
|
+
namePlaceholder: string
|
|
58
|
+
empty: string
|
|
59
|
+
save: string
|
|
60
|
+
cancel: string
|
|
61
|
+
}
|
|
62
|
+
groupMembersView: {
|
|
63
|
+
selectGroup: string
|
|
64
|
+
selectPrompt: string
|
|
65
|
+
actionAdd: string
|
|
66
|
+
actionRemove: string
|
|
67
|
+
}
|
|
68
|
+
allowlistView: {
|
|
69
|
+
title: string
|
|
70
|
+
addEntry: string
|
|
71
|
+
pattern: string
|
|
72
|
+
type: string
|
|
73
|
+
emailType: string
|
|
74
|
+
domainType: string
|
|
75
|
+
/** 一覧のバッジ表示 (短縮形)。 */
|
|
76
|
+
emailTypeShort: string
|
|
77
|
+
domainTypeShort: string
|
|
78
|
+
description: string
|
|
79
|
+
actions: string
|
|
80
|
+
/** 変更不能な供給元 (環境変数など) 由来の行に出す表示。 */
|
|
81
|
+
readOnly: string
|
|
82
|
+
delete: string
|
|
83
|
+
empty: string
|
|
84
|
+
save: string
|
|
85
|
+
cancel: string
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Package default labels (English).
|
|
91
|
+
* パッケージ既定ラベル (英語)。
|
|
92
|
+
*/
|
|
93
|
+
export const defaultUserAdminLabels: UserAdminLabels = {
|
|
94
|
+
title: "Users & Groups",
|
|
95
|
+
heading: "👤 User administration",
|
|
96
|
+
tabs: {
|
|
97
|
+
users: "👤 Users",
|
|
98
|
+
groups: "👥 Groups",
|
|
99
|
+
groupMembers: "🔗 Members",
|
|
100
|
+
allowlist: "✉️ Allowlist",
|
|
101
|
+
},
|
|
102
|
+
usersView: {
|
|
103
|
+
title: "Users",
|
|
104
|
+
searchPlaceholder: "Search by name or email...",
|
|
105
|
+
addUser: "+ Add user",
|
|
106
|
+
editUser: "Edit user",
|
|
107
|
+
id: "ID",
|
|
108
|
+
displayName: "Name",
|
|
109
|
+
email: "Email",
|
|
110
|
+
status: "Status",
|
|
111
|
+
active: "Active",
|
|
112
|
+
inactive: "Inactive",
|
|
113
|
+
actions: "Actions",
|
|
114
|
+
edit: "Edit",
|
|
115
|
+
activate: "Activate",
|
|
116
|
+
deactivate: "Deactivate",
|
|
117
|
+
empty: "No matching users",
|
|
118
|
+
save: "Save",
|
|
119
|
+
cancel: "Cancel",
|
|
120
|
+
},
|
|
121
|
+
groupsView: {
|
|
122
|
+
title: "Groups",
|
|
123
|
+
searchPlaceholder: "Search by group name or key...",
|
|
124
|
+
addGroup: "+ Add group",
|
|
125
|
+
editGroup: "Edit group",
|
|
126
|
+
id: "ID",
|
|
127
|
+
groupKey: "Group key",
|
|
128
|
+
name: "Name",
|
|
129
|
+
description: "Description",
|
|
130
|
+
memberCount: "Members",
|
|
131
|
+
status: "Status",
|
|
132
|
+
actions: "Actions",
|
|
133
|
+
edit: "Edit",
|
|
134
|
+
delete: "Delete",
|
|
135
|
+
confirmDelete: "Delete this group?",
|
|
136
|
+
noDescription: "No description",
|
|
137
|
+
groupKeyPlaceholder: "e.g. dev-team",
|
|
138
|
+
namePlaceholder: "e.g. Development team",
|
|
139
|
+
empty: "No groups registered",
|
|
140
|
+
save: "Save",
|
|
141
|
+
cancel: "Cancel",
|
|
142
|
+
},
|
|
143
|
+
groupMembersView: {
|
|
144
|
+
selectGroup: "Select a group",
|
|
145
|
+
selectPrompt: "Select a group to manage its members",
|
|
146
|
+
actionAdd: "Add",
|
|
147
|
+
actionRemove: "Remove",
|
|
148
|
+
},
|
|
149
|
+
allowlistView: {
|
|
150
|
+
title: "Sign-in allowlist",
|
|
151
|
+
addEntry: "+ Add pattern",
|
|
152
|
+
pattern: "Pattern (e.g. @example.com or user@example.com)",
|
|
153
|
+
type: "Type",
|
|
154
|
+
emailType: "Exact email match",
|
|
155
|
+
domainType: "Domain match",
|
|
156
|
+
emailTypeShort: "Email",
|
|
157
|
+
domainTypeShort: "Domain",
|
|
158
|
+
description: "Description",
|
|
159
|
+
actions: "Actions",
|
|
160
|
+
readOnly: "Read-only",
|
|
161
|
+
delete: "Delete",
|
|
162
|
+
empty: "No allowlist entries registered",
|
|
163
|
+
save: "Add",
|
|
164
|
+
cancel: "Cancel",
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Standard Japanese labels for user administration (opt-in).
|
|
170
|
+
* ユーザー管理の標準日本語ラベル (アプリ側で明示注入して使う)。
|
|
171
|
+
*/
|
|
172
|
+
export const jaUserAdminLabels: UserAdminLabels = {
|
|
173
|
+
title: "ユーザー・グループ管理",
|
|
174
|
+
heading: "👤 ユーザー管理",
|
|
175
|
+
tabs: {
|
|
176
|
+
users: "👤 ユーザー一覧",
|
|
177
|
+
groups: "👥 ユーザーグループ",
|
|
178
|
+
groupMembers: "🔗 グループメンバー",
|
|
179
|
+
allowlist: "✉️ アクセス許可リスト",
|
|
180
|
+
},
|
|
181
|
+
usersView: {
|
|
182
|
+
title: "ユーザー管理",
|
|
183
|
+
searchPlaceholder: "表示名や Email で検索...",
|
|
184
|
+
addUser: "+ ユーザー追加",
|
|
185
|
+
editUser: "ユーザーの編集",
|
|
186
|
+
id: "ID",
|
|
187
|
+
displayName: "表示名",
|
|
188
|
+
email: "メールアドレス",
|
|
189
|
+
status: "ステータス",
|
|
190
|
+
active: "有効",
|
|
191
|
+
inactive: "無効",
|
|
192
|
+
actions: "操作",
|
|
193
|
+
edit: "編集",
|
|
194
|
+
activate: "有効化",
|
|
195
|
+
deactivate: "無効化",
|
|
196
|
+
empty: "該当するユーザーが見つかりません",
|
|
197
|
+
save: "保存",
|
|
198
|
+
cancel: "キャンセル",
|
|
199
|
+
},
|
|
200
|
+
groupsView: {
|
|
201
|
+
title: "ユーザーグループ管理",
|
|
202
|
+
searchPlaceholder: "グループ名やキーで検索...",
|
|
203
|
+
addGroup: "+ グループ作成",
|
|
204
|
+
editGroup: "グループの編集",
|
|
205
|
+
id: "ID",
|
|
206
|
+
groupKey: "グループキー",
|
|
207
|
+
name: "グループ名",
|
|
208
|
+
description: "説明",
|
|
209
|
+
memberCount: "メンバー数",
|
|
210
|
+
status: "ステータス",
|
|
211
|
+
actions: "操作",
|
|
212
|
+
edit: "編集",
|
|
213
|
+
delete: "削除",
|
|
214
|
+
confirmDelete: "このグループを削除してもよろしいですか?",
|
|
215
|
+
noDescription: "説明なし",
|
|
216
|
+
groupKeyPlaceholder: "例: dev-team",
|
|
217
|
+
namePlaceholder: "例: 開発チーム",
|
|
218
|
+
empty: "グループが登録されていません",
|
|
219
|
+
save: "保存",
|
|
220
|
+
cancel: "キャンセル",
|
|
221
|
+
},
|
|
222
|
+
groupMembersView: {
|
|
223
|
+
selectGroup: "グループ選択",
|
|
224
|
+
selectPrompt: "グループを選択してください",
|
|
225
|
+
actionAdd: "追加",
|
|
226
|
+
actionRemove: "除外",
|
|
227
|
+
},
|
|
228
|
+
allowlistView: {
|
|
229
|
+
title: "アクセス許可リスト (Allowlist)",
|
|
230
|
+
addEntry: "+ 許可パターン追加",
|
|
231
|
+
pattern: "パターン (例: @example.com または user@example.com)",
|
|
232
|
+
type: "種別",
|
|
233
|
+
emailType: "メールアドレス完全一致",
|
|
234
|
+
domainType: "ドメイン一致",
|
|
235
|
+
emailTypeShort: "Email 完全一致",
|
|
236
|
+
domainTypeShort: "ドメイン一致",
|
|
237
|
+
description: "説明 / 備考",
|
|
238
|
+
actions: "操作",
|
|
239
|
+
readOnly: "変更不可",
|
|
240
|
+
delete: "削除",
|
|
241
|
+
empty: "アロワリストエントリが登録されていません",
|
|
242
|
+
save: "登録",
|
|
243
|
+
cancel: "キャンセル",
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** セクション単位の部分上書き。 */
|
|
248
|
+
export type PartialUserAdminLabels = {
|
|
249
|
+
title?: string
|
|
250
|
+
heading?: string
|
|
251
|
+
tabs?: Partial<UserAdminLabels["tabs"]>
|
|
252
|
+
usersView?: Partial<UserAdminLabels["usersView"]>
|
|
253
|
+
groupsView?: Partial<UserAdminLabels["groupsView"]>
|
|
254
|
+
groupMembersView?: Partial<UserAdminLabels["groupMembersView"]>
|
|
255
|
+
allowlistView?: Partial<UserAdminLabels["allowlistView"]>
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Shallow per-section merge over the English defaults.
|
|
260
|
+
* 英語既定に対してセクション単位で浅くマージする処理。
|
|
261
|
+
*/
|
|
262
|
+
export function resolveUserAdminLabels(over?: PartialUserAdminLabels): UserAdminLabels {
|
|
263
|
+
const d = defaultUserAdminLabels
|
|
264
|
+
if (!over) return d
|
|
265
|
+
return {
|
|
266
|
+
title: over.title ?? d.title,
|
|
267
|
+
heading: over.heading ?? d.heading,
|
|
268
|
+
tabs: { ...d.tabs, ...over.tabs },
|
|
269
|
+
usersView: { ...d.usersView, ...over.usersView },
|
|
270
|
+
groupsView: { ...d.groupsView, ...over.groupsView },
|
|
271
|
+
groupMembersView: { ...d.groupMembersView, ...over.groupMembersView },
|
|
272
|
+
allowlistView: { ...d.allowlistView, ...over.allowlistView },
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
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
|
+
})
|