@aiquants/daily-report 0.12.4 → 0.13.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 +32 -0
- package/dist/client.d.mts +1 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.js +5 -5
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +5 -5
- package/dist/client.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/server.d.mts +175 -21
- package/dist/server.d.ts +175 -21
- package/dist/server.js +12 -9
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +12 -9
- package/dist/server.mjs.map +1 -1
- package/dist/{sse-schema-YZh7Pz7i.d.ts → sse-schema-D1oZnFg6.d.ts} +18 -0
- package/dist/{sse-schema-BiUnufrI.d.mts → sse-schema-lMo8yW4e.d.mts} +18 -0
- package/package.json +5 -5
- package/src/server/handlers.ts +181 -35
- package/src/server/handlers.visibility.spec.ts +458 -0
- package/src/server/ports.ts +48 -0
- package/src/server/service.spec.ts +335 -14
- package/src/server/service.ts +222 -37
- package/src/server/test-helpers/handlers-config.ts +93 -9
- package/src/server/visibility.spec.ts +142 -0
- package/src/server/visibility.ts +189 -0
- package/src/server.spec.ts +72 -0
- package/src/server.ts +20 -1
- package/src/shared/sse-schema.ts +15 -0
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
* 本番コードからは import されない (テスト専用)。
|
|
9
9
|
*/
|
|
10
10
|
import type { DailyReportHandlersConfig } from "../handlers"
|
|
11
|
-
import type { DailyReportAttachmentBytes, DailyReportAttachmentError, DailyReportIdCodec } from "../ports"
|
|
11
|
+
import type { DailyReportAttachmentBytes, DailyReportAttachmentError, DailyReportIdCodec, DailyReportResolveVisibleSourceTypes, DailyReportVisibilityResolveContext, DailyReportVisibilityResolveReason } from "../ports"
|
|
12
12
|
import type { DailyReportService } from "../service"
|
|
13
|
-
import type { DailyReportSseReader } from "../sse-reader"
|
|
13
|
+
import type { DailyReportSseReader, StreamEntry } from "../sse-reader"
|
|
14
|
+
import type { VisibleSourceTypeSet } from "../visibility"
|
|
14
15
|
|
|
15
16
|
/** 既定の外部ユーザー ID (認証済みユーザーを表す)。 */
|
|
16
17
|
export const TEST_EXTERNAL_USER_ID = "external-user"
|
|
@@ -53,6 +54,10 @@ export type HandlersFixtureOptions = {
|
|
|
53
54
|
attachmentConcurrency?: number
|
|
54
55
|
/** `intent=clearCache` (開発用の全キャッシュ破棄) を許可するか。 */
|
|
55
56
|
enableDevCacheClear?: boolean
|
|
57
|
+
/** 可視 sourceType リゾルバ (未指定なら未注入 = 無制限)。 */
|
|
58
|
+
resolveVisibleSourceTypes?: DailyReportResolveVisibleSourceTypes
|
|
59
|
+
/** ミューテーション stub に投げさせる例外 (区分ゲート相当の検証用)。 */
|
|
60
|
+
mutationError?: Error
|
|
56
61
|
/** ids ストリームが返すアイテム一覧 (ストリーム順で渡す)。 */
|
|
57
62
|
idsStreamItems?: Array<{ reportHubId: number; businessDate: string | null; sourceType?: string }>
|
|
58
63
|
/** ids 取得 (全量・先頭ページの両方) を失敗させるエラー。 */
|
|
@@ -76,6 +81,19 @@ export type HandlersFixtureCalls = {
|
|
|
76
81
|
getDailyReportIds: Array<[string, boolean]>
|
|
77
82
|
/** `service.getDailyReportIdsFirstPageByExternalId` が受け取った `(externalId, limit)`。 */
|
|
78
83
|
getDailyReportIdsFirstPage: Array<[string, number]>
|
|
84
|
+
/**
|
|
85
|
+
* 各サービス呼び出しが受け取った可視集合。
|
|
86
|
+
*
|
|
87
|
+
* 記録しないと「ハンドラが可視集合を渡し忘れた」欠陥がテストに一切映らない
|
|
88
|
+
* (サービスは stub なので実際の絞り込みは起きず、素通りが成功に見える)。
|
|
89
|
+
*/
|
|
90
|
+
visibleArgs: Array<{ method: string; visible: VisibleSourceTypeSet }>
|
|
91
|
+
/** `resolveVisibleSourceTypes` ポートの呼び出し回数。 */
|
|
92
|
+
resolveVisibleSourceTypes: number
|
|
93
|
+
/** ポートが受け取った解決理由の並び (initial / refresh の別を検証する)。 */
|
|
94
|
+
visibilityResolveReasons: DailyReportVisibilityResolveReason[]
|
|
95
|
+
/** ミューテーションが受け取った `(reportHubId, visible)`。 */
|
|
96
|
+
mutations: Array<{ method: string; reportHubId: number; visible: VisibleSourceTypeSet }>
|
|
79
97
|
}
|
|
80
98
|
|
|
81
99
|
/**
|
|
@@ -85,8 +103,20 @@ export type HandlersFixtureCalls = {
|
|
|
85
103
|
* @param options Fixture adjustments. フィクスチャの調整点。
|
|
86
104
|
* @returns The config and its call log. 設定と呼び出し記録。
|
|
87
105
|
*/
|
|
88
|
-
export const makeHandlersFixture = (options: HandlersFixtureOptions = {}): { config: DailyReportHandlersConfig; calls: HandlersFixtureCalls } => {
|
|
89
|
-
const calls: HandlersFixtureCalls = {
|
|
106
|
+
export const makeHandlersFixture = (options: HandlersFixtureOptions = {}): { config: DailyReportHandlersConfig; calls: HandlersFixtureCalls; pushSseEntry: (entry: StreamEntry) => void } => {
|
|
107
|
+
const calls: HandlersFixtureCalls = {
|
|
108
|
+
clearCache: 0,
|
|
109
|
+
getAttachmentForUser: [],
|
|
110
|
+
readAttachment: [],
|
|
111
|
+
markAttachmentMissing: [],
|
|
112
|
+
markAttachmentPresent: [],
|
|
113
|
+
getDailyReportIds: [],
|
|
114
|
+
getDailyReportIdsFirstPage: [],
|
|
115
|
+
visibleArgs: [],
|
|
116
|
+
resolveVisibleSourceTypes: 0,
|
|
117
|
+
visibilityResolveReasons: [],
|
|
118
|
+
mutations: [],
|
|
119
|
+
}
|
|
90
120
|
|
|
91
121
|
const user = options.user === undefined ? { id: TEST_EXTERNAL_USER_ID } : options.user
|
|
92
122
|
const internalUserId = options.internalUserId === undefined ? TEST_INTERNAL_USER_ID : options.internalUserId
|
|
@@ -98,22 +128,52 @@ export const makeHandlersFixture = (options: HandlersFixtureOptions = {}): { con
|
|
|
98
128
|
clearCache: async () => {
|
|
99
129
|
calls.clearCache += 1
|
|
100
130
|
},
|
|
101
|
-
getDailyReportIdsByExternalId: async (externalId: string, opts
|
|
131
|
+
getDailyReportIdsByExternalId: async (externalId: string, opts: { forceRefresh?: boolean } | undefined, visible: VisibleSourceTypeSet) => {
|
|
102
132
|
calls.getDailyReportIds.push([externalId, opts?.forceRefresh ?? false])
|
|
133
|
+
calls.visibleArgs.push({ method: "getDailyReportIdsByExternalId", visible })
|
|
103
134
|
if (options.idsStreamError) throw options.idsStreamError
|
|
104
135
|
if (options.idsStreamFullGate) await options.idsStreamFullGate
|
|
105
136
|
return options.idsStreamItems ?? []
|
|
106
137
|
},
|
|
107
|
-
getDailyReportIdsFirstPageByExternalId: async (externalId: string, limit: number) => {
|
|
138
|
+
getDailyReportIdsFirstPageByExternalId: async (externalId: string, limit: number, visible: VisibleSourceTypeSet) => {
|
|
108
139
|
calls.getDailyReportIdsFirstPage.push([externalId, limit])
|
|
140
|
+
calls.visibleArgs.push({ method: "getDailyReportIdsFirstPageByExternalId", visible })
|
|
109
141
|
if (options.idsStreamError) throw options.idsStreamError
|
|
110
142
|
return options.idsStreamFirstPageItems ?? (options.idsStreamItems ?? []).slice(0, limit)
|
|
111
143
|
},
|
|
112
|
-
getAttachmentForUser: async (attachmentId: number, viewerUserId: number) => {
|
|
144
|
+
getAttachmentForUser: async (attachmentId: number, viewerUserId: number, visible: VisibleSourceTypeSet) => {
|
|
113
145
|
// 引数を記録する。記録しないと「閲覧者 ID を渡し忘れた」欠陥がテストに映らない
|
|
114
146
|
calls.getAttachmentForUser.push([attachmentId, viewerUserId])
|
|
147
|
+
calls.visibleArgs.push({ method: "getAttachmentForUser", visible })
|
|
115
148
|
return attachment
|
|
116
149
|
},
|
|
150
|
+
getDailyReportsByBusinessDateByExternalId: async (_businessDate: string, _externalId: string, _opts: unknown, visible: VisibleSourceTypeSet) => {
|
|
151
|
+
calls.visibleArgs.push({ method: "getDailyReportsByBusinessDateByExternalId", visible })
|
|
152
|
+
return []
|
|
153
|
+
},
|
|
154
|
+
getDailyReportDetailByIdByExternalId: async (_reportHubId: number, _externalId: string, _opts: unknown, visible: VisibleSourceTypeSet) => {
|
|
155
|
+
calls.visibleArgs.push({ method: "getDailyReportDetailByIdByExternalId", visible })
|
|
156
|
+
return null
|
|
157
|
+
},
|
|
158
|
+
setStarStatus: async (_userId: number, reportHubId: number, _businessDate: string | null, _value: boolean, _clientTempId: string, visible: VisibleSourceTypeSet) => {
|
|
159
|
+
calls.mutations.push({ method: "setStarStatus", reportHubId, visible })
|
|
160
|
+
if (options.mutationError) throw options.mutationError
|
|
161
|
+
return { hubId: reportHubId, userId: internalUserId ?? 0, isRead: false, isStarred: true, createdAt: "", createdBy: "", updatedAt: "", updatedBy: "" }
|
|
162
|
+
},
|
|
163
|
+
setReadStatus: async (_userId: number, reportHubId: number, _businessDate: string | null, _value: boolean, _clientTempId: string, visible: VisibleSourceTypeSet) => {
|
|
164
|
+
calls.mutations.push({ method: "setReadStatus", reportHubId, visible })
|
|
165
|
+
if (options.mutationError) throw options.mutationError
|
|
166
|
+
return { hubId: reportHubId, userId: internalUserId ?? 0, isRead: true, isStarred: false, createdAt: "", createdBy: "", updatedAt: "", updatedBy: "" }
|
|
167
|
+
},
|
|
168
|
+
addComment: async (_userId: number, reportHubId: number, _content: string, _businessDate: string | null, _clientTempId: string, visible: VisibleSourceTypeSet) => {
|
|
169
|
+
calls.mutations.push({ method: "addComment", reportHubId, visible })
|
|
170
|
+
if (options.mutationError) throw options.mutationError
|
|
171
|
+
return { id: 1, userId: "ENC1", userName: "u", content: "c", createdAt: "", isMine: true }
|
|
172
|
+
},
|
|
173
|
+
deleteComment: async (_userId: number, reportHubId: number, _commentId: number, _businessDate: string | null, _clientTempId: string, visible: VisibleSourceTypeSet) => {
|
|
174
|
+
calls.mutations.push({ method: "deleteComment", reportHubId, visible })
|
|
175
|
+
if (options.mutationError) throw options.mutationError
|
|
176
|
+
},
|
|
117
177
|
markAttachmentMissing: async (id: number) => {
|
|
118
178
|
calls.markAttachmentMissing.push(id)
|
|
119
179
|
},
|
|
@@ -122,11 +182,34 @@ export const makeHandlersFixture = (options: HandlersFixtureOptions = {}): { con
|
|
|
122
182
|
},
|
|
123
183
|
} as unknown as DailyReportService
|
|
124
184
|
|
|
185
|
+
// 捕捉した onEntry を露出し、テストから任意の Stream エントリを流し込めるようにする。
|
|
186
|
+
// 露出しないと SSE の受信側フィルタ (区分・宛先) を一切検証できない
|
|
187
|
+
const sseSubscribers: Array<(entry: StreamEntry) => void> = []
|
|
188
|
+
/** 購読中の SSE ハンドラ全員へエントリを流し込む (テスト用の注入口)。 */
|
|
189
|
+
const pushSseEntry = (entry: StreamEntry) => {
|
|
190
|
+
for (const onEntry of [...sseSubscribers]) onEntry(entry)
|
|
191
|
+
}
|
|
192
|
+
|
|
125
193
|
const base: DailyReportHandlersConfig = {
|
|
126
194
|
authenticate: async () => ({ user: user ?? undefined, cookie: options.cookie }),
|
|
195
|
+
resolveVisibleSourceTypes: options.resolveVisibleSourceTypes
|
|
196
|
+
? async (request: Request, context: DailyReportVisibilityResolveContext) => {
|
|
197
|
+
calls.resolveVisibleSourceTypes += 1
|
|
198
|
+
calls.visibilityResolveReasons.push(context.reason)
|
|
199
|
+
return options.resolveVisibleSourceTypes?.(request, context)
|
|
200
|
+
}
|
|
201
|
+
: undefined,
|
|
127
202
|
service,
|
|
128
203
|
encodeUserId: (id) => `ENC${id}`,
|
|
129
|
-
sseReader: {
|
|
204
|
+
sseReader: {
|
|
205
|
+
subscribe: (onEntry: (entry: StreamEntry) => void) => {
|
|
206
|
+
sseSubscribers.push(onEntry)
|
|
207
|
+
return () => {
|
|
208
|
+
const index = sseSubscribers.indexOf(onEntry)
|
|
209
|
+
if (index >= 0) sseSubscribers.splice(index, 1)
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
} as unknown as DailyReportSseReader,
|
|
130
213
|
streamKey: "test-stream",
|
|
131
214
|
attachmentMaxBytes: options.attachmentMaxBytes,
|
|
132
215
|
attachmentRateLimitPerMinute: options.attachmentRateLimitPerMinute,
|
|
@@ -135,7 +218,7 @@ export const makeHandlersFixture = (options: HandlersFixtureOptions = {}): { con
|
|
|
135
218
|
}
|
|
136
219
|
|
|
137
220
|
if (options.omitPorts) {
|
|
138
|
-
return { config: base, calls }
|
|
221
|
+
return { config: base, calls, pushSseEntry }
|
|
139
222
|
}
|
|
140
223
|
|
|
141
224
|
return {
|
|
@@ -153,6 +236,7 @@ export const makeHandlersFixture = (options: HandlersFixtureOptions = {}): { con
|
|
|
153
236
|
},
|
|
154
237
|
},
|
|
155
238
|
calls,
|
|
239
|
+
pushSseEntry,
|
|
156
240
|
}
|
|
157
241
|
}
|
|
158
242
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the source-type visibility helpers.
|
|
3
|
+
* sourceType 可視性ヘルパーの単体テスト。
|
|
4
|
+
*
|
|
5
|
+
* ここで固定するのは「無制限 (null) と全拒否 (空集合) を決して取り違えない」という不変条件であり、
|
|
6
|
+
* 取り違えは情報漏洩 (全件配信) か機能停止 (0 件) に直結する。
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from "vitest"
|
|
9
|
+
import { computeSourceTypeDigest, isSourceTypeVisible, normalizeVisibleSourceTypes } from "./visibility"
|
|
10
|
+
|
|
11
|
+
describe("normalizeVisibleSourceTypes", () => {
|
|
12
|
+
it("undefined と null は無制限 (null) になる", () => {
|
|
13
|
+
expect(normalizeVisibleSourceTypes(undefined)).toBeNull()
|
|
14
|
+
expect(normalizeVisibleSourceTypes(null)).toBeNull()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it("空配列は空集合 (0 件) であり、無制限へは倒れない", () => {
|
|
18
|
+
const result = normalizeVisibleSourceTypes([])
|
|
19
|
+
// ❗ ここが null になると「権限なし」が「全件可視」へ反転する
|
|
20
|
+
expect(result).not.toBeNull()
|
|
21
|
+
expect(result?.size).toBe(0)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("大小文字を畳み込み、前後空白を除去し、重複と空文字を落とす", () => {
|
|
25
|
+
const result = normalizeVisibleSourceTypes(["NI", " ni ", "kintone", "", " ", "Kintone"])
|
|
26
|
+
expect([...(result ?? [])].sort()).toEqual(["kintone", "ni"])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("非文字列要素は可視性を広げずに捨てられる", () => {
|
|
30
|
+
const raw = ["NI", 42, null, undefined, {}] as unknown as readonly string[]
|
|
31
|
+
const result = normalizeVisibleSourceTypes(raw)
|
|
32
|
+
expect([...(result ?? [])]).toEqual(["ni"])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("配列以外 (契約違反の文字列) は文字反復せず全拒否になる", () => {
|
|
36
|
+
// `for...of "NI"` が文字単位で回ると {"n","i"} という誤った可視集合になる
|
|
37
|
+
const result = normalizeVisibleSourceTypes("NI" as unknown as readonly string[])
|
|
38
|
+
expect(result?.size).toBe(0)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe("isSourceTypeVisible", () => {
|
|
43
|
+
it("無制限 (null) は sourceType を問わず常に可視", () => {
|
|
44
|
+
expect(isSourceTypeVisible(null, "NI")).toBe(true)
|
|
45
|
+
expect(isSourceTypeVisible(null, null)).toBe(true)
|
|
46
|
+
expect(isSourceTypeVisible(null, undefined)).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("限定時は大小文字を無視して一致判定する", () => {
|
|
50
|
+
const set = normalizeVisibleSourceTypes(["ni", "kintone"])
|
|
51
|
+
expect(isSourceTypeVisible(set, "NI")).toBe(true)
|
|
52
|
+
expect(isSourceTypeVisible(set, "Kintone")).toBe(true)
|
|
53
|
+
// 列値のトリムは行わない (SQL 述語と揃えるため。詳細は「JS 判定と SQL 述語の対称性」)
|
|
54
|
+
expect(isSourceTypeVisible(set, " ni ")).toBe(false)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("限定時に集合外の区分は不可視", () => {
|
|
58
|
+
const set = normalizeVisibleSourceTypes(["kintone"])
|
|
59
|
+
expect(isSourceTypeVisible(set, "NI")).toBe(false)
|
|
60
|
+
expect(isSourceTypeVisible(set, "Internal")).toBe(false)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("限定時に区分不明 (null / undefined) は不可視 (fail-close)", () => {
|
|
64
|
+
const set = normalizeVisibleSourceTypes(["ni"])
|
|
65
|
+
expect(isSourceTypeVisible(set, null)).toBe(false)
|
|
66
|
+
expect(isSourceTypeVisible(set, undefined)).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("空集合はいかなる区分も不可視", () => {
|
|
70
|
+
const set = normalizeVisibleSourceTypes([])
|
|
71
|
+
expect(isSourceTypeVisible(set, "NI")).toBe(false)
|
|
72
|
+
expect(isSourceTypeVisible(set, "Internal")).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe("computeSourceTypeDigest", () => {
|
|
77
|
+
it("無制限と全拒否は互いに異なる固定値になる", () => {
|
|
78
|
+
expect(computeSourceTypeDigest(null)).toBe("all")
|
|
79
|
+
expect(computeSourceTypeDigest(normalizeVisibleSourceTypes([]))).toBe("none")
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("正規化後に等価な集合は同一ダイジェストになる", () => {
|
|
83
|
+
const a = computeSourceTypeDigest(normalizeVisibleSourceTypes(["NI"]))
|
|
84
|
+
const b = computeSourceTypeDigest(normalizeVisibleSourceTypes([" ni "]))
|
|
85
|
+
expect(a).toBe(b)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("要素順に依存しない", () => {
|
|
89
|
+
const a = computeSourceTypeDigest(normalizeVisibleSourceTypes(["ni", "kintone"]))
|
|
90
|
+
const b = computeSourceTypeDigest(normalizeVisibleSourceTypes(["kintone", "ni"]))
|
|
91
|
+
expect(a).toBe(b)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("論理的に異なる集合は必ず別のダイジェストになる (相互汚染の防止)", () => {
|
|
95
|
+
const digests = [
|
|
96
|
+
computeSourceTypeDigest(null),
|
|
97
|
+
computeSourceTypeDigest(normalizeVisibleSourceTypes([])),
|
|
98
|
+
computeSourceTypeDigest(normalizeVisibleSourceTypes(["ni"])),
|
|
99
|
+
computeSourceTypeDigest(normalizeVisibleSourceTypes(["kintone"])),
|
|
100
|
+
computeSourceTypeDigest(normalizeVisibleSourceTypes(["internal"])),
|
|
101
|
+
computeSourceTypeDigest(normalizeVisibleSourceTypes(["ni", "kintone"])),
|
|
102
|
+
computeSourceTypeDigest(normalizeVisibleSourceTypes(["ni", "internal"])),
|
|
103
|
+
computeSourceTypeDigest(normalizeVisibleSourceTypes(["ni", "kintone", "internal"])),
|
|
104
|
+
]
|
|
105
|
+
expect(new Set(digests).size).toBe(digests.length)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("巨大な集合はハッシュへ退避しつつキー長が有界になる", () => {
|
|
109
|
+
const many = Array.from({ length: 200 }, (_, index) => `source-type-${index}`)
|
|
110
|
+
const digest = computeSourceTypeDigest(normalizeVisibleSourceTypes(many))
|
|
111
|
+
expect(digest.startsWith("h:")).toBe(true)
|
|
112
|
+
expect(digest.length).toBeLessThanOrEqual(16)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("退避したハッシュも生連結と衝突しない接頭辞を持つ", () => {
|
|
116
|
+
const many = Array.from({ length: 200 }, (_, index) => `source-type-${index}`)
|
|
117
|
+
const hashed = computeSourceTypeDigest(normalizeVisibleSourceTypes(many))
|
|
118
|
+
const raw = computeSourceTypeDigest(normalizeVisibleSourceTypes(["ni"]))
|
|
119
|
+
expect(hashed).not.toBe(raw)
|
|
120
|
+
expect(raw.startsWith("h:")).toBe(false)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe("JS 判定と SQL 述語の対称性", () => {
|
|
125
|
+
// 同じ 3 入力に対し、両者が同じ三値意味を返すことを固定する。
|
|
126
|
+
// どちらか一方だけ畳み込み規則を変えると、SSE (JS 判定) と読み取り (SQL 判定) で
|
|
127
|
+
// 同じ行の可視性が食い違い、SSE 側が fail-open へずれる
|
|
128
|
+
it("列値は両側ともトリムしない (空白混じりの値は双方で不可視 = fail-close)", () => {
|
|
129
|
+
const set = normalizeVisibleSourceTypes(["NI"])
|
|
130
|
+
// JS 判定: 前後空白付きの列値は一致しない
|
|
131
|
+
expect(isSourceTypeVisible(set, " NI")).toBe(false)
|
|
132
|
+
expect(isSourceTypeVisible(set, "NI ")).toBe(false)
|
|
133
|
+
// 空白なしは一致する (大小文字は無視)
|
|
134
|
+
expect(isSourceTypeVisible(set, "ni")).toBe(true)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("リゾルバのトークン側だけは前後空白を吸収する", () => {
|
|
138
|
+
const set = normalizeVisibleSourceTypes([" NI "])
|
|
139
|
+
expect(isSourceTypeVisible(set, "NI")).toBe(true)
|
|
140
|
+
expect([...(set ?? [])]).toEqual(["ni"])
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source-type visibility set: normalization, digest, and predicate helpers.
|
|
3
|
+
* sourceType 可視集合の正規化・ダイジェスト・判定ヘルパー群。
|
|
4
|
+
*
|
|
5
|
+
* 中立文字列 (`DailyReportHub.sourceType` の値) のみを扱い、いかなる認可パッケージの型にも
|
|
6
|
+
* 依存しない。可視集合は 2 状態を厳密に区別する:
|
|
7
|
+
*
|
|
8
|
+
* - `null` = 無制限 (全件)。ポート未注入時の既定であり、現行互換の意味を持つ。
|
|
9
|
+
* - `Set` = その集合に限定。**空集合は 0 件 (fail-close)** であって無制限ではない。
|
|
10
|
+
*
|
|
11
|
+
* この 2 状態の取り違えは情報漏洩か機能停止に直結するため、判定・畳み込み・キー生成を
|
|
12
|
+
* 本ファイルへ一元化し、ハンドラ層とサービス層の双方がここだけを参照する。
|
|
13
|
+
*/
|
|
14
|
+
import { inArray, type SQL, sql } from "drizzle-orm"
|
|
15
|
+
import type { AnyMsSqlColumn } from "drizzle-orm/mssql-core"
|
|
16
|
+
import type { DailyReportVisibleSourceTypes } from "./ports"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Normalized visible set. null = unrestricted (all rows), ReadonlySet = restricted (empty = zero rows).
|
|
20
|
+
* 正規化済みの可視集合。null は無制限 (全件)、ReadonlySet は限定 (空集合は 0 件)。
|
|
21
|
+
*/
|
|
22
|
+
export type VisibleSourceTypeSet = ReadonlySet<string> | null
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Separator for digest concatenation; never occurs inside a sourceType value.
|
|
26
|
+
* ダイジェスト連結用の区切り文字。sourceType の値には決して現れない制御文字。
|
|
27
|
+
*/
|
|
28
|
+
const DIGEST_SEPARATOR = "\u0001"
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Maximum digest length before falling back to a hash.
|
|
32
|
+
* ハッシュへ退避する前のダイジェスト最大長。
|
|
33
|
+
*
|
|
34
|
+
* キャッシュキーが際限なく伸びるのを防ぐだけの閾値であり、通常の区分数 (数個) では到達しない。
|
|
35
|
+
*/
|
|
36
|
+
const DIGEST_RAW_MAX_LENGTH = 64
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Folds a stored sourceType value into its comparison key (case-insensitive only).
|
|
40
|
+
* 格納済みの sourceType 値を比較キーへ畳み込む処理 (大小文字非依存のみ)。
|
|
41
|
+
*
|
|
42
|
+
* ❗ ここで `trim()` してはならない。SQL 側の述語は `LOWER(source_type)` だけを掛けており
|
|
43
|
+
* 列値をトリムしないため、JS 側だけ切り詰めると同じ行について SSE (JS 判定) は可視・
|
|
44
|
+
* 読み取り (SQL 判定) は不可視という乖離が生まれる (SSE が fail-open 側へずれる)。
|
|
45
|
+
* 両者を「列値は畳み込むだけ」に揃え、空白混じりの値は双方で不可視 = fail-close とする。
|
|
46
|
+
*/
|
|
47
|
+
const foldStoredSourceType = (raw: string): string => raw.toLowerCase()
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Folds a resolver-supplied token into its comparison key (trimmed, case-insensitive).
|
|
51
|
+
* リゾルバ由来のトークンを比較キーへ畳み込む処理 (前後空白除去・大小文字非依存)。
|
|
52
|
+
*
|
|
53
|
+
* トークンは人手で設定された設定値であり、前後の空白は書き間違いとして吸収してよい。
|
|
54
|
+
* 一方で列値 ({@link foldStoredSourceType}) は DB のデータなので勝手に切り詰めない。
|
|
55
|
+
*/
|
|
56
|
+
const foldResolverToken = (raw: string): string => raw.trim().toLowerCase()
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Normalizes a raw resolver return into a VisibleSourceTypeSet.
|
|
60
|
+
* ポートの生戻り値を正規化済み可視集合へ変換する処理。
|
|
61
|
+
*
|
|
62
|
+
* `null` / `undefined` は無制限 (`null`) へ。配列は各要素を畳み込み・空文字除去・重複排除して
|
|
63
|
+
* `Set` 化する。**空配列は空 `Set` (0 件) を返し、`null` (無制限) へは決して倒さない** —
|
|
64
|
+
* この取り違えが情報漏洩か機能停止に直結するため。
|
|
65
|
+
*
|
|
66
|
+
* @param raw Raw resolver return value. ポートの生戻り値。
|
|
67
|
+
* @returns Normalized visible set. 正規化済みの可視集合。
|
|
68
|
+
*/
|
|
69
|
+
export function normalizeVisibleSourceTypes(raw: DailyReportVisibleSourceTypes): VisibleSourceTypeSet {
|
|
70
|
+
if (raw == null) {
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
// 配列以外 (契約違反。例: リゾルバが誤って文字列 "NI" を返す) は fail-close で空集合にする。
|
|
74
|
+
// ガードが無いと `for...of "NI"` が文字単位で反復し {"n","i"} という誤った可視集合になる
|
|
75
|
+
if (!Array.isArray(raw)) {
|
|
76
|
+
return new Set<string>()
|
|
77
|
+
}
|
|
78
|
+
const set = new Set<string>()
|
|
79
|
+
for (const token of raw) {
|
|
80
|
+
// 非文字列要素は黙って捨てる (可視性を広げる方向には決して働かない)
|
|
81
|
+
if (typeof token !== "string") {
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
const folded = foldResolverToken(token)
|
|
85
|
+
if (folded.length > 0) {
|
|
86
|
+
set.add(folded)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return set
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Tests whether a report's sourceType is visible under the given set.
|
|
94
|
+
* ある日報の sourceType が可視集合に含まれるかの三値判定。
|
|
95
|
+
*
|
|
96
|
+
* - 無制限 (`null`): 常に `true`。
|
|
97
|
+
* - 限定 + sourceType 判明: 畳み込み一致で判定。
|
|
98
|
+
* - 限定 + sourceType 不明 (`null` / `undefined`): `false` — 制限された閲覧者は「不明な区分」を
|
|
99
|
+
* 見られない。区分を運ばない旧 SSE エントリ等を fail-close 側へ倒すための規則。
|
|
100
|
+
*
|
|
101
|
+
* @param set Normalized visible set. 正規化済みの可視集合。
|
|
102
|
+
* @param sourceType The row's source type, if known. 判明していれば当該行のソース種別。
|
|
103
|
+
* @returns True when visible. 可視なら true。
|
|
104
|
+
*/
|
|
105
|
+
export function isSourceTypeVisible(set: VisibleSourceTypeSet, sourceType: string | null | undefined): boolean {
|
|
106
|
+
if (set === null) {
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
if (sourceType == null) {
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
return set.has(foldStoredSourceType(sourceType))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Tells whether the set denies everything (zero rows).
|
|
117
|
+
* 可視集合が全拒否 (0 件) を意味するかを判定する処理。
|
|
118
|
+
*
|
|
119
|
+
* 真のとき呼び出し側は DB へ問い合わせず即座に空を返してよい (無駄クエリの排除)。
|
|
120
|
+
* これは性能上の短絡であり、認可の防御そのものは {@link buildVisibleSourceTypeCondition} が担う。
|
|
121
|
+
*
|
|
122
|
+
* @param set Normalized visible set. 正規化済みの可視集合。
|
|
123
|
+
* @returns True when nothing is visible. 何も可視でないなら true。
|
|
124
|
+
*/
|
|
125
|
+
export function deniesEverySourceType(set: VisibleSourceTypeSet): boolean {
|
|
126
|
+
return set !== null && set.size === 0
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Builds the SQL condition restricting rows to the visible source types.
|
|
131
|
+
* 可視 sourceType へ行を限定する SQL 条件を生成する処理 (三値)。
|
|
132
|
+
*
|
|
133
|
+
* {@link isSourceTypeVisible} の SQL 版であり、同じ三値を同じ向きへ写像する:
|
|
134
|
+
*
|
|
135
|
+
* - 無制限 (`null`): `undefined` を返す。drizzle の `and(...)` は `undefined` を無視するため、
|
|
136
|
+
* 既存の述語がそのまま残る (現行互換)。
|
|
137
|
+
* - 全拒否 (空集合): `1 = 0` を返す。**決して `undefined` へ倒さない** — 呼び出し側の短絡漏れが
|
|
138
|
+
* あっても生成器自身が fail-close する二重防御であり、ここを緩めると認可が反転する。
|
|
139
|
+
* - 限定: `LOWER(<column>) IN (...)`。トークンは正規化時に小文字畳み込み済みなので両辺が小文字となり、
|
|
140
|
+
* DB の照合順序 (collation) に依存せず決定的に大小文字非依存で一致する。
|
|
141
|
+
*
|
|
142
|
+
* @param sourceTypeColumn The source-type column to constrain. 制限対象の sourceType 列。
|
|
143
|
+
* @param set Normalized visible set. 正規化済みの可視集合。
|
|
144
|
+
* @returns Condition, or undefined when unrestricted. 条件 (無制限なら undefined)。
|
|
145
|
+
*/
|
|
146
|
+
export function buildVisibleSourceTypeCondition(sourceTypeColumn: AnyMsSqlColumn, set: VisibleSourceTypeSet): SQL | undefined {
|
|
147
|
+
if (set === null) {
|
|
148
|
+
return undefined
|
|
149
|
+
}
|
|
150
|
+
if (set.size === 0) {
|
|
151
|
+
return sql`1 = 0`
|
|
152
|
+
}
|
|
153
|
+
return inArray(sql`LOWER(${sourceTypeColumn})`, [...set])
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Computes a stable, bounded, collision-free cache-key digest of the set.
|
|
158
|
+
* 可視集合から安定・有界・衝突不能なキャッシュキー用ダイジェストを算出する処理。
|
|
159
|
+
*
|
|
160
|
+
* 無制限は `"all"`、空集合は `"none"`、それ以外は**畳み込み済みトークンを昇順にそのまま連結**する。
|
|
161
|
+
* 生連結のため、論理的に等価な集合は必ず同一の、論理的に異なる集合は必ず別のダイジェストになる
|
|
162
|
+
* (ハッシュ衝突で別権限のキャッシュ行を配信する事故が原理的に起きない)。権限が安定している間は
|
|
163
|
+
* キーが不変になるため、キャッシュは完全に再利用される (性能無退行)。
|
|
164
|
+
* 区分数が異常に多い場合だけキー長を抑えるため FNV-1a 32bit へ退避し、`h:` 接頭辞で生連結と区別する。
|
|
165
|
+
*
|
|
166
|
+
* @param set Normalized visible set. 正規化済みの可視集合。
|
|
167
|
+
* @returns Digest usable as a cache-key component. キャッシュキー構成要素として使えるダイジェスト。
|
|
168
|
+
*/
|
|
169
|
+
export function computeSourceTypeDigest(set: VisibleSourceTypeSet): string {
|
|
170
|
+
if (set === null) {
|
|
171
|
+
return "all"
|
|
172
|
+
}
|
|
173
|
+
if (set.size === 0) {
|
|
174
|
+
return "none"
|
|
175
|
+
}
|
|
176
|
+
// 昇順ソートで要素順に依存しないキーにする (同じ集合は常に同じダイジェスト)
|
|
177
|
+
const joined = [...set].sort().join(DIGEST_SEPARATOR)
|
|
178
|
+
if (joined.length <= DIGEST_RAW_MAX_LENGTH) {
|
|
179
|
+
return joined
|
|
180
|
+
}
|
|
181
|
+
// 長大キーの退避のみハッシュ化する。衝突しても影響は同一ユーザーのキャッシュ再利用に限られ、
|
|
182
|
+
// かつ TTL で失効するが、生連結と混同しないよう接頭辞で区別する
|
|
183
|
+
let hash = 0x811c9dc5
|
|
184
|
+
for (let i = 0; i < joined.length; i++) {
|
|
185
|
+
hash ^= joined.charCodeAt(i)
|
|
186
|
+
hash = Math.imul(hash, 0x01000193)
|
|
187
|
+
}
|
|
188
|
+
return `h:${(hash >>> 0).toString(16)}`
|
|
189
|
+
}
|
package/src/server.spec.ts
CHANGED
|
@@ -47,3 +47,75 @@ describe("createDailyReportServer: enableDevCacheClear の結線", () => {
|
|
|
47
47
|
expect(res.status).toBe(200)
|
|
48
48
|
})
|
|
49
49
|
})
|
|
50
|
+
|
|
51
|
+
describe("createDailyReportServer: resolveVisibleSourceTypes の結線", () => {
|
|
52
|
+
/** ポートを注入したファサードを組み立てる。 */
|
|
53
|
+
const makeServerWithPort = (resolveVisibleSourceTypes?: () => Promise<string[]>) =>
|
|
54
|
+
createDailyReportServer({
|
|
55
|
+
db: noopDb,
|
|
56
|
+
tables,
|
|
57
|
+
userTable: testUsers,
|
|
58
|
+
resolveUserId: async () => 8,
|
|
59
|
+
encodeUserId: (id) => `ENC${id}`,
|
|
60
|
+
authenticate: async () => ({ user: { id: "ext-8" }, cookie: null }),
|
|
61
|
+
draftLabelName: "draft",
|
|
62
|
+
resolveVisibleSourceTypes,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/** 読み取り API の引数。 */
|
|
66
|
+
const readArgs = () => ({
|
|
67
|
+
request: new Request("http://localhost/daily_report/api/business-date?businessDate=2099-01-05"),
|
|
68
|
+
params: { endpoint: "business-date" } as Record<string, string | undefined>,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("注入したポートがハンドラまで伝播して呼ばれる", async () => {
|
|
72
|
+
// ❗ createDailyReportServer の明示列挙からこの 1 行が落ちると、アプリがポートを
|
|
73
|
+
// 注入しても黙って捨てられ、区分フィルタが効かないまま全件配信になる (認可バイパス)
|
|
74
|
+
let calls = 0
|
|
75
|
+
const server = makeServerWithPort(async () => {
|
|
76
|
+
calls += 1
|
|
77
|
+
return ["NI"]
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
await server.api.loader(readArgs())
|
|
81
|
+
|
|
82
|
+
expect(calls).toBe(1)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// 未注入時に無制限 (null) が全経路へ渡ることは handlers.visibility.spec.ts が
|
|
86
|
+
// サービス受領引数レベルで固定しているため、ここでは転送の有無だけを見る
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe("createDailyReportServer: knownSourceTypes", () => {
|
|
90
|
+
it("externalSources の sourceType と Internal を重複なく公開する", () => {
|
|
91
|
+
const server = createDailyReportServer({
|
|
92
|
+
db: noopDb,
|
|
93
|
+
tables,
|
|
94
|
+
userTable: testUsers,
|
|
95
|
+
resolveUserId: async () => 8,
|
|
96
|
+
encodeUserId: (id) => `ENC${id}`,
|
|
97
|
+
authenticate: async () => ({ user: { id: "ext-8" }, cookie: null }),
|
|
98
|
+
draftLabelName: "draft",
|
|
99
|
+
externalSources: [
|
|
100
|
+
{ sourceType: "NI", table: {}, idColumn: tables.hub.id, mapRow: () => ({}) },
|
|
101
|
+
{ sourceType: "kintone", table: {}, idColumn: tables.hub.id, mapRow: () => ({}) },
|
|
102
|
+
],
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
expect([...server.knownSourceTypes].sort()).toEqual(["Internal", "NI", "kintone"].sort())
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("externalSources 未注入でも Internal だけは必ず含む", () => {
|
|
109
|
+
const server = createDailyReportServer({
|
|
110
|
+
db: noopDb,
|
|
111
|
+
tables,
|
|
112
|
+
userTable: testUsers,
|
|
113
|
+
resolveUserId: async () => 8,
|
|
114
|
+
encodeUserId: (id) => `ENC${id}`,
|
|
115
|
+
authenticate: async () => ({ user: { id: "ext-8" }, cookie: null }),
|
|
116
|
+
draftLabelName: "draft",
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
expect([...server.knownSourceTypes]).toEqual(["Internal"])
|
|
120
|
+
})
|
|
121
|
+
})
|
package/src/server.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { createEpochStore, SqlResultCache } from "./server/cache"
|
|
6
6
|
import type { DailyReportExternalSource } from "./server/external-source"
|
|
7
7
|
import { createDailyReportHandlers } from "./server/handlers"
|
|
8
|
-
import type { DailyReportAuthenticate, DailyReportReadAttachment } from "./server/ports"
|
|
8
|
+
import type { DailyReportAuthenticate, DailyReportReadAttachment, DailyReportResolveVisibleSourceTypes } from "./server/ports"
|
|
9
9
|
import { createDailyReportService, type DailyReportServiceConfig } from "./server/service"
|
|
10
10
|
import { DailyReportSseReader } from "./server/sse-reader"
|
|
11
11
|
|
|
@@ -19,11 +19,20 @@ export * from "./server/response"
|
|
|
19
19
|
export * from "./server/schema"
|
|
20
20
|
export * from "./server/service"
|
|
21
21
|
export * from "./server/sse-reader"
|
|
22
|
+
export * from "./server/visibility"
|
|
22
23
|
|
|
23
24
|
/** createDailyReportServer の設定 (サービス設定 + 認証ポート + ハンドラ設定)。 */
|
|
24
25
|
export type DailyReportServerConfig = Omit<DailyReportServiceConfig, "cache" | "epochs"> & {
|
|
25
26
|
/** リクエスト認証ポート。 */
|
|
26
27
|
authenticate: DailyReportAuthenticate
|
|
28
|
+
/**
|
|
29
|
+
* 可視 sourceType リゾルバ。未注入なら全件 (現行互換)。
|
|
30
|
+
*
|
|
31
|
+
* サーバー側の全データ経路 (一覧 / 詳細 / コメント / 添付 / SSE) に一貫して効く。
|
|
32
|
+
* 戻り値は `DailyReportHub.sourceType` の値 (`knownSourceTypes` 参照) であり、認可リソースキーではない。
|
|
33
|
+
* `undefined` / `null` は無制限、空配列は 0 件、例外は全拒否 (fail-close)。
|
|
34
|
+
*/
|
|
35
|
+
resolveVisibleSourceTypes?: DailyReportResolveVisibleSourceTypes
|
|
27
36
|
/** 添付ファイル読み取りポート。未注入なら添付エンドポイントは常に 404。 */
|
|
28
37
|
readAttachment?: DailyReportReadAttachment
|
|
29
38
|
/** 添付ファイルの最大バイト数 (既定 32 MiB)。サービス側のワイヤ上限より低く保つこと。 */
|
|
@@ -75,6 +84,9 @@ export function createDailyReportServer(config: DailyReportServerConfig) {
|
|
|
75
84
|
// アプリが注入しても黙って捨てられ、添付エンドポイントは永久に 404 を返す。
|
|
76
85
|
const handlers = createDailyReportHandlers({
|
|
77
86
|
authenticate: config.authenticate,
|
|
87
|
+
// ❗ 明示列挙。ここへ足し忘れると注入されたポートが黙って捨てられ、区分フィルタが
|
|
88
|
+
// 一切効かないまま全件配信になる (認可バイパス)。server.spec.ts が配線を gate する
|
|
89
|
+
resolveVisibleSourceTypes: config.resolveVisibleSourceTypes,
|
|
78
90
|
service,
|
|
79
91
|
encodeUserId: config.encodeUserId,
|
|
80
92
|
attachmentIdCodec: config.attachmentIdCodec,
|
|
@@ -101,6 +113,13 @@ export function createDailyReportServer(config: DailyReportServerConfig) {
|
|
|
101
113
|
sseReader,
|
|
102
114
|
/** SSE Redis Stream キー。 */
|
|
103
115
|
streamKey: service.streamKey,
|
|
116
|
+
/**
|
|
117
|
+
* 既知の正準 sourceType 集合 (注入した `externalSources` の全 sourceType + `"Internal"`)。
|
|
118
|
+
*
|
|
119
|
+
* `resolveVisibleSourceTypes` が返すべき語彙そのものであり、消費側は起動時にこの集合と
|
|
120
|
+
* 自前のマッピングを突き合わせて誤トークン (無診断で 0 件になる) を検出できる。
|
|
121
|
+
*/
|
|
122
|
+
knownSourceTypes: [...new Set(["Internal", ...(config.externalSources ?? []).map((source) => source.sourceType)])] as readonly string[],
|
|
104
123
|
...handlers,
|
|
105
124
|
}
|
|
106
125
|
}
|