@aiquants/daily-report 0.6.2 → 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.
Files changed (49) hide show
  1. package/dist/client.d.mts +81 -3
  2. package/dist/client.d.ts +81 -3
  3. package/dist/client.js +4 -4
  4. package/dist/client.js.map +1 -1
  5. package/dist/client.mjs +4 -4
  6. package/dist/client.mjs.map +1 -1
  7. package/dist/index.d.mts +2 -2
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +1 -1
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/server.d.mts +434 -6
  14. package/dist/server.d.ts +434 -6
  15. package/dist/server.js +6 -6
  16. package/dist/server.js.map +1 -1
  17. package/dist/server.mjs +6 -6
  18. package/dist/server.mjs.map +1 -1
  19. package/dist/{sse-schema-rbG114od.d.mts → sse-schema-Df49KA7B.d.mts} +643 -249
  20. package/dist/{sse-schema-eXcMG-Ej.d.ts → sse-schema-RHckD7TS.d.ts} +643 -249
  21. package/dist/styles/daily-report.standalone.css +1 -1
  22. package/dist/{types-D1PKubyo.d.mts → types-Ct1ggzy-.d.mts} +30 -1
  23. package/dist/{types-D1PKubyo.d.ts → types-Ct1ggzy-.d.ts} +30 -1
  24. package/package.json +3 -3
  25. package/src/client/components/daily-report-attachment-indicator.spec.tsx +51 -0
  26. package/src/client/components/daily-report-attachment-indicator.tsx +45 -0
  27. package/src/client/components/daily-report-attachment-list.spec.tsx +94 -0
  28. package/src/client/components/daily-report-attachment-list.tsx +112 -0
  29. package/src/client/components/daily-report-detail-list.tsx +14 -7
  30. package/src/client/components/daily-report-list.tsx +19 -11
  31. package/src/client/components/report-views.spec.tsx +340 -0
  32. package/src/client/config-context.tsx +8 -0
  33. package/src/client/contexts/daily-report-action-context.tsx +2 -0
  34. package/src/client/hooks/use-responsive-layout.spec.ts +74 -0
  35. package/src/client/hooks/use-responsive-layout.ts +56 -0
  36. package/src/client/utils/constants.spec.ts +96 -0
  37. package/src/client/utils/constants.ts +47 -2
  38. package/src/client.ts +2 -0
  39. package/src/server/handlers.attachment.spec.ts +470 -0
  40. package/src/server/handlers.ts +426 -1
  41. package/src/server/ports.ts +74 -0
  42. package/src/server/schema.ts +80 -5
  43. package/src/server/service.spec.ts +280 -14
  44. package/src/server/service.ts +308 -25
  45. package/src/server/test-helpers/handlers-config.ts +142 -0
  46. package/src/server.ts +16 -1
  47. package/src/shared/sse-schema.spec.ts +50 -0
  48. package/src/shared/sse-schema.ts +27 -0
  49. package/src/shared/types.ts +31 -0
@@ -0,0 +1,470 @@
1
+ /**
2
+ * Authorization and delivery tests for the attachment endpoint.
3
+ * 添付エンドポイントの認可・配信テスト。
4
+ *
5
+ * ここで固定する不変条件のうち、特に重要なもの:
6
+ *
7
+ * - **`text/html` / `image/svg+xml` をインライン配信しない。** `file_type` は外部システム由来の
8
+ * 未検証値であり、自オリジンでインライン表示すると格納型 XSS になる。`nosniff` は
9
+ * 「宣言型から外れた推測」を止めるだけで、宣言された型の実行は止めない。
10
+ * - **「存在しない」と「他人の下書き」が応答としてバイト同一になる** (存在オラクル回避)。
11
+ * - **ログに `file_path` を出さない。**
12
+ */
13
+ import { describe, expect, it, vi } from "vitest"
14
+ import { asciiFallbackFileName, createDailyReportHandlers, encodeRfc8187, sanitizeMediaType } from "./handlers"
15
+ import { makeAttachmentArgs, makeHandlersFixture, type TestAttachmentRow } from "./test-helpers/handlers-config"
16
+
17
+ /** 可視な添付 1 件の既定形。 */
18
+ const visibleRow = (over: Partial<TestAttachmentRow> = {}): TestAttachmentRow => ({
19
+ fileName: "report.png",
20
+ filePath: "gcs://bucket/path/report.png",
21
+ fileType: "image/png",
22
+ fileSize: 1024,
23
+ ...over,
24
+ })
25
+
26
+ /** 既定のバイト列 (3 バイト)。 */
27
+ const threeBytes = () => new Uint8Array(new ArrayBuffer(3))
28
+
29
+ describe("attachment loader / 認証と入口の検証", () => {
30
+ it("未認証は 401 を返し、配信用ヘッダーを一切出さない", async () => {
31
+ const { config } = makeHandlersFixture({ user: null })
32
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1"))
33
+ expect(res.status).toBe(401)
34
+ expect(res.headers.get("Content-Disposition")).toBeNull()
35
+ expect(res.headers.get("Content-Type")).toBe("application/json")
36
+ })
37
+
38
+ it("GET / HEAD 以外のメソッドは 405 を返す", async () => {
39
+ const { config } = makeHandlersFixture({ attachment: visibleRow() })
40
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1", { method: "POST" }))
41
+ expect(res.status).toBe(405)
42
+ })
43
+
44
+ it("ポート未注入の配備では全トークンが 404 になる (意図した縮退動作)", async () => {
45
+ const { config } = makeHandlersFixture({ omitPorts: true, attachment: visibleRow() })
46
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1"))
47
+ expect(res.status).toBe(404)
48
+ })
49
+
50
+ it("復号できないトークンは 400、負値・0 も 400 を返す", async () => {
51
+ const handlers = createDailyReportHandlers(makeHandlersFixture({ attachment: visibleRow() }).config)
52
+ expect((await handlers.attachment.loader(makeAttachmentArgs("not-a-token"))).status).toBe(400)
53
+ expect((await handlers.attachment.loader(makeAttachmentArgs("A0"))).status).toBe(400)
54
+ expect((await handlers.attachment.loader(makeAttachmentArgs("A-5"))).status).toBe(400)
55
+ })
56
+
57
+ it("安全整数域を超える ID へ復号されるトークンは 400 で止め、DB へ渡さない", async () => {
58
+ // 可逆難読化はトークンの正準性を検証しないため、正規トークンと同じ形の入力から
59
+ // 2^53 超の値が復号されうる。素通しすると SQL ドライバーの範囲検証が例外を投げ、
60
+ // 400 のはずが 500 になる (実測: sqids の 12 文字トークンで 1.6e19 が返る)
61
+ const { config, calls } = makeHandlersFixture({ attachment: visibleRow() })
62
+ const handlers = createDailyReportHandlers(config)
63
+ expect((await handlers.attachment.loader(makeAttachmentArgs(`A${Number.MAX_SAFE_INTEGER + 2}`))).status).toBe(400)
64
+ expect((await handlers.attachment.loader(makeAttachmentArgs("A1e300"))).status).toBe(400)
65
+ expect(calls.getAttachmentForUser).toEqual([])
66
+ // 境界そのもの (2^53-1) は正当な ID として通す
67
+ expect((await handlers.attachment.loader(makeAttachmentArgs(`A${Number.MAX_SAFE_INTEGER}`))).status).toBe(200)
68
+ })
69
+
70
+ it("内部ユーザー ID が解決できない場合は 403 を返す", async () => {
71
+ const { config } = makeHandlersFixture({ internalUserId: null, attachment: visibleRow() })
72
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1"))
73
+ expect(res.status).toBe(403)
74
+ })
75
+
76
+ it("Set-Cookie を失敗分岐でも成功応答でも伝播する", async () => {
77
+ // 成功経路は fail() を通らず独立に append しているため、失敗分岐だけを見ても固定できない
78
+ const cookie = "session=abc; Path=/"
79
+ for (const options of [{ user: null }, { internalUserId: null }, { attachment: null }, { attachment: visibleRow() }] as const) {
80
+ const { config } = makeHandlersFixture({ cookie, ...options })
81
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1"))
82
+ expect(res.headers.get("Set-Cookie")).toBe(cookie)
83
+ }
84
+ })
85
+ })
86
+
87
+ describe("attachment loader / 認可 (存在オラクル回避)", () => {
88
+ it("不可視な添付は 404 を返す", async () => {
89
+ const { config } = makeHandlersFixture({ attachment: null })
90
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1"))
91
+ expect(res.status).toBe(404)
92
+ })
93
+
94
+ it("認可判定へ「添付 ID と閲覧者の内部ユーザー ID」をこの順で渡す", async () => {
95
+ // 唯一の認可境界は SQL 述語であり、その入力はこの 2 引数しかない。
96
+ // 閲覧者 ID を渡し忘れる / 取り違える欠陥は、ここを表明しないと一切検出できない
97
+ const { config, calls } = makeHandlersFixture({ attachment: visibleRow() })
98
+ await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A77"))
99
+ expect(calls.getAttachmentForUser).toEqual([[77, 42]])
100
+ })
101
+
102
+ it("「行が無い」と「他人の下書き」の応答がバイト同一になる", async () => {
103
+ // どちらもサービス層は null を返す。ハンドラーが区別しないことを表明する
104
+ const a = await createDailyReportHandlers(makeHandlersFixture({ attachment: null }).config).attachment.loader(makeAttachmentArgs("A1"))
105
+ const b = await createDailyReportHandlers(makeHandlersFixture({ attachment: null }).config).attachment.loader(makeAttachmentArgs("A2"))
106
+ expect(a.status).toBe(b.status)
107
+ expect(await a.text()).toBe(await b.text())
108
+ expect([...a.headers].sort()).toEqual([...b.headers].sort())
109
+ })
110
+
111
+ it("ストレージ側の not_found / denied / invalid_path も同じ 404 本文にする", async () => {
112
+ const bodies: string[] = []
113
+ for (const reason of ["not_found", "denied", "invalid_path"] as const) {
114
+ const { config } = makeHandlersFixture({ attachment: visibleRow(), readResult: { ok: false, reason } })
115
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1"))
116
+ expect(res.status).toBe(404)
117
+ bodies.push(await res.text())
118
+ }
119
+ expect(new Set(bodies).size).toBe(1)
120
+ })
121
+
122
+ it("ストレージが not_found を返したとき実体消失を記録する", async () => {
123
+ const { config, calls } = makeHandlersFixture({ attachment: visibleRow(), readResult: { ok: false, reason: "not_found" } })
124
+ await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A77"))
125
+ expect(calls.markAttachmentMissing).toEqual([77])
126
+ })
127
+
128
+ it("denied では実体消失を記録しない (消えたわけではない)", async () => {
129
+ const { config, calls } = makeHandlersFixture({ attachment: visibleRow(), readResult: { ok: false, reason: "denied" } })
130
+ await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A77"))
131
+ expect(calls.markAttachmentMissing).toEqual([])
132
+ })
133
+
134
+ it("200 で配信できたら消失記録を解除する (absent からの唯一の自動回復経路)", async () => {
135
+ // 片方向だと一時障害で立った absent を利用者が自力で戻せない
136
+ // (UI は absent の添付にリンクを描画しないため再取得の手段が消える)
137
+ const { config, calls } = makeHandlersFixture({ attachment: visibleRow() })
138
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A77"))
139
+ expect(res.status).toBe(200)
140
+ expect(calls.markAttachmentPresent).toEqual([77])
141
+ expect(calls.markAttachmentMissing).toEqual([])
142
+ })
143
+
144
+ it("失敗応答では実体存在を記録しない", async () => {
145
+ for (const reason of ["not_found", "denied", "unavailable", "too_large", "invalid_path"] as const) {
146
+ const { config, calls } = makeHandlersFixture({ attachment: visibleRow(), readResult: { ok: false, reason } })
147
+ await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A77"))
148
+ expect(calls.markAttachmentPresent, `reason=${reason}`).toEqual([])
149
+ }
150
+ })
151
+ })
152
+
153
+ describe("attachment loader / サイズと可用性", () => {
154
+ it("DB の既知サイズが上限を超える場合は読み取らずに 413 を返す", async () => {
155
+ const { config, calls } = makeHandlersFixture({ attachment: visibleRow({ fileSize: 999_999_999 }), attachmentMaxBytes: 1024 })
156
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1"))
157
+ expect(res.status).toBe(413)
158
+ expect(calls.readAttachment).toHaveLength(0)
159
+ })
160
+
161
+ it("サイズ未知 (取込元が報告しない) でもポートの too_large を 413 へ写像する", async () => {
162
+ const { config } = makeHandlersFixture({ attachment: visibleRow({ fileSize: null }), readResult: { ok: false, reason: "too_large" } })
163
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1"))
164
+ expect(res.status).toBe(413)
165
+ })
166
+
167
+ it("unavailable は 502 を返す", async () => {
168
+ const { config } = makeHandlersFixture({ attachment: visibleRow(), readResult: { ok: false, reason: "unavailable" } })
169
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1"))
170
+ expect(res.status).toBe(502)
171
+ })
172
+
173
+ it("レート上限を超えると 429 と Retry-After を返す", async () => {
174
+ const { config } = makeHandlersFixture({ attachment: visibleRow(), attachmentRateLimitPerMinute: 1 })
175
+ const handlers = createDailyReportHandlers(config)
176
+ expect((await handlers.attachment.loader(makeAttachmentArgs("A1"))).status).toBe(200)
177
+ const second = await handlers.attachment.loader(makeAttachmentArgs("A1"))
178
+ expect(second.status).toBe(429)
179
+ expect(second.headers.get("Retry-After")).toBe("60")
180
+ })
181
+
182
+ it("同時実行上限に達すると 503 と Retry-After を返す", async () => {
183
+ let release: (() => void) | undefined
184
+ const gate = new Promise<void>((resolve) => {
185
+ release = resolve
186
+ })
187
+ const { config } = makeHandlersFixture({ attachment: visibleRow(), attachmentConcurrency: 1 })
188
+ const handlers = createDailyReportHandlers({
189
+ ...config,
190
+ readAttachment: async () => {
191
+ await gate
192
+ return { ok: true as const, bytes: threeBytes(), contentType: null }
193
+ },
194
+ })
195
+ const inFlight = handlers.attachment.loader(makeAttachmentArgs("A1"))
196
+ const rejected = await handlers.attachment.loader(makeAttachmentArgs("A2"))
197
+ expect(rejected.status).toBe(503)
198
+ expect(rejected.headers.get("Retry-After")).toBe("5")
199
+ release?.()
200
+ expect((await inFlight).status).toBe(200)
201
+ })
202
+ })
203
+
204
+ describe("attachment loader / Content-Type と Disposition (XSS 対策)", () => {
205
+ it("許可リスト内の型は宣言型のまま inline で配信する", async () => {
206
+ const { config } = makeHandlersFixture({ attachment: visibleRow({ fileType: "image/png" }) })
207
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1"))
208
+ expect(res.headers.get("Content-Type")).toBe("image/png")
209
+ expect(res.headers.get("Content-Disposition")).toContain("inline")
210
+ })
211
+
212
+ it("text/html は octet-stream + attachment へ落とす (格納型 XSS の防止)", async () => {
213
+ const { config } = makeHandlersFixture({ attachment: visibleRow({ fileName: "evil.html", fileType: "text/html" }) })
214
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1"))
215
+ expect(res.headers.get("Content-Type")).toBe("application/octet-stream")
216
+ expect(res.headers.get("Content-Disposition")).toContain("attachment")
217
+ })
218
+
219
+ it("image/svg+xml も octet-stream + attachment へ落とす (SVG はスクリプトを含みうる)", async () => {
220
+ const { config } = makeHandlersFixture({ attachment: visibleRow({ fileName: "evil.svg", fileType: "image/svg+xml" }) })
221
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1"))
222
+ expect(res.headers.get("Content-Type")).toBe("application/octet-stream")
223
+ expect(res.headers.get("Content-Disposition")).toContain("attachment")
224
+ })
225
+
226
+ it("CR/LF を含む file_type でも 500 にせず既定 MIME へ落とす (許可リストが第 1 層として機能する)", async () => {
227
+ const { config } = makeHandlersFixture({ attachment: visibleRow({ fileType: "text/plain\r\nX-Injected: 1" }) })
228
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1"))
229
+ expect(res.status).toBe(200)
230
+ expect(res.headers.get("Content-Type")).toBe("application/octet-stream")
231
+ expect(res.headers.get("X-Injected")).toBeNull()
232
+ })
233
+
234
+ it("非 ASCII を含む file_type でも 500 にせず既定 MIME へ落とす", async () => {
235
+ const { config } = makeHandlersFixture({ attachment: visibleRow({ fileType: 'application/pdf; name="あ"' }) })
236
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1"))
237
+ expect(res.status).toBe(200)
238
+ expect(res.headers.get("Content-Type")).toBe("application/octet-stream")
239
+ })
240
+
241
+ it("DB の file_type が無いときはストレージ申告型へフォールバックする", async () => {
242
+ // サイズや MIME を DB に持たない取込元があるため、この分岐は実運用で通る
243
+ const { config } = makeHandlersFixture({ attachment: visibleRow({ fileType: null }), readResult: { ok: true, bytes: threeBytes(), contentType: "image/png" } })
244
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1"))
245
+ expect(res.headers.get("Content-Type")).toBe("image/png")
246
+ expect(res.headers.get("Content-Disposition")).toContain("inline")
247
+ })
248
+
249
+ it("ストレージ申告型が許可リスト外なら octet-stream へ落とす", async () => {
250
+ const { config } = makeHandlersFixture({ attachment: visibleRow({ fileType: null }), readResult: { ok: true, bytes: threeBytes(), contentType: "text/html" } })
251
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1"))
252
+ expect(res.headers.get("Content-Type")).toBe("application/octet-stream")
253
+ expect(res.headers.get("Content-Disposition")).toContain("attachment")
254
+ })
255
+
256
+ it("DB の file_type はストレージ申告型より優先する", async () => {
257
+ const { config } = makeHandlersFixture({ attachment: visibleRow({ fileType: "application/pdf" }), readResult: { ok: true, bytes: threeBytes(), contentType: "image/png" } })
258
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1"))
259
+ expect(res.headers.get("Content-Type")).toBe("application/pdf")
260
+ })
261
+
262
+ it("?download=1 は許可リスト内の型でも attachment にする", async () => {
263
+ const { config } = makeHandlersFixture({ attachment: visibleRow({ fileType: "image/png" }) })
264
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1", { query: "?download=1" }))
265
+ expect(res.headers.get("Content-Disposition")).toContain("attachment")
266
+ })
267
+
268
+ it("日本語ファイル名を RFC 8187 で符号化し、ASCII フォールバックも併記する", async () => {
269
+ const { config } = makeHandlersFixture({ attachment: visibleRow({ fileName: "報告書(1)'s *最終*.pdf", fileType: "application/pdf" }) })
270
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1"))
271
+ const disposition = res.headers.get("Content-Disposition") ?? ""
272
+ expect(disposition).toContain("filename*=UTF-8''")
273
+ expect(disposition).toContain('filename="')
274
+ // encodeURIComponent が残す ' ( ) * が符号化されていること
275
+ expect(disposition).not.toMatch(/filename\*=UTF-8''[^;]*[('*)]/)
276
+ // ASCII フォールバックに生の引用符が入っていないこと
277
+ expect(disposition).toMatch(/filename="[^"]*"/)
278
+ })
279
+
280
+ it("セキュリティヘッダーを 200 応答へ付与する", async () => {
281
+ const { config } = makeHandlersFixture({ attachment: visibleRow() })
282
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1"))
283
+ expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff")
284
+ expect(res.headers.get("Content-Security-Policy")).toBe("default-src 'none'; sandbox")
285
+ expect(res.headers.get("X-Frame-Options")).toBe("SAMEORIGIN")
286
+ expect(res.headers.get("Cache-Control")).toBe("private, no-store")
287
+ expect(res.headers.get("Content-Length")).toBe("3")
288
+ })
289
+ })
290
+
291
+ describe("attachment loader / HEAD と副作用", () => {
292
+ it("HEAD は本体を読まず、本文を返さない", async () => {
293
+ const { config, calls } = makeHandlersFixture({ attachment: visibleRow() })
294
+ const res = await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1", { method: "HEAD" }))
295
+ expect(res.status).toBe(200)
296
+ expect(res.body).toBeNull()
297
+ expect(calls.readAttachment[0]?.head).toBe(true)
298
+ })
299
+
300
+ it("HEAD の Content-Length は GET と一致する (RFC 9110 §9.3.2)", async () => {
301
+ // 本体を読まない HEAD ではポートが長さ 0 のビューを返すため、`bytes.byteLength` から
302
+ // 組み立てると必ず 0 になる。ポートが申告した `size` を使うことを固定する
303
+ const { config } = makeHandlersFixture({ attachment: visibleRow() })
304
+ const handlers = createDailyReportHandlers(config)
305
+ const get = await handlers.attachment.loader(makeAttachmentArgs("A1"))
306
+ const head = await handlers.attachment.loader(makeAttachmentArgs("A1", { method: "HEAD" }))
307
+ expect(get.headers.get("Content-Length")).toBe("3")
308
+ expect(head.headers.get("Content-Length")).toBe(get.headers.get("Content-Length"))
309
+ })
310
+
311
+ it("ポートが size を申告しない場合は実バイト長を使う", async () => {
312
+ const { config } = makeHandlersFixture({ attachment: visibleRow() })
313
+ const handlers = createDailyReportHandlers({ ...config, readAttachment: async () => ({ ok: true as const, bytes: new Uint8Array(new ArrayBuffer(7)), contentType: null }) })
314
+ const res = await handlers.attachment.loader(makeAttachmentArgs("A1"))
315
+ expect(res.headers.get("Content-Length")).toBe("7")
316
+ })
317
+
318
+ it("読み取りポートへ内部パスと難読化済み principal を渡す", async () => {
319
+ const { config, calls } = makeHandlersFixture({ attachment: visibleRow() })
320
+ await createDailyReportHandlers(config).attachment.loader(makeAttachmentArgs("A1"))
321
+ expect(calls.readAttachment[0]?.filePath).toBe("gcs://bucket/path/report.png")
322
+ // 生の内部ユーザー ID ではなく難読化済みの値を渡すこと
323
+ expect(calls.readAttachment[0]?.principal).toBe("ENC42")
324
+ })
325
+
326
+ it("ログに file_path を出さない", async () => {
327
+ const lines: string[] = []
328
+ const logger = {
329
+ debug: (m?: unknown) => lines.push(String(m)),
330
+ info: (m?: unknown) => lines.push(String(m)),
331
+ warn: (m?: unknown) => lines.push(String(m)),
332
+ error: (m?: unknown) => lines.push(String(m)),
333
+ }
334
+ const { config } = makeHandlersFixture({ attachment: visibleRow() })
335
+ await createDailyReportHandlers({ ...config, logger }).attachment.loader(makeAttachmentArgs("A1"))
336
+ expect(lines.length).toBeGreaterThan(0)
337
+ for (const line of lines) {
338
+ expect(line).not.toContain("gcs://")
339
+ expect(line).not.toContain("bucket")
340
+ }
341
+ })
342
+
343
+ it("認証ポートが例外を投げても 500 の JSON を返す (認証呼び出しも try の内側にある)", async () => {
344
+ // 認証実装はセッション復号や warmup の失敗で素の例外を投げうる。
345
+ // authenticate を try の外に置くとこの穴が残り、プレーンテキストの 500 になる
346
+ const { config } = makeHandlersFixture({ attachment: visibleRow() })
347
+ const handlers = createDailyReportHandlers({
348
+ ...config,
349
+ authenticate: async () => {
350
+ throw new Error("session decode failed")
351
+ },
352
+ })
353
+ const res = await handlers.attachment.loader(makeAttachmentArgs("A1"))
354
+ expect(res.status).toBe(500)
355
+ expect(res.headers.get("Content-Type")).toBe("application/json")
356
+ expect(await res.text()).not.toContain("session decode failed")
357
+ })
358
+
359
+ it("認証ポートが投げた Response (リダイレクト) は握り潰さず素通しする", async () => {
360
+ // リダイレクトは例外ではなく正常な制御フロー。500 に化けさせるとログイン導線が壊れる
361
+ const redirect = new Response(null, { status: 302, headers: { Location: "/auth/login" } })
362
+ const { config } = makeHandlersFixture({ attachment: visibleRow() })
363
+ const handlers = createDailyReportHandlers({
364
+ ...config,
365
+ authenticate: async () => {
366
+ throw redirect
367
+ },
368
+ })
369
+ await expect(handlers.attachment.loader(makeAttachmentArgs("A1"))).rejects.toBe(redirect)
370
+ })
371
+
372
+ it("読み取りポートが例外を投げても 500 の JSON を返し、Set-Cookie を落とさない", async () => {
373
+ // 例外を漏らすとフレームワークの最終防衛線でプレーンテキストの 500 になり、
374
+ // セッション延長の Set-Cookie も失われる
375
+ const { config } = makeHandlersFixture({ attachment: visibleRow(), cookie: "session=abc" })
376
+ const handlers = createDailyReportHandlers({ ...config, readAttachment: vi.fn().mockRejectedValue(new Error("boom")) })
377
+ const res = await handlers.attachment.loader(makeAttachmentArgs("A1"))
378
+ expect(res.status).toBe(500)
379
+ expect(res.headers.get("Content-Type")).toBe("application/json")
380
+ expect(res.headers.get("Set-Cookie")).toBe("session=abc")
381
+ // 内部の例外文言を応答本文へ載せない
382
+ expect(await res.text()).not.toContain("boom")
383
+ })
384
+
385
+ it("GET のスロットは本文を読み切るまで保持し、読み切ったら解放する", async () => {
386
+ // 読み取り完了時点で解放すると、前の応答のバイト列がヒープに載ったまま次の読み取りが
387
+ // 始まるため「同時実行上限 × 上限バイト数」というヒープ見積りが成立しなくなる
388
+ const { config } = makeHandlersFixture({ attachment: visibleRow(), attachmentConcurrency: 1 })
389
+ const handlers = createDailyReportHandlers(config)
390
+
391
+ const first = await handlers.attachment.loader(makeAttachmentArgs("A1"))
392
+ expect(first.status).toBe(200)
393
+ // 本文が未消費の間はスロットが空かない
394
+ expect((await handlers.attachment.loader(makeAttachmentArgs("A2"))).status).toBe(503)
395
+
396
+ // 読み切るとスロットが返る
397
+ expect((await first.arrayBuffer()).byteLength).toBe(3)
398
+ expect((await handlers.attachment.loader(makeAttachmentArgs("A3"))).status).toBe(200)
399
+ })
400
+
401
+ it("本文をキャンセルした場合もスロットを解放する", async () => {
402
+ const { config } = makeHandlersFixture({ attachment: visibleRow(), attachmentConcurrency: 1 })
403
+ const handlers = createDailyReportHandlers(config)
404
+ const first = await handlers.attachment.loader(makeAttachmentArgs("A1"))
405
+ await first.body?.cancel()
406
+ expect((await handlers.attachment.loader(makeAttachmentArgs("A2"))).status).toBe(200)
407
+ })
408
+
409
+ it("HEAD は本文を持たないため即座にスロットを解放する", async () => {
410
+ const { config } = makeHandlersFixture({ attachment: visibleRow(), attachmentConcurrency: 1 })
411
+ const handlers = createDailyReportHandlers(config)
412
+ expect((await handlers.attachment.loader(makeAttachmentArgs("A1", { method: "HEAD" }))).status).toBe(200)
413
+ expect((await handlers.attachment.loader(makeAttachmentArgs("A2", { method: "HEAD" }))).status).toBe(200)
414
+ })
415
+
416
+ it("失敗時にも同時実行スロットを解放する", async () => {
417
+ const { config } = makeHandlersFixture({ attachment: visibleRow(), attachmentConcurrency: 1 })
418
+ const handlers = createDailyReportHandlers({
419
+ ...config,
420
+ readAttachment: vi
421
+ .fn()
422
+ .mockRejectedValueOnce(new Error("boom"))
423
+ .mockResolvedValue({ ok: true as const, bytes: threeBytes(), contentType: null }),
424
+ })
425
+ expect((await handlers.attachment.loader(makeAttachmentArgs("A1"))).status).toBe(500)
426
+ // 1 回目で解放されていなければここが 503 になる
427
+ expect((await handlers.attachment.loader(makeAttachmentArgs("A2"))).status).toBe(200)
428
+ })
429
+ })
430
+
431
+ describe("純粋ヘルパー (第 2 層の防御を直接固定する)", () => {
432
+ // ミューテーションテストで判明: 配信経路だけを見ると sanitizeMediaType は到達不能なため、
433
+ // 撤去しても 28 件のテストが全て通ってしまう。第 2 層には第 2 層のゲートを与える。
434
+ it("sanitizeMediaType は CR/LF を含む値を拒否する", () => {
435
+ expect(sanitizeMediaType("text/plain\r\nX-Injected: 1")).toBeNull()
436
+ expect(sanitizeMediaType("text/plain\nX-Injected: 1")).toBeNull()
437
+ })
438
+
439
+ it("sanitizeMediaType は非 ASCII を含む値を拒否する", () => {
440
+ expect(sanitizeMediaType('application/pdf; name="あ"')).toBeNull()
441
+ })
442
+
443
+ it("sanitizeMediaType は MIME 型の形をしていない値を拒否する", () => {
444
+ expect(sanitizeMediaType("not-a-media-type")).toBeNull()
445
+ expect(sanitizeMediaType("")).toBeNull()
446
+ expect(sanitizeMediaType(null)).toBeNull()
447
+ expect(sanitizeMediaType(undefined)).toBeNull()
448
+ })
449
+
450
+ it("sanitizeMediaType は正当な値をそのまま通す", () => {
451
+ expect(sanitizeMediaType("image/png")).toBe("image/png")
452
+ expect(sanitizeMediaType("application/vnd.ms-excel")).toBe("application/vnd.ms-excel")
453
+ expect(sanitizeMediaType("text/plain; charset=utf-8")).toBe("text/plain; charset=utf-8")
454
+ })
455
+
456
+ it("encodeRfc8187 は encodeURIComponent が残す ' ( ) * を符号化する", () => {
457
+ // encodeURIComponent はこの 4 文字を残すが、いずれも RFC 8187 の attr-char ではない
458
+ expect(encodeURIComponent("a'b(c)d*e")).toBe("a'b(c)d*e")
459
+ expect(encodeRfc8187("a'b(c)d*e")).toBe("a%27b%28c%29d%2Ae")
460
+ })
461
+
462
+ it("encodeRfc8187 は日本語を UTF-8 パーセント符号化する", () => {
463
+ expect(encodeRfc8187("報告書.pdf")).toBe("%E5%A0%B1%E5%91%8A%E6%9B%B8.pdf")
464
+ })
465
+
466
+ it("asciiFallbackFileName は非 ASCII と引用符・バックスラッシュを置換する", () => {
467
+ expect(asciiFallbackFileName('報告書"x\\y.pdf')).toBe("____x_y.pdf")
468
+ expect(asciiFallbackFileName("plain.pdf")).toBe("plain.pdf")
469
+ })
470
+ })