@aiquants/daily-report 0.20.0 → 0.21.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.
Files changed (116) hide show
  1. package/README.md +229 -8
  2. package/dist/client.d.mts +201 -739
  3. package/dist/client.d.ts +201 -739
  4. package/dist/client.js +5 -6
  5. package/dist/client.mjs +5 -6
  6. package/dist/index.d.mts +5 -197
  7. package/dist/index.d.ts +5 -197
  8. package/dist/index.js +1 -2
  9. package/dist/index.mjs +1 -2
  10. package/dist/logger-D3krZrNK.d.mts +0 -14
  11. package/dist/logger-D3krZrNK.d.ts +0 -14
  12. package/dist/server.d.mts +114 -744
  13. package/dist/server.d.ts +114 -744
  14. package/dist/server.js +1 -2
  15. package/dist/server.mjs +1 -2
  16. package/dist/{sse-schema-ChSFm-4q.d.mts → sse-schema-DCxpdETM.d.mts} +1106 -1090
  17. package/dist/{sse-schema-BdUGThiz.d.ts → sse-schema-DoQw8_om.d.ts} +1106 -1090
  18. package/dist/types-CqgYQgbH.d.mts +0 -34
  19. package/dist/types-CqgYQgbH.d.ts +0 -34
  20. package/package.json +9 -10
  21. package/dist/client.js.map +0 -1
  22. package/dist/client.mjs.map +0 -1
  23. package/dist/index.js.map +0 -1
  24. package/dist/index.mjs.map +0 -1
  25. package/dist/server.js.map +0 -1
  26. package/dist/server.mjs.map +0 -1
  27. package/src/client/components/business-day-thumb-overlay.tsx +0 -19
  28. package/src/client/components/daily-report-attachment-indicator.spec.tsx +0 -51
  29. package/src/client/components/daily-report-attachment-indicator.tsx +0 -45
  30. package/src/client/components/daily-report-attachment-list.spec.tsx +0 -94
  31. package/src/client/components/daily-report-attachment-list.tsx +0 -112
  32. package/src/client/components/daily-report-comment-item.tsx +0 -89
  33. package/src/client/components/daily-report-comment-section.tsx +0 -166
  34. package/src/client/components/daily-report-detail-list.tsx +0 -842
  35. package/src/client/components/daily-report-edit-form.tsx +0 -87
  36. package/src/client/components/daily-report-ids-stream-status.spec.tsx +0 -93
  37. package/src/client/components/daily-report-ids-stream-status.tsx +0 -67
  38. package/src/client/components/daily-report-list.tsx +0 -1232
  39. package/src/client/components/daily-report-page.spec.tsx +0 -119
  40. package/src/client/components/daily-report-page.tsx +0 -140
  41. package/src/client/components/daily-report-resize-panels.spec.tsx +0 -307
  42. package/src/client/components/daily-report-resolved-content.tsx +0 -207
  43. package/src/client/components/daily-report-scroll-restoration.spec.tsx +0 -422
  44. package/src/client/components/report-views.spec.tsx +0 -375
  45. package/src/client/components/unread-indicator.tsx +0 -13
  46. package/src/client/config-context.tsx +0 -184
  47. package/src/client/contexts/daily-report-action-context.spec.tsx +0 -433
  48. package/src/client/contexts/daily-report-action-context.tsx +0 -1147
  49. package/src/client/contexts/daily-report-error-context.spec.tsx +0 -66
  50. package/src/client/contexts/daily-report-error-context.tsx +0 -98
  51. package/src/client/hooks/use-animated-number.spec.ts +0 -103
  52. package/src/client/hooks/use-animated-number.ts +0 -69
  53. package/src/client/hooks/use-daily-report-comments.ts +0 -73
  54. package/src/client/hooks/use-daily-report-sse-connection.ts +0 -86
  55. package/src/client/hooks/use-daily-report.spec.ts +0 -546
  56. package/src/client/hooks/use-daily-report.ts +0 -549
  57. package/src/client/hooks/use-dynamic-viewport-height.ts +0 -127
  58. package/src/client/hooks/use-responsive-layout.spec.ts +0 -74
  59. package/src/client/hooks/use-responsive-layout.ts +0 -63
  60. package/src/client/route-helpers.spec.ts +0 -100
  61. package/src/client/route-helpers.ts +0 -61
  62. package/src/client/streaming/daily-report-ids-stream-client.spec.ts +0 -910
  63. package/src/client/streaming/daily-report-ids-stream-client.ts +0 -730
  64. package/src/client/streaming/daily-report-ids-stream-session.spec.ts +0 -401
  65. package/src/client/streaming/daily-report-ids-stream-session.ts +0 -319
  66. package/src/client/ui/button.tsx +0 -42
  67. package/src/client/ui/cn.ts +0 -14
  68. package/src/client/ui/input.tsx +0 -21
  69. package/src/client/ui/label.tsx +0 -16
  70. package/src/client/ui/switch.tsx +0 -19
  71. package/src/client/ui/tabs.tsx +0 -40
  72. package/src/client/ui/textarea.tsx +0 -19
  73. package/src/client/utils/constants.spec.ts +0 -96
  74. package/src/client/utils/constants.ts +0 -84
  75. package/src/client.ts +0 -28
  76. package/src/index.ts +0 -11
  77. package/src/lucide-icons.d.ts +0 -6
  78. package/src/server/authz.spec.ts +0 -57
  79. package/src/server/authz.ts +0 -99
  80. package/src/server/cache.spec.ts +0 -556
  81. package/src/server/cache.ts +0 -303
  82. package/src/server/etag.ts +0 -18
  83. package/src/server/external-source.ts +0 -60
  84. package/src/server/handlers.action.spec.ts +0 -44
  85. package/src/server/handlers.attachment.spec.ts +0 -470
  86. package/src/server/handlers.ids-stream.spec.ts +0 -618
  87. package/src/server/handlers.ts +0 -1638
  88. package/src/server/handlers.unauthorized-cookie.spec.ts +0 -187
  89. package/src/server/handlers.visibility.spec.ts +0 -774
  90. package/src/server/ports.ts +0 -222
  91. package/src/server/response.ts +0 -37
  92. package/src/server/schema.spec.ts +0 -30
  93. package/src/server/schema.ts +0 -344
  94. package/src/server/service.spec.ts +0 -1130
  95. package/src/server/service.ts +0 -2045
  96. package/src/server/sse-reader.spec.ts +0 -302
  97. package/src/server/sse-reader.ts +0 -267
  98. package/src/server/test-helpers/handlers-config.ts +0 -321
  99. package/src/server/transaction-retry.spec.ts +0 -63
  100. package/src/server/visibility.spec.ts +0 -403
  101. package/src/server/visibility.ts +0 -373
  102. package/src/server.spec.ts +0 -160
  103. package/src/server.ts +0 -131
  104. package/src/shared/business-date.spec.ts +0 -82
  105. package/src/shared/business-date.ts +0 -104
  106. package/src/shared/comment-adapter.spec.ts +0 -38
  107. package/src/shared/comment-adapter.ts +0 -80
  108. package/src/shared/ids-stream.spec.ts +0 -154
  109. package/src/shared/ids-stream.ts +0 -279
  110. package/src/shared/logger.ts +0 -47
  111. package/src/shared/sse-schema.spec.ts +0 -50
  112. package/src/shared/sse-schema.ts +0 -198
  113. package/src/shared/text-utils.spec.ts +0 -42
  114. package/src/shared/text-utils.ts +0 -65
  115. package/src/shared/types.ts +0 -109
  116. package/src/styles/standalone.entry.css +0 -51
@@ -1,403 +0,0 @@
1
- /**
2
- * Unit tests for the source-type visibility helpers.
3
- * sourceType 可視性ヘルパーの単体テスト。
4
- *
5
- * ここで固定するのは「無制限 (null) と全拒否 (空集合) を決して取り違えない」という不変条件であり、
6
- * 取り違えは情報漏洩 (全件配信) か機能停止 (0 件) に直結する。
7
- */
8
-
9
- import { bigint, MsSqlDialect, mssqlSchema, nvarchar, primaryKey } from "drizzle-orm/mssql-core"
10
- import { describe, expect, it } from "vitest"
11
- import { buildVisibleSourceTypeCondition, computeSourceTypeDigest, computeViewerScopeDigest, DailyReportViewerScope, deniesEverySourceType, isSourceTypeVisible, normalizeVisibleSourceTypes } from "./visibility"
12
-
13
- describe("normalizeVisibleSourceTypes", () => {
14
- it("undefined と null は無制限 (null) になる", () => {
15
- expect(normalizeVisibleSourceTypes(undefined)).toBeNull()
16
- expect(normalizeVisibleSourceTypes(null)).toBeNull()
17
- })
18
-
19
- it("空配列は空集合 (0 件) であり、無制限へは倒れない", () => {
20
- const result = normalizeVisibleSourceTypes([])
21
- // ❗ ここが null になると「権限なし」が「全件可視」へ反転する
22
- expect(result).not.toBeNull()
23
- expect(result?.size).toBe(0)
24
- })
25
-
26
- it("大小文字を畳み込み、前後空白を除去し、重複と空文字を落とす", () => {
27
- const result = normalizeVisibleSourceTypes(["NI", " ni ", "kintone", "", " ", "Kintone"])
28
- expect([...(result ?? [])].sort()).toEqual(["kintone", "ni"])
29
- })
30
-
31
- it("非文字列要素は可視性を広げずに捨てられる", () => {
32
- const raw = ["NI", 42, null, undefined, {}] as unknown as readonly string[]
33
- const result = normalizeVisibleSourceTypes(raw)
34
- expect([...(result ?? [])]).toEqual(["ni"])
35
- })
36
-
37
- it("配列以外 (契約違反の文字列) は文字反復せず全拒否になる", () => {
38
- // `for...of "NI"` が文字単位で回ると {"n","i"} という誤った可視集合になる
39
- const result = normalizeVisibleSourceTypes("NI" as unknown as readonly string[])
40
- expect(result?.size).toBe(0)
41
- })
42
- })
43
-
44
- describe("isSourceTypeVisible", () => {
45
- it("無制限 (null) は sourceType を問わず常に可視", () => {
46
- expect(isSourceTypeVisible(null, "NI")).toBe(true)
47
- expect(isSourceTypeVisible(null, null)).toBe(true)
48
- expect(isSourceTypeVisible(null, undefined)).toBe(true)
49
- })
50
-
51
- it("限定時は大小文字を無視して一致判定する", () => {
52
- const set = normalizeVisibleSourceTypes(["ni", "kintone"])
53
- expect(isSourceTypeVisible(set, "NI")).toBe(true)
54
- expect(isSourceTypeVisible(set, "Kintone")).toBe(true)
55
- // 列値のトリムは行わない (SQL 述語と揃えるため。詳細は「JS 判定と SQL 述語の対称性」)
56
- expect(isSourceTypeVisible(set, " ni ")).toBe(false)
57
- })
58
-
59
- it("限定時に集合外の区分は不可視", () => {
60
- const set = normalizeVisibleSourceTypes(["kintone"])
61
- expect(isSourceTypeVisible(set, "NI")).toBe(false)
62
- expect(isSourceTypeVisible(set, "Internal")).toBe(false)
63
- })
64
-
65
- it("限定時に区分不明 (null / undefined) は不可視 (fail-close)", () => {
66
- const set = normalizeVisibleSourceTypes(["ni"])
67
- expect(isSourceTypeVisible(set, null)).toBe(false)
68
- expect(isSourceTypeVisible(set, undefined)).toBe(false)
69
- })
70
-
71
- it("空集合はいかなる区分も不可視", () => {
72
- const set = normalizeVisibleSourceTypes([])
73
- expect(isSourceTypeVisible(set, "NI")).toBe(false)
74
- expect(isSourceTypeVisible(set, "Internal")).toBe(false)
75
- })
76
- })
77
-
78
- describe("computeSourceTypeDigest", () => {
79
- it("無制限と全拒否は互いに異なる固定値になる", () => {
80
- expect(computeSourceTypeDigest(null)).toBe("all")
81
- expect(computeSourceTypeDigest(normalizeVisibleSourceTypes([]))).toBe("none")
82
- })
83
-
84
- it("正規化後に等価な集合は同一ダイジェストになる", () => {
85
- const a = computeSourceTypeDigest(normalizeVisibleSourceTypes(["NI"]))
86
- const b = computeSourceTypeDigest(normalizeVisibleSourceTypes([" ni "]))
87
- expect(a).toBe(b)
88
- })
89
-
90
- it("要素順に依存しない", () => {
91
- const a = computeSourceTypeDigest(normalizeVisibleSourceTypes(["ni", "kintone"]))
92
- const b = computeSourceTypeDigest(normalizeVisibleSourceTypes(["kintone", "ni"]))
93
- expect(a).toBe(b)
94
- })
95
-
96
- it("論理的に異なる集合は必ず別のダイジェストになる (相互汚染の防止)", () => {
97
- const digests = [
98
- computeSourceTypeDigest(null),
99
- computeSourceTypeDigest(normalizeVisibleSourceTypes([])),
100
- computeSourceTypeDigest(normalizeVisibleSourceTypes(["ni"])),
101
- computeSourceTypeDigest(normalizeVisibleSourceTypes(["kintone"])),
102
- computeSourceTypeDigest(normalizeVisibleSourceTypes(["internal"])),
103
- computeSourceTypeDigest(normalizeVisibleSourceTypes(["ni", "kintone"])),
104
- computeSourceTypeDigest(normalizeVisibleSourceTypes(["ni", "internal"])),
105
- computeSourceTypeDigest(normalizeVisibleSourceTypes(["ni", "kintone", "internal"])),
106
- ]
107
- expect(new Set(digests).size).toBe(digests.length)
108
- })
109
-
110
- it("sentinel と同名のトークンだけを許された閲覧者が無制限 / 全拒否とキャッシュを共有しない", () => {
111
- // "all" や "none" へ畳み込まれるトークンだけを持つ集合が sentinel と同じ digest になると、
112
- // その閲覧者と無制限閲覧者が同じキャッシュバケットを共有してしまう
113
- expect(computeSourceTypeDigest(normalizeVisibleSourceTypes(["all"]))).not.toBe("all")
114
- expect(computeSourceTypeDigest(normalizeVisibleSourceTypes(["none"]))).not.toBe("none")
115
- expect(computeSourceTypeDigest(normalizeVisibleSourceTypes(["All"]))).not.toBe(computeSourceTypeDigest(null))
116
- expect(computeSourceTypeDigest(normalizeVisibleSourceTypes(["NONE"]))).not.toBe(computeSourceTypeDigest(normalizeVisibleSourceTypes([])))
117
- })
118
-
119
- it("区切り文字を含みうるトークンでも連結が単射になる (長さ接頭辞)", () => {
120
- // 長さ接頭辞が無いと、区切り位置の異なる別集合が同じ連結結果になりうる
121
- const a = computeSourceTypeDigest(normalizeVisibleSourceTypes(["ab", "c"]))
122
- const b = computeSourceTypeDigest(normalizeVisibleSourceTypes(["a", "bc"]))
123
- expect(a).not.toBe(b)
124
- })
125
-
126
- it("巨大な集合はハッシュへ退避しつつキー長が有界になる", () => {
127
- const many = Array.from({ length: 200 }, (_, index) => `source-type-${index}`)
128
- const digest = computeSourceTypeDigest(normalizeVisibleSourceTypes(many))
129
- expect(digest.startsWith("sh:")).toBe(true)
130
- expect(digest.length).toBeLessThanOrEqual(16)
131
- })
132
-
133
- it("退避したハッシュも生連結と衝突しない接頭辞を持つ", () => {
134
- const many = Array.from({ length: 200 }, (_, index) => `source-type-${index}`)
135
- const hashed = computeSourceTypeDigest(normalizeVisibleSourceTypes(many))
136
- const raw = computeSourceTypeDigest(normalizeVisibleSourceTypes(["ni"]))
137
- expect(hashed).not.toBe(raw)
138
- expect(raw.startsWith("sh:")).toBe(false)
139
- // 限定集合は必ず s: 名前空間の下に入る (sentinel と衝突しない)
140
- expect(raw.startsWith("s:")).toBe(true)
141
- })
142
- })
143
-
144
- describe("JS 判定と SQL 述語の対称性", () => {
145
- // 同じ 3 入力に対し、両者が同じ三値意味を返すことを固定する。
146
- // どちらか一方だけ畳み込み規則を変えると、SSE (JS 判定) と読み取り (SQL 判定) で
147
- // 同じ行の可視性が食い違い、SSE 側が fail-open へずれる
148
- it("列値は両側ともトリムしない (空白混じりの値は双方で不可視 = fail-close)", () => {
149
- const set = normalizeVisibleSourceTypes(["NI"])
150
- // JS 判定: 前後空白付きの列値は一致しない
151
- expect(isSourceTypeVisible(set, " NI")).toBe(false)
152
- expect(isSourceTypeVisible(set, "NI ")).toBe(false)
153
- // 空白なしは一致する (大小文字は無視)
154
- expect(isSourceTypeVisible(set, "ni")).toBe(true)
155
- })
156
-
157
- it("リゾルバのトークン側だけは前後空白を吸収する", () => {
158
- const set = normalizeVisibleSourceTypes([" NI "])
159
- expect(isSourceTypeVisible(set, "NI")).toBe(true)
160
- expect([...(set ?? [])]).toEqual(["ni"])
161
- })
162
- })
163
-
164
- describe("DailyReportViewerScope", () => {
165
- it("restrictTo は入力を正規化して保持する", () => {
166
- const scope = DailyReportViewerScope.restrictTo(["NI", " ni ", "kintone"])
167
- expect([...(scope.visible ?? [])].sort()).toEqual(["kintone", "ni"])
168
- })
169
-
170
- it("restrictTo に空配列を渡すと全拒否 (0 件) になる", () => {
171
- const scope = DailyReportViewerScope.restrictTo([])
172
- expect(scope.visible).not.toBeNull()
173
- expect(scope.visible?.size).toBe(0)
174
- })
175
-
176
- it("unrestricted は無制限 (null) を保持する", () => {
177
- const scope = DailyReportViewerScope.unrestricted("unit test: documents why unfiltered is correct")
178
- expect(scope.visible).toBeNull()
179
- })
180
-
181
- it("computeViewerScopeDigest はスコープ単位で集合ダイジェストと一致する", () => {
182
- const scope = DailyReportViewerScope.restrictTo(["ni"])
183
- expect(computeViewerScopeDigest(scope)).toBe(computeSourceTypeDigest(normalizeVisibleSourceTypes(["ni"])))
184
- expect(computeViewerScopeDigest(DailyReportViewerScope.unrestricted("unit test"))).toBe("all")
185
- expect(computeViewerScopeDigest(DailyReportViewerScope.restrictTo([]))).toBe("none")
186
- })
187
-
188
- it("構造的偽造もスプレッド widening も型が拒否する (名目型であることの実証)", () => {
189
- const real = DailyReportViewerScope.restrictTo(["ni"])
190
-
191
- // @ts-expect-error 素のオブジェクトはスコープとして通らない (構造的偽造の拒否)
192
- const forged: DailyReportViewerScope = { visible: null }
193
- // @ts-expect-error 既存スコープを展開して visible だけ差し替える偽装も通らない
194
- const widened: DailyReportViewerScope = { ...real, visible: null }
195
- // @ts-expect-error コンストラクタは private なので直接生成できない
196
- const constructed = new DailyReportViewerScope(null)
197
-
198
- // 生成経路は 2 つの静的ファクトリだけ (実行時にも到達しないことの明示)
199
- void forged
200
- void widened
201
- void constructed
202
- expect(real.visible?.has("ni")).toBe(true)
203
- })
204
-
205
- it("restrictTo は nullish を受け付けない (無制限の第 2 の入口を作らない)", () => {
206
- // 受け付けると「restrict」という名前のまま無制限スコープを生めてしまい、
207
- // 「絞り込まれない読み取りは必ず unrestricted() に現れる」という監査不変条件が崩れる
208
- // @ts-expect-error null は限定トークン列ではない
209
- const fromNull = DailyReportViewerScope.restrictTo(null)
210
- // @ts-expect-error undefined も同様 (省略可能フィールドの取りこぼしを型で止める)
211
- const fromUndefined = DailyReportViewerScope.restrictTo(undefined)
212
- void fromNull
213
- void fromUndefined
214
- expect(DailyReportViewerScope.restrictTo([]).visible?.size).toBe(0)
215
- })
216
-
217
- it("Object.assign による実体の書き換えを実行時に拒否する", () => {
218
- const scope = DailyReportViewerScope.restrictTo(["ni"])
219
- // private スロット + getter なので、public プロパティへの代入は実行時に失敗する
220
- expect(() => Object.assign(scope, { visible: null })).toThrow()
221
- expect(scope.visible?.has("ni")).toBe(true)
222
- })
223
-
224
- it("公開された可視集合を in-place で広げられない (毎回コピーを返す)", () => {
225
- const scope = DailyReportViewerScope.restrictTo(["ni"])
226
- const leaked = scope.visible as unknown as Set<string>
227
- leaked.add("kintone")
228
- // 取得したコピーを汚しても、スコープ本体は変わらない
229
- expect(scope.visible?.has("kintone")).toBe(false)
230
- expect(scope.visible?.has("ni")).toBe(true)
231
- })
232
- })
233
-
234
- describe("DailyReportViewerScope のコメント次元", () => {
235
- it("素の配列指定ではコメント次元が本文次元に一致する (単一次元と同義・現行互換)", () => {
236
- const scope = DailyReportViewerScope.restrictTo(["NI", "internal"])
237
- expect([...(scope.commentable ?? [])].sort()).toEqual(["internal", "ni"])
238
- expect([...(scope.commentable ?? [])].sort()).toEqual([...(scope.visible ?? [])].sort())
239
- })
240
-
241
- it("comment 省略時もコメント次元は read に従う", () => {
242
- const scope = DailyReportViewerScope.restrictTo({ read: ["ni", "internal"] })
243
- expect([...(scope.commentable ?? [])].sort()).toEqual(["internal", "ni"])
244
- })
245
-
246
- it("comment に空配列を渡すとコメントだけ 0 件になる (本文は残る)", () => {
247
- // 要件 4: 「省略」と「空配列」を区別する。空配列は無制限へ倒れてはならない
248
- const scope = DailyReportViewerScope.restrictTo({ read: ["ni", "internal"], comment: [] })
249
- expect(scope.commentable?.size).toBe(0)
250
- expect([...(scope.visible ?? [])].sort()).toEqual(["internal", "ni"])
251
- })
252
-
253
- it("コメント次元は read との積集合へ狭められる (read 外のトークンは意味を持たない)", () => {
254
- const scope = DailyReportViewerScope.restrictTo({ read: ["internal"], comment: ["internal", "ni", "kintone"] })
255
- expect([...(scope.commentable ?? [])]).toEqual(["internal"])
256
- // 積集合を外すと ni / kintone が残ってしまう (この 2 行が積集合の番人)
257
- expect(scope.commentable?.has("ni")).toBe(false)
258
- expect(scope.commentable?.has("kintone")).toBe(false)
259
- })
260
-
261
- it("コメント次元にも同じ畳み込み規則が効く (前後空白・大小文字・重複)", () => {
262
- const scope = DailyReportViewerScope.restrictTo({ read: ["NI"], comment: [" ni ", "NI", ""] })
263
- expect([...(scope.commentable ?? [])]).toEqual(["ni"])
264
- })
265
-
266
- it("unrestricted は両次元とも無制限にする", () => {
267
- const scope = DailyReportViewerScope.unrestricted("unit test: documents why unfiltered is correct")
268
- expect(scope.visible).toBeNull()
269
- expect(scope.commentable).toBeNull()
270
- })
271
-
272
- it("契約違反の入力は次元ごとに fail-close する", () => {
273
- // 素の文字列 (配列ではない) は両次元とも全拒否へ倒す。`in` 判定が実行時に throw しないことも兼ねる
274
- const fromString = DailyReportViewerScope.restrictTo("NI" as unknown as readonly string[])
275
- expect(fromString.visible?.size).toBe(0)
276
- expect(fromString.commentable?.size).toBe(0)
277
-
278
- // comment 側だけが契約違反でも、本文次元は保たれたままコメントだけ全拒否になる
279
- const badComment = DailyReportViewerScope.restrictTo({ read: ["ni"], comment: "ni" as unknown as readonly string[] })
280
- expect([...(badComment.visible ?? [])]).toEqual(["ni"])
281
- expect(badComment.commentable?.size).toBe(0)
282
- })
283
-
284
- it("コメント集合も in-place で広げられない (毎回コピーを返す)", () => {
285
- const scope = DailyReportViewerScope.restrictTo({ read: ["ni", "internal"], comment: ["ni"] })
286
- const leaked = scope.commentable as unknown as Set<string>
287
- leaked.add("internal")
288
- expect(scope.commentable?.has("internal")).toBe(false)
289
- expect(scope.commentable?.has("ni")).toBe(true)
290
- })
291
-
292
- it("Object.assign による commentable の書き換えを実行時に拒否する", () => {
293
- const scope = DailyReportViewerScope.restrictTo({ read: ["ni"], comment: [] })
294
- expect(() => Object.assign(scope, { commentable: null })).toThrow()
295
- expect(scope.commentable?.size).toBe(0)
296
- })
297
-
298
- it("comment に nullish を綴れず、実行時に届いても fail-close する", () => {
299
- // 型では拒否する (「read に従う」の綴りは省略ただ 1 つ)
300
- // @ts-expect-error null は「read に従う」の綴りではない
301
- const withNull = DailyReportViewerScope.restrictTo({ read: ["ni"], comment: null })
302
- // ❗ 実行時にすり抜けた場合も**全拒否**へ倒す。正規化は nullish を無制限へ写すため、
303
- // 素通りさせると read より広いコメント集合が生まれる (fail-open)。落ちるのも不可
304
- expect(withNull.commentable?.size).toBe(0)
305
- expect([...(withNull.visible ?? [])]).toEqual(["ni"])
306
- // 省略はこれまでどおり read に従う
307
- expect(DailyReportViewerScope.restrictTo({ read: ["ni"] }).commentable?.has("ni")).toBe(true)
308
- })
309
- })
310
-
311
- describe("computeViewerScopeDigest の 2 次元化", () => {
312
- it("既定 (コメント次元が read に従う) では単一次元ダイジェストと完全同一 (キャッシュ無退行)", () => {
313
- // ここが崩れると既存配備の全キャッシュキーが変わり、権限が変わっていないのに一斉ミスする
314
- expect(computeViewerScopeDigest(DailyReportViewerScope.restrictTo(["ni"]))).toBe(computeSourceTypeDigest(normalizeVisibleSourceTypes(["ni"])))
315
- expect(computeViewerScopeDigest(DailyReportViewerScope.restrictTo({ read: ["ni"] }))).toBe(computeSourceTypeDigest(normalizeVisibleSourceTypes(["ni"])))
316
- expect(computeViewerScopeDigest(DailyReportViewerScope.unrestricted("unit test"))).toBe("all")
317
- expect(computeViewerScopeDigest(DailyReportViewerScope.restrictTo([]))).toBe("none")
318
- })
319
-
320
- it("コメント次元だけが違う閲覧者は別ダイジェストになる (キャッシュ隔離)", () => {
321
- // これが等しいと、コメント権限だけを剥奪された同一ユーザーが剥奪前のバケットから
322
- // コメント本文入りの詳細を TTL の間受け取り続ける (キーが変わらないので無効化も走らない)
323
- const full = computeViewerScopeDigest(DailyReportViewerScope.restrictTo({ read: ["ni", "internal"] }))
324
- const noComment = computeViewerScopeDigest(DailyReportViewerScope.restrictTo({ read: ["ni", "internal"], comment: [] }))
325
- const partial = computeViewerScopeDigest(DailyReportViewerScope.restrictTo({ read: ["ni", "internal"], comment: ["internal"] }))
326
- expect(new Set([full, noComment, partial]).size).toBe(3)
327
- })
328
-
329
- it("2 次元ダイジェストは単射で、単一次元表記や sentinel と衝突しない", () => {
330
- const scopes = [
331
- DailyReportViewerScope.unrestricted("unit test"),
332
- DailyReportViewerScope.restrictTo([]),
333
- DailyReportViewerScope.restrictTo(["ni"]),
334
- DailyReportViewerScope.restrictTo(["internal"]),
335
- DailyReportViewerScope.restrictTo(["ni", "internal"]),
336
- DailyReportViewerScope.restrictTo({ read: ["ni", "internal"], comment: [] }),
337
- DailyReportViewerScope.restrictTo({ read: ["ni", "internal"], comment: ["ni"] }),
338
- DailyReportViewerScope.restrictTo({ read: ["ni", "internal"], comment: ["internal"] }),
339
- DailyReportViewerScope.restrictTo({ read: ["ni"], comment: [] }),
340
- DailyReportViewerScope.restrictTo({ read: ["internal"], comment: [] }),
341
- ]
342
- const digests = scopes.map(computeViewerScopeDigest)
343
- expect(new Set(digests).size).toBe(scopes.length)
344
- })
345
-
346
- it("ダイジェストの構造文字や sentinel を含むトークンでも組ごとに一意になる", () => {
347
- // ダイジェストは `s:` / `none` / `all` / 長さ接頭辞 / 制御文字区切りで組み立てられる。
348
- // それらを**トークンの値そのもの**に混ぜ、境界を誤読させにいっても組が潰れないことを固定する。
349
- // ❗ 長さ接頭辞 (`${read.length}:`) 自体は防御的な冗長性であり、現行の名前空間設計
350
- // (`d:` / `s:` / `sh:` / sentinel) の下では接頭辞を外しても衝突は構成できなかった
351
- // (総当たり 2048 組で 0 件)。外してよいという意味ではなく、この test が守っているのは
352
- // 「2 次元の組が一意である」という上位の性質であって接頭辞の有無ではない、という表明
353
- const adversarial = ["none", "all", "s:1", "1:a", ":", "a"]
354
- const scopes: DailyReportViewerScope[] = []
355
- for (let i = 0; i < adversarial.length; i++) {
356
- for (let j = i + 1; j < adversarial.length; j++) {
357
- const read = [adversarial[i], adversarial[j]]
358
- scopes.push(DailyReportViewerScope.restrictTo({ read }))
359
- scopes.push(DailyReportViewerScope.restrictTo({ read, comment: [] }))
360
- scopes.push(DailyReportViewerScope.restrictTo({ read, comment: [adversarial[i]] }))
361
- scopes.push(DailyReportViewerScope.restrictTo({ read, comment: [adversarial[j]] }))
362
- }
363
- }
364
- const digests = scopes.map(computeViewerScopeDigest)
365
- expect(new Set(digests).size).toBe(scopes.length)
366
- })
367
- })
368
-
369
- describe("deniesEverySourceType の三値", () => {
370
- it("無制限 (null) は全拒否ではない", () => {
371
- expect(deniesEverySourceType(null)).toBe(false)
372
- })
373
-
374
- it("空集合だけが全拒否になる", () => {
375
- expect(deniesEverySourceType(normalizeVisibleSourceTypes([]))).toBe(true)
376
- expect(deniesEverySourceType(normalizeVisibleSourceTypes(["ni"]))).toBe(false)
377
- })
378
- })
379
-
380
- describe("buildVisibleSourceTypeCondition の三値", () => {
381
- // 実カラムが要るため最小のテスト用テーブルを組む (getColumns / sql テンプレートが実体を要求する)
382
- const testTable = mssqlSchema("test").table("Hub", { id: bigint("id", { mode: "number" }).notNull(), sourceType: nvarchar("source_type", { length: 20 }).notNull() }, (t) => [primaryKey({ columns: [t.id] })])
383
- const dialect = new MsSqlDialect()
384
-
385
- it("無制限は条件を生成しない (既存述語をそのまま残す)", () => {
386
- expect(buildVisibleSourceTypeCondition(testTable.sourceType, null)).toBeUndefined()
387
- })
388
-
389
- it("全拒否は 1 = 0 を生成する (呼び出し側の短絡が漏れても認可が反転しない)", () => {
390
- const condition = buildVisibleSourceTypeCondition(testTable.sourceType, normalizeVisibleSourceTypes([]))
391
- expect(condition).toBeDefined()
392
- expect(dialect.sqlToQuery(condition as NonNullable<typeof condition>).sql).toContain("1 = 0")
393
- })
394
-
395
- it("限定は LOWER(列) IN (畳み込み済みトークン) を生成する", () => {
396
- const condition = buildVisibleSourceTypeCondition(testTable.sourceType, normalizeVisibleSourceTypes(["NI", "kintone"]))
397
- const rendered = dialect.sqlToQuery(condition as NonNullable<typeof condition>)
398
- expect(rendered.sql).toContain("LOWER(")
399
- expect(rendered.sql).toContain("[source_type]")
400
- expect(rendered.sql).toContain(" in ")
401
- expect(rendered.params).toEqual(expect.arrayContaining(["kintone", "ni"]))
402
- })
403
- })