@aiquants/daily-report 0.13.0 → 0.14.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 +3 -0
- package/dist/server.d.mts +91 -30
- package/dist/server.d.ts +91 -30
- package/dist/server.js +8 -8
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +8 -8
- package/dist/server.mjs.map +1 -1
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +7 -5
- package/src/server/handlers.ids-stream.spec.ts +4 -2
- package/src/server/handlers.ts +30 -26
- package/src/server/handlers.visibility.spec.ts +70 -0
- package/src/server/service.spec.ts +90 -61
- package/src/server/service.ts +57 -52
- package/src/server/test-helpers/handlers-config.ts +19 -19
- package/src/server/visibility.spec.ts +129 -3
- package/src/server/visibility.ts +106 -11
package/src/server/service.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type { EpochStore, SqlResultCache, SqlResultCacheQueryOptions } from "./c
|
|
|
13
13
|
import type { DailyReportExternalSource } from "./external-source"
|
|
14
14
|
import type { DailyReportEncodeUserId, DailyReportIdCodec, DailyReportRedisProvider, DailyReportResolveUserId } from "./ports"
|
|
15
15
|
import type { DailyReportTables, DailyReportUserTable } from "./schema"
|
|
16
|
-
import { buildVisibleSourceTypeCondition,
|
|
16
|
+
import { buildVisibleSourceTypeCondition, computeViewerScopeDigest, DailyReportViewerScope, deniesEverySourceType, isSourceTypeVisible, type VisibleSourceTypeSet } from "./visibility"
|
|
17
17
|
|
|
18
18
|
export type { SqlResultCacheQueryOptions }
|
|
19
19
|
|
|
@@ -451,10 +451,10 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
451
451
|
* 区分 (sourceType) の可視性は緩和の対象外であり、他経路と完全に同一の条件を適用する。
|
|
452
452
|
*
|
|
453
453
|
* @param userId Internal numeric user id. 内部数値ユーザー ID。
|
|
454
|
-
* @param
|
|
454
|
+
* @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
|
|
455
455
|
* @returns Stream-ordered items. ストリーム順のアイテム一覧。
|
|
456
456
|
*/
|
|
457
|
-
const fetchDailyReportIdsByUserId = async (
|
|
457
|
+
const fetchDailyReportIdsByUserId = async (scope: DailyReportViewerScope, userId: number): Promise<DailyReportItem[]> => {
|
|
458
458
|
const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
|
|
459
459
|
const draftLabelJoinIds = await getDraftLabelJoinIds()
|
|
460
460
|
|
|
@@ -466,7 +466,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
466
466
|
})
|
|
467
467
|
.from(DailyReportHub)
|
|
468
468
|
.leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
|
|
469
|
-
.where(and(isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DailyReportHub.userId), isNull(DraftLabelRelation.hubId)), visibleSourceTypeCondition(visible)))
|
|
469
|
+
.where(and(isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DailyReportHub.userId), isNull(DraftLabelRelation.hubId)), visibleSourceTypeCondition(scope.visible)))
|
|
470
470
|
.orderBy(desc(DailyReportHub.businessDate), desc(DailyReportHub.id))
|
|
471
471
|
|
|
472
472
|
return result.map((item) => ({
|
|
@@ -486,16 +486,16 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
486
486
|
*
|
|
487
487
|
* @param externalId External user id. 外部ユーザー ID。
|
|
488
488
|
* @param limit Page size. ページサイズ。
|
|
489
|
-
* @param
|
|
489
|
+
* @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
|
|
490
490
|
* @returns First-page items in stream order. ストリーム順の先頭ページ。
|
|
491
491
|
*/
|
|
492
|
-
const getDailyReportIdsFirstPageByExternalId = async (externalId: string, limit: number
|
|
492
|
+
const getDailyReportIdsFirstPageByExternalId = async (scope: DailyReportViewerScope, externalId: string, limit: number): Promise<DailyReportItem[]> => {
|
|
493
493
|
const userId = await getUserIdByExternalId(externalId)
|
|
494
494
|
if (!userId) {
|
|
495
495
|
return []
|
|
496
496
|
}
|
|
497
497
|
// 全拒否なら DB を引かずに空を返す (0 件 = fail-close)
|
|
498
|
-
if (deniesEverySourceType(visible)) {
|
|
498
|
+
if (deniesEverySourceType(scope.visible)) {
|
|
499
499
|
return []
|
|
500
500
|
}
|
|
501
501
|
const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
|
|
@@ -510,7 +510,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
510
510
|
.top(limit)
|
|
511
511
|
.from(DailyReportHub)
|
|
512
512
|
.leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
|
|
513
|
-
.where(and(isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DailyReportHub.userId), isNull(DraftLabelRelation.hubId)), visibleSourceTypeCondition(visible)))
|
|
513
|
+
.where(and(isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DailyReportHub.userId), isNull(DraftLabelRelation.hubId)), visibleSourceTypeCondition(scope.visible)))
|
|
514
514
|
.orderBy(desc(DailyReportHub.businessDate), desc(DailyReportHub.id))
|
|
515
515
|
|
|
516
516
|
return result.map((item) => ({
|
|
@@ -523,7 +523,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
523
523
|
* Fetches daily report details for the provided business date.
|
|
524
524
|
* 指定した営業日の日報詳細を取得する内部処理。
|
|
525
525
|
*/
|
|
526
|
-
const fetchDailyReportsByBusinessDate = async (normalizedBusinessDate: string): Promise<DailyReportDetail[]> => {
|
|
526
|
+
const fetchDailyReportsByBusinessDate = async (scope: DailyReportViewerScope, normalizedBusinessDate: string): Promise<DailyReportDetail[]> => {
|
|
527
527
|
const rows = await applyExternalJoins(
|
|
528
528
|
db
|
|
529
529
|
.select<HubQueryRow>({
|
|
@@ -536,7 +536,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
536
536
|
)
|
|
537
537
|
.leftJoin(DailyReportInternal, eq(DailyReportHub.id, DailyReportInternal.hubId))
|
|
538
538
|
.leftJoin(users, eq(DailyReportHub.userId, users.id))
|
|
539
|
-
.where(and(eq(DailyReportHub.businessDate, sql`${normalizedBusinessDate}`), isNull(DailyReportHub.deletedAt)))
|
|
539
|
+
.where(and(eq(DailyReportHub.businessDate, sql`${normalizedBusinessDate}`), isNull(DailyReportHub.deletedAt), visibleSourceTypeCondition(scope.visible)))
|
|
540
540
|
.orderBy(desc(DailyReportHub.id))
|
|
541
541
|
|
|
542
542
|
const attachmentsMap = await getAttachmentsByHubIds(rows.map((r) => r.hub.id))
|
|
@@ -575,25 +575,25 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
575
575
|
*
|
|
576
576
|
* @param externalId External user id. 外部ユーザー ID。
|
|
577
577
|
* @param options Cache query options. キャッシュ問い合わせオプション。
|
|
578
|
-
* @param
|
|
578
|
+
* @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
|
|
579
579
|
* @returns Stream-ordered items. ストリーム順のアイテム一覧。
|
|
580
580
|
*/
|
|
581
|
-
const getDailyReportIdsByExternalId = async (externalId: string, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}
|
|
581
|
+
const getDailyReportIdsByExternalId = async (scope: DailyReportViewerScope, externalId: string, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}): Promise<DailyReportItem[]> => {
|
|
582
582
|
const userId = await getUserIdByExternalId(externalId)
|
|
583
583
|
if (!userId) {
|
|
584
584
|
return []
|
|
585
585
|
}
|
|
586
586
|
// 全拒否なら DB を引かずに空を返す (0 件 = fail-close)
|
|
587
|
-
if (deniesEverySourceType(visible)) {
|
|
587
|
+
if (deniesEverySourceType(scope.visible)) {
|
|
588
588
|
return []
|
|
589
589
|
}
|
|
590
590
|
|
|
591
591
|
// 可視集合ごとに別バケットへ隔離する (権限変更後に旧結果を配信しない)
|
|
592
|
-
const cacheKey = idsCacheKey(userId,
|
|
592
|
+
const cacheKey = idsCacheKey(userId, computeViewerScopeDigest(scope))
|
|
593
593
|
return sqlResultCache.getOrFetch<DailyReportItem>({
|
|
594
594
|
cacheKey,
|
|
595
595
|
fetcher: async () => {
|
|
596
|
-
const res = await fetchDailyReportIdsByUserId(
|
|
596
|
+
const res = await fetchDailyReportIdsByUserId(scope, userId)
|
|
597
597
|
return res
|
|
598
598
|
},
|
|
599
599
|
forceRefresh,
|
|
@@ -616,15 +616,20 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
616
616
|
* 必ず {@link getDailyReportsByBusinessDateByExternalId} を使うこと。
|
|
617
617
|
* (本関数は管理・バッチ・テスト用途に限定する。)
|
|
618
618
|
*/
|
|
619
|
-
const getDailyReportsByBusinessDate = (businessDate: string, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}): Promise<DailyReportDetail[]> => {
|
|
619
|
+
const getDailyReportsByBusinessDate = (scope: DailyReportViewerScope, businessDate: string, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}): Promise<DailyReportDetail[]> => {
|
|
620
620
|
const normalizedBusinessDate = normalizeBusinessDateKey(businessDate)
|
|
621
621
|
if (!normalizedBusinessDate) {
|
|
622
622
|
return Promise.resolve([])
|
|
623
623
|
}
|
|
624
|
+
// 全拒否なら DB を引かずに空を返す (0 件 = fail-close)
|
|
625
|
+
if (deniesEverySourceType(scope.visible)) {
|
|
626
|
+
return Promise.resolve([])
|
|
627
|
+
}
|
|
624
628
|
|
|
625
629
|
return sqlResultCache.getOrFetch<DailyReportDetail>({
|
|
626
|
-
|
|
627
|
-
|
|
630
|
+
// 可視集合ごとに別バケットへ隔離する (管理経路でも権限混在を起こさない)
|
|
631
|
+
cacheKey: `${buildBusinessDateCacheKey(normalizedBusinessDate)}:st:${computeViewerScopeDigest(scope)}`,
|
|
632
|
+
fetcher: () => fetchDailyReportsByBusinessDate(scope, normalizedBusinessDate),
|
|
628
633
|
forceRefresh,
|
|
629
634
|
snapshot,
|
|
630
635
|
ttlMsOverride: ttlMsOverride ?? DAILY_REPORT_BUSINESS_DATE_TTL_MS,
|
|
@@ -765,16 +770,16 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
765
770
|
*
|
|
766
771
|
* @param attachmentId Internal numeric attachment id. 内部数値の添付 ID。
|
|
767
772
|
* @param userId Internal numeric user id of the viewer. 閲覧者の内部数値ユーザー ID。
|
|
768
|
-
* @param
|
|
773
|
+
* @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
|
|
769
774
|
* @returns Attachment row when visible, otherwise null. 可視なら添付行、それ以外は null。
|
|
770
775
|
*/
|
|
771
|
-
const getAttachmentForUser = async (
|
|
776
|
+
const getAttachmentForUser = async (scope: DailyReportViewerScope, attachmentId: number, userId: number): Promise<{ fileName: string; filePath: string; fileType: string | null; fileSize: number | null } | null> => {
|
|
772
777
|
const attachment = tables.attachment
|
|
773
778
|
if (!attachment) {
|
|
774
779
|
return null
|
|
775
780
|
}
|
|
776
781
|
// 全拒否なら DB を引かずに null を返す (0 件 = fail-close)
|
|
777
|
-
if (deniesEverySourceType(visible)) {
|
|
782
|
+
if (deniesEverySourceType(scope.visible)) {
|
|
778
783
|
return null
|
|
779
784
|
}
|
|
780
785
|
const draftLabelJoinIds = await getDraftLabelJoinIds()
|
|
@@ -795,7 +800,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
795
800
|
.from(attachment)
|
|
796
801
|
.innerJoin(DailyReportHub, eq(DailyReportHub.id, attachment.hubId))
|
|
797
802
|
.leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
|
|
798
|
-
.where(and(eq(attachment.id, attachmentId), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId)), visibleSourceTypeCondition(visible)))
|
|
803
|
+
.where(and(eq(attachment.id, attachmentId), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId)), visibleSourceTypeCondition(scope.visible)))
|
|
799
804
|
|
|
800
805
|
return rows[0] ?? null
|
|
801
806
|
}
|
|
@@ -849,7 +854,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
849
854
|
* Retrieves daily report details for a business date with user-specific status and relations.
|
|
850
855
|
* 指定した営業日の日報詳細を、ユーザー状態と関連データを含めて取得する。
|
|
851
856
|
*/
|
|
852
|
-
const getDailyReportsByBusinessDateByExternalId = async (businessDate: string, externalId: string, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}
|
|
857
|
+
const getDailyReportsByBusinessDateByExternalId = async (scope: DailyReportViewerScope, businessDate: string, externalId: string, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}): Promise<DailyReportDetail[]> => {
|
|
853
858
|
const userId = await getUserIdByExternalId(externalId)
|
|
854
859
|
if (!userId) {
|
|
855
860
|
return []
|
|
@@ -860,12 +865,12 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
860
865
|
return []
|
|
861
866
|
}
|
|
862
867
|
// 全拒否なら DB を引かずに空を返す (0 件 = fail-close)
|
|
863
|
-
if (deniesEverySourceType(visible)) {
|
|
868
|
+
if (deniesEverySourceType(scope.visible)) {
|
|
864
869
|
return []
|
|
865
870
|
}
|
|
866
871
|
|
|
867
872
|
// 可視集合ごとに別バケットへ隔離する (権限変更後に旧結果を配信しない)
|
|
868
|
-
const cacheKey = dateCacheKey(normalizedBusinessDate, userId,
|
|
873
|
+
const cacheKey = dateCacheKey(normalizedBusinessDate, userId, computeViewerScopeDigest(scope))
|
|
869
874
|
|
|
870
875
|
return sqlResultCache.getOrFetch<DailyReportDetail>({
|
|
871
876
|
cacheKey,
|
|
@@ -889,7 +894,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
889
894
|
.leftJoin(DailyReportUserStatus, and(eq(DailyReportHub.id, DailyReportUserStatus.hubId), eq(DailyReportUserStatus.userId, userId)))
|
|
890
895
|
.leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
|
|
891
896
|
.leftJoin(users, eq(DailyReportHub.userId, users.id))
|
|
892
|
-
.where(and(eq(DailyReportHub.businessDate, sql`${normalizedBusinessDate}`), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId)), visibleSourceTypeCondition(visible)))
|
|
897
|
+
.where(and(eq(DailyReportHub.businessDate, sql`${normalizedBusinessDate}`), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId)), visibleSourceTypeCondition(scope.visible)))
|
|
893
898
|
.orderBy(desc(DailyReportHub.id))
|
|
894
899
|
|
|
895
900
|
if (rows.length === 0) {
|
|
@@ -992,17 +997,17 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
992
997
|
* @param reportHubId Hub id. 対象 hub の ID。
|
|
993
998
|
* @param userId Internal numeric user id of the viewer. 閲覧者の内部数値ユーザー ID。
|
|
994
999
|
* @param options Cache query options. キャッシュ問い合わせオプション。
|
|
995
|
-
* @param
|
|
1000
|
+
* @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
|
|
996
1001
|
* @returns Detail when visible, otherwise null. 可視なら詳細、そうでなければ null。
|
|
997
1002
|
*/
|
|
998
|
-
const getDailyReportDetailById = async (reportHubId: number, userId: number, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}
|
|
1003
|
+
const getDailyReportDetailById = async (scope: DailyReportViewerScope, reportHubId: number, userId: number, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}): Promise<DailyReportDetail | null> => {
|
|
999
1004
|
// 全拒否なら DB を引かずに null を返す (0 件 = fail-close)
|
|
1000
|
-
if (deniesEverySourceType(visible)) {
|
|
1005
|
+
if (deniesEverySourceType(scope.visible)) {
|
|
1001
1006
|
return null
|
|
1002
1007
|
}
|
|
1003
1008
|
|
|
1004
1009
|
// 可視集合ごとに別バケットへ隔離する (権限変更後に旧結果を配信しない)
|
|
1005
|
-
const cacheKey = detailCacheKey(reportHubId, userId,
|
|
1010
|
+
const cacheKey = detailCacheKey(reportHubId, userId, computeViewerScopeDigest(scope))
|
|
1006
1011
|
|
|
1007
1012
|
const results = (await sqlResultCache.getOrFetch<DailyReportDetail>({
|
|
1008
1013
|
cacheKey,
|
|
@@ -1026,7 +1031,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1026
1031
|
.leftJoin(DailyReportUserStatus, and(eq(DailyReportHub.id, DailyReportUserStatus.hubId), eq(DailyReportUserStatus.userId, userId)))
|
|
1027
1032
|
.leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
|
|
1028
1033
|
.leftJoin(users, eq(DailyReportHub.userId, users.id))
|
|
1029
|
-
.where(and(eq(DailyReportHub.id, reportHubId), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId)), visibleSourceTypeCondition(visible)))
|
|
1034
|
+
.where(and(eq(DailyReportHub.id, reportHubId), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId)), visibleSourceTypeCondition(scope.visible)))
|
|
1030
1035
|
|
|
1031
1036
|
if (rows.length === 0) {
|
|
1032
1037
|
return []
|
|
@@ -1102,17 +1107,17 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1102
1107
|
* @param reportHubId Hub id. 対象 hub の ID。
|
|
1103
1108
|
* @param externalId External user id. 外部ユーザー ID。
|
|
1104
1109
|
* @param options Cache query options. キャッシュ問い合わせオプション。
|
|
1105
|
-
* @param
|
|
1110
|
+
* @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
|
|
1106
1111
|
* @returns Detail when visible, otherwise null. 可視なら詳細、そうでなければ null。
|
|
1107
1112
|
*/
|
|
1108
|
-
const getDailyReportDetailByIdByExternalId = async (reportHubId: number, externalId: string, options: SqlResultCacheQueryOptions = {}
|
|
1113
|
+
const getDailyReportDetailByIdByExternalId = async (scope: DailyReportViewerScope, reportHubId: number, externalId: string, options: SqlResultCacheQueryOptions = {}): Promise<DailyReportDetail | null> => {
|
|
1109
1114
|
const userId = await getUserIdByExternalId(externalId)
|
|
1110
1115
|
if (!userId) {
|
|
1111
1116
|
return null
|
|
1112
1117
|
}
|
|
1113
1118
|
// ❗ 受け取った可視集合はそのまま透過する。ここで null へ潰すと外部 ID 経由の詳細取得だけ
|
|
1114
1119
|
// フィルタが外れる (内部の create/update/publish が明示 null を渡すのとは別物)
|
|
1115
|
-
return getDailyReportDetailById(reportHubId, userId, options
|
|
1120
|
+
return getDailyReportDetailById(scope, reportHubId, userId, options)
|
|
1116
1121
|
}
|
|
1117
1122
|
|
|
1118
1123
|
/**
|
|
@@ -1135,12 +1140,12 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1135
1140
|
* 一切検証せず任意の `reportHubId` へ upsert できたため、その穴も同時に塞ぐ。
|
|
1136
1141
|
*
|
|
1137
1142
|
* @param reportHubId Hub id. 対象 hub の ID。
|
|
1138
|
-
* @param
|
|
1143
|
+
* @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
|
|
1139
1144
|
* @returns Nothing; throws when rejected. なし (拒否時は例外送出)。
|
|
1140
1145
|
*/
|
|
1141
|
-
const assertHubMutable = async (
|
|
1146
|
+
const assertHubMutable = async (scope: DailyReportViewerScope, reportHubId: number): Promise<void> => {
|
|
1142
1147
|
const hub = await findHubGateColumns(reportHubId)
|
|
1143
|
-
if (!hub || hub.deletedAt || !isSourceTypeVisible(visible, hub.sourceType)) {
|
|
1148
|
+
if (!hub || hub.deletedAt || !isSourceTypeVisible(scope.visible, hub.sourceType)) {
|
|
1144
1149
|
throw new Error("Not Found")
|
|
1145
1150
|
}
|
|
1146
1151
|
}
|
|
@@ -1154,12 +1159,12 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1154
1159
|
* @param businessDate Business date for cache invalidation. キャッシュ無効化に使う営業日。
|
|
1155
1160
|
* @param isStarred Desired star state. 設定するスター状態。
|
|
1156
1161
|
* @param clientTempId Optimistic-update correlation id. 楽観更新の相関 ID。
|
|
1157
|
-
* @param
|
|
1162
|
+
* @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
|
|
1158
1163
|
* @returns Updated status row. 更新後の状態行。
|
|
1159
1164
|
*/
|
|
1160
|
-
const setStarStatus = async (userId: number, reportHubId: number, businessDate: string | null, isStarred: boolean, clientTempId: string
|
|
1165
|
+
const setStarStatus = async (scope: DailyReportViewerScope, userId: number, reportHubId: number, businessDate: string | null, isStarred: boolean, clientTempId: string): Promise<DailyReportUserStatusRow> => {
|
|
1161
1166
|
// 不可視区分・不存在・削除済みへの書き込みを先に弾く
|
|
1162
|
-
await assertHubMutable(
|
|
1167
|
+
await assertHubMutable(scope, reportHubId)
|
|
1163
1168
|
|
|
1164
1169
|
const existing = await db
|
|
1165
1170
|
.select<DailyReportUserStatusRow>()
|
|
@@ -1238,12 +1243,12 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1238
1243
|
* @param businessDate Business date for cache invalidation. キャッシュ無効化に使う営業日。
|
|
1239
1244
|
* @param isRead Desired read state. 設定する既読状態。
|
|
1240
1245
|
* @param clientTempId Optimistic-update correlation id. 楽観更新の相関 ID。
|
|
1241
|
-
* @param
|
|
1246
|
+
* @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
|
|
1242
1247
|
* @returns Updated status row. 更新後の状態行。
|
|
1243
1248
|
*/
|
|
1244
|
-
const setReadStatus = async (userId: number, reportHubId: number, businessDate: string | null, isRead: boolean, clientTempId: string
|
|
1249
|
+
const setReadStatus = async (scope: DailyReportViewerScope, userId: number, reportHubId: number, businessDate: string | null, isRead: boolean, clientTempId: string): Promise<DailyReportUserStatusRow> => {
|
|
1245
1250
|
// 不可視区分・不存在・削除済みへの書き込みを先に弾く
|
|
1246
|
-
await assertHubMutable(
|
|
1251
|
+
await assertHubMutable(scope, reportHubId)
|
|
1247
1252
|
|
|
1248
1253
|
const existing = await db
|
|
1249
1254
|
.select<DailyReportUserStatusRow>()
|
|
@@ -1346,10 +1351,10 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1346
1351
|
* @param content Comment body. コメント本文。
|
|
1347
1352
|
* @param businessDate Business date for cache invalidation. キャッシュ無効化に使う営業日。
|
|
1348
1353
|
* @param clientTempId Optimistic-update correlation id. 楽観更新の相関 ID。
|
|
1349
|
-
* @param
|
|
1354
|
+
* @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
|
|
1350
1355
|
* @returns The created comment. 追加されたコメント。
|
|
1351
1356
|
*/
|
|
1352
|
-
const addComment = async (userId: number, reportHubId: number, content: string, businessDate: string | null, clientTempId: string
|
|
1357
|
+
const addComment = async (scope: DailyReportViewerScope, userId: number, reportHubId: number, content: string, businessDate: string | null, clientTempId: string): Promise<DailyReportCommentItem> => {
|
|
1353
1358
|
const [hub] = await db
|
|
1354
1359
|
.select<{ sourceType: string; deletedAt: Date | string | null; userId: number | null }>({ sourceType: DailyReportHub.sourceType, deletedAt: DailyReportHub.deletedAt, userId: DailyReportHub.userId })
|
|
1355
1360
|
.top(1)
|
|
@@ -1362,7 +1367,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1362
1367
|
}
|
|
1363
1368
|
// 不可視区分の日報にはコメントできない。認可失敗と不存在を区別せず Not Found にする
|
|
1364
1369
|
// (存在オラクルの回避)
|
|
1365
|
-
if (!isSourceTypeVisible(visible, hub.sourceType)) {
|
|
1370
|
+
if (!isSourceTypeVisible(scope.visible, hub.sourceType)) {
|
|
1366
1371
|
throw new Error("Not Found")
|
|
1367
1372
|
}
|
|
1368
1373
|
if (hub.sourceType.toLowerCase() !== "internal") {
|
|
@@ -1441,10 +1446,10 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1441
1446
|
* @param commentId Comment id to delete. 削除対象のコメント ID。
|
|
1442
1447
|
* @param businessDate Business date for cache invalidation. キャッシュ無効化に使う営業日。
|
|
1443
1448
|
* @param clientTempId Optimistic-update correlation id. 楽観更新の相関 ID。
|
|
1444
|
-
* @param
|
|
1449
|
+
* @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
|
|
1445
1450
|
* @returns Nothing. なし。
|
|
1446
1451
|
*/
|
|
1447
|
-
const deleteComment = async (userId: number, reportHubId: number, commentId: number, businessDate: string | null, clientTempId: string
|
|
1452
|
+
const deleteComment = async (scope: DailyReportViewerScope, userId: number, reportHubId: number, commentId: number, businessDate: string | null, clientTempId: string): Promise<void> => {
|
|
1448
1453
|
const comments = await findDailyReportCommentById(db, commentId)
|
|
1449
1454
|
if (comments.length === 0) {
|
|
1450
1455
|
throw new Error("Not Found")
|
|
@@ -1463,7 +1468,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1463
1468
|
// あり、呼び出し側が渡した ID ではない (上で一致を確認済み)。hub 行が引けない場合も
|
|
1464
1469
|
// fail-close で Not Found にする (assertHubMutable と同一の規約)
|
|
1465
1470
|
const [hubForDelete] = await db.select<{ userId: number | null; sourceType: string }>({ userId: DailyReportHub.userId, sourceType: DailyReportHub.sourceType }).top(1).from(DailyReportHub).where(eq(DailyReportHub.id, comments[0].hubId))
|
|
1466
|
-
if (!(hubForDelete && isSourceTypeVisible(visible, hubForDelete.sourceType))) {
|
|
1471
|
+
if (!(hubForDelete && isSourceTypeVisible(scope.visible, hubForDelete.sourceType))) {
|
|
1467
1472
|
throw new Error("Not Found")
|
|
1468
1473
|
}
|
|
1469
1474
|
|
|
@@ -1618,7 +1623,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1618
1623
|
// ❗ 明示 null (無制限)。所有者が自分で作った日報を取り直す経路なので、消費側の可視集合に
|
|
1619
1624
|
// 作成区分 ("Internal") が含まれなくても取得と SSE 配信を壊さない。区分の出し分けは
|
|
1620
1625
|
// 受信側の SSE フィルタと次回読み取り時のサーバーフィルタが担う
|
|
1621
|
-
const fullDetail = await getDailyReportDetailById(result.reportHubId, userId, { forceRefresh: true }
|
|
1626
|
+
const fullDetail = await getDailyReportDetailById(DailyReportViewerScope.unrestricted("post-write self read: the writer always sees the row they just wrote"), result.reportHubId, userId, { forceRefresh: true })
|
|
1622
1627
|
|
|
1623
1628
|
if (fullDetail) {
|
|
1624
1629
|
// 下書き判定
|
|
@@ -1773,7 +1778,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1773
1778
|
|
|
1774
1779
|
// ❗ 明示 null (無制限)。所有者が自分の日報を取り直す経路であり、区分の出し分けは
|
|
1775
1780
|
// 受信側の SSE フィルタと次回読み取り時のサーバーフィルタが担う
|
|
1776
|
-
const fullDetail = await getDailyReportDetailById(reportHubId, userId, { forceRefresh: true }
|
|
1781
|
+
const fullDetail = await getDailyReportDetailById(DailyReportViewerScope.unrestricted("post-write self read: the writer always sees the row they just wrote"), reportHubId, userId, { forceRefresh: true })
|
|
1777
1782
|
if (fullDetail) {
|
|
1778
1783
|
// 下書き判定
|
|
1779
1784
|
// 判定は代表 ID ではなく下書きラベル**全体**で行う (2 つ目の下書き名を見落とさない)
|
|
@@ -1833,7 +1838,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1833
1838
|
|
|
1834
1839
|
// ❗ 明示 null (無制限)。所有者が自分の日報を取り直す経路であり、区分の出し分けは
|
|
1835
1840
|
// 受信側の SSE フィルタと次回読み取り時のサーバーフィルタが担う
|
|
1836
|
-
const fullDetail = await getDailyReportDetailById(reportHubId, userId, { forceRefresh: true }
|
|
1841
|
+
const fullDetail = await getDailyReportDetailById(DailyReportViewerScope.unrestricted("post-write self read: the writer always sees the row they just wrote"), reportHubId, userId, { forceRefresh: true })
|
|
1837
1842
|
if (fullDetail) {
|
|
1838
1843
|
await publishToSseStream(
|
|
1839
1844
|
reportPublishMessageSchema.parse({
|
|
@@ -11,7 +11,7 @@ import type { DailyReportHandlersConfig } from "../handlers"
|
|
|
11
11
|
import type { DailyReportAttachmentBytes, DailyReportAttachmentError, DailyReportIdCodec, DailyReportResolveVisibleSourceTypes, DailyReportVisibilityResolveContext, DailyReportVisibilityResolveReason } from "../ports"
|
|
12
12
|
import type { DailyReportService } from "../service"
|
|
13
13
|
import type { DailyReportSseReader, StreamEntry } from "../sse-reader"
|
|
14
|
-
import type { VisibleSourceTypeSet } from "../visibility"
|
|
14
|
+
import type { DailyReportViewerScope, VisibleSourceTypeSet } from "../visibility"
|
|
15
15
|
|
|
16
16
|
/** 既定の外部ユーザー ID (認証済みユーザーを表す)。 */
|
|
17
17
|
export const TEST_EXTERNAL_USER_ID = "external-user"
|
|
@@ -128,50 +128,50 @@ export const makeHandlersFixture = (options: HandlersFixtureOptions = {}): { con
|
|
|
128
128
|
clearCache: async () => {
|
|
129
129
|
calls.clearCache += 1
|
|
130
130
|
},
|
|
131
|
-
getDailyReportIdsByExternalId: async (externalId: string, opts
|
|
131
|
+
getDailyReportIdsByExternalId: async (scope: DailyReportViewerScope, externalId: string, opts?: { forceRefresh?: boolean }) => {
|
|
132
132
|
calls.getDailyReportIds.push([externalId, opts?.forceRefresh ?? false])
|
|
133
|
-
calls.visibleArgs.push({ method: "getDailyReportIdsByExternalId", visible })
|
|
133
|
+
calls.visibleArgs.push({ method: "getDailyReportIdsByExternalId", visible: scope.visible })
|
|
134
134
|
if (options.idsStreamError) throw options.idsStreamError
|
|
135
135
|
if (options.idsStreamFullGate) await options.idsStreamFullGate
|
|
136
136
|
return options.idsStreamItems ?? []
|
|
137
137
|
},
|
|
138
|
-
getDailyReportIdsFirstPageByExternalId: async (externalId: string, limit: number
|
|
138
|
+
getDailyReportIdsFirstPageByExternalId: async (scope: DailyReportViewerScope, externalId: string, limit: number) => {
|
|
139
139
|
calls.getDailyReportIdsFirstPage.push([externalId, limit])
|
|
140
|
-
calls.visibleArgs.push({ method: "getDailyReportIdsFirstPageByExternalId", visible })
|
|
140
|
+
calls.visibleArgs.push({ method: "getDailyReportIdsFirstPageByExternalId", visible: scope.visible })
|
|
141
141
|
if (options.idsStreamError) throw options.idsStreamError
|
|
142
142
|
return options.idsStreamFirstPageItems ?? (options.idsStreamItems ?? []).slice(0, limit)
|
|
143
143
|
},
|
|
144
|
-
getAttachmentForUser: async (
|
|
144
|
+
getAttachmentForUser: async (scope: DailyReportViewerScope, attachmentId: number, viewerUserId: number) => {
|
|
145
145
|
// 引数を記録する。記録しないと「閲覧者 ID を渡し忘れた」欠陥がテストに映らない
|
|
146
146
|
calls.getAttachmentForUser.push([attachmentId, viewerUserId])
|
|
147
|
-
calls.visibleArgs.push({ method: "getAttachmentForUser", visible })
|
|
147
|
+
calls.visibleArgs.push({ method: "getAttachmentForUser", visible: scope.visible })
|
|
148
148
|
return attachment
|
|
149
149
|
},
|
|
150
|
-
getDailyReportsByBusinessDateByExternalId: async (_businessDate: string, _externalId: string, _opts
|
|
151
|
-
calls.visibleArgs.push({ method: "getDailyReportsByBusinessDateByExternalId", visible })
|
|
150
|
+
getDailyReportsByBusinessDateByExternalId: async (scope: DailyReportViewerScope, _businessDate: string, _externalId: string, _opts?: unknown) => {
|
|
151
|
+
calls.visibleArgs.push({ method: "getDailyReportsByBusinessDateByExternalId", visible: scope.visible })
|
|
152
152
|
return []
|
|
153
153
|
},
|
|
154
|
-
getDailyReportDetailByIdByExternalId: async (_reportHubId: number, _externalId: string, _opts
|
|
155
|
-
calls.visibleArgs.push({ method: "getDailyReportDetailByIdByExternalId", visible })
|
|
154
|
+
getDailyReportDetailByIdByExternalId: async (scope: DailyReportViewerScope, _reportHubId: number, _externalId: string, _opts?: unknown) => {
|
|
155
|
+
calls.visibleArgs.push({ method: "getDailyReportDetailByIdByExternalId", visible: scope.visible })
|
|
156
156
|
return null
|
|
157
157
|
},
|
|
158
|
-
setStarStatus: async (_userId: number, reportHubId: number, _businessDate: string | null, _value: boolean, _clientTempId: string
|
|
159
|
-
calls.mutations.push({ method: "setStarStatus", reportHubId, visible })
|
|
158
|
+
setStarStatus: async (scope: DailyReportViewerScope, _userId: number, reportHubId: number, _businessDate: string | null, _value: boolean, _clientTempId: string) => {
|
|
159
|
+
calls.mutations.push({ method: "setStarStatus", reportHubId, visible: scope.visible })
|
|
160
160
|
if (options.mutationError) throw options.mutationError
|
|
161
161
|
return { hubId: reportHubId, userId: internalUserId ?? 0, isRead: false, isStarred: true, createdAt: "", createdBy: "", updatedAt: "", updatedBy: "" }
|
|
162
162
|
},
|
|
163
|
-
setReadStatus: async (_userId: number, reportHubId: number, _businessDate: string | null, _value: boolean, _clientTempId: string
|
|
164
|
-
calls.mutations.push({ method: "setReadStatus", reportHubId, visible })
|
|
163
|
+
setReadStatus: async (scope: DailyReportViewerScope, _userId: number, reportHubId: number, _businessDate: string | null, _value: boolean, _clientTempId: string) => {
|
|
164
|
+
calls.mutations.push({ method: "setReadStatus", reportHubId, visible: scope.visible })
|
|
165
165
|
if (options.mutationError) throw options.mutationError
|
|
166
166
|
return { hubId: reportHubId, userId: internalUserId ?? 0, isRead: true, isStarred: false, createdAt: "", createdBy: "", updatedAt: "", updatedBy: "" }
|
|
167
167
|
},
|
|
168
|
-
addComment: async (_userId: number, reportHubId: number, _content: string, _businessDate: string | null, _clientTempId: string
|
|
169
|
-
calls.mutations.push({ method: "addComment", reportHubId, visible })
|
|
168
|
+
addComment: async (scope: DailyReportViewerScope, _userId: number, reportHubId: number, _content: string, _businessDate: string | null, _clientTempId: string) => {
|
|
169
|
+
calls.mutations.push({ method: "addComment", reportHubId, visible: scope.visible })
|
|
170
170
|
if (options.mutationError) throw options.mutationError
|
|
171
171
|
return { id: 1, userId: "ENC1", userName: "u", content: "c", createdAt: "", isMine: true }
|
|
172
172
|
},
|
|
173
|
-
deleteComment: async (_userId: number, reportHubId: number, _commentId: number, _businessDate: string | null, _clientTempId: string
|
|
174
|
-
calls.mutations.push({ method: "deleteComment", reportHubId, visible })
|
|
173
|
+
deleteComment: async (scope: DailyReportViewerScope, _userId: number, reportHubId: number, _commentId: number, _businessDate: string | null, _clientTempId: string) => {
|
|
174
|
+
calls.mutations.push({ method: "deleteComment", reportHubId, visible: scope.visible })
|
|
175
175
|
if (options.mutationError) throw options.mutationError
|
|
176
176
|
},
|
|
177
177
|
markAttachmentMissing: async (id: number) => {
|
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* ここで固定するのは「無制限 (null) と全拒否 (空集合) を決して取り違えない」という不変条件であり、
|
|
6
6
|
* 取り違えは情報漏洩 (全件配信) か機能停止 (0 件) に直結する。
|
|
7
7
|
*/
|
|
8
|
+
|
|
9
|
+
import { bigint, MsSqlDialect, mssqlSchema, nvarchar, primaryKey } from "drizzle-orm/mssql-core"
|
|
8
10
|
import { describe, expect, it } from "vitest"
|
|
9
|
-
import { computeSourceTypeDigest, isSourceTypeVisible, normalizeVisibleSourceTypes } from "./visibility"
|
|
11
|
+
import { buildVisibleSourceTypeCondition, computeSourceTypeDigest, computeViewerScopeDigest, DailyReportViewerScope, deniesEverySourceType, isSourceTypeVisible, normalizeVisibleSourceTypes } from "./visibility"
|
|
10
12
|
|
|
11
13
|
describe("normalizeVisibleSourceTypes", () => {
|
|
12
14
|
it("undefined と null は無制限 (null) になる", () => {
|
|
@@ -105,10 +107,26 @@ describe("computeSourceTypeDigest", () => {
|
|
|
105
107
|
expect(new Set(digests).size).toBe(digests.length)
|
|
106
108
|
})
|
|
107
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
|
+
|
|
108
126
|
it("巨大な集合はハッシュへ退避しつつキー長が有界になる", () => {
|
|
109
127
|
const many = Array.from({ length: 200 }, (_, index) => `source-type-${index}`)
|
|
110
128
|
const digest = computeSourceTypeDigest(normalizeVisibleSourceTypes(many))
|
|
111
|
-
expect(digest.startsWith("
|
|
129
|
+
expect(digest.startsWith("sh:")).toBe(true)
|
|
112
130
|
expect(digest.length).toBeLessThanOrEqual(16)
|
|
113
131
|
})
|
|
114
132
|
|
|
@@ -117,7 +135,9 @@ describe("computeSourceTypeDigest", () => {
|
|
|
117
135
|
const hashed = computeSourceTypeDigest(normalizeVisibleSourceTypes(many))
|
|
118
136
|
const raw = computeSourceTypeDigest(normalizeVisibleSourceTypes(["ni"]))
|
|
119
137
|
expect(hashed).not.toBe(raw)
|
|
120
|
-
expect(raw.startsWith("
|
|
138
|
+
expect(raw.startsWith("sh:")).toBe(false)
|
|
139
|
+
// 限定集合は必ず s: 名前空間の下に入る (sentinel と衝突しない)
|
|
140
|
+
expect(raw.startsWith("s:")).toBe(true)
|
|
121
141
|
})
|
|
122
142
|
})
|
|
123
143
|
|
|
@@ -140,3 +160,109 @@ describe("JS 判定と SQL 述語の対称性", () => {
|
|
|
140
160
|
expect([...(set ?? [])]).toEqual(["ni"])
|
|
141
161
|
})
|
|
142
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("deniesEverySourceType の三値", () => {
|
|
235
|
+
it("無制限 (null) は全拒否ではない", () => {
|
|
236
|
+
expect(deniesEverySourceType(null)).toBe(false)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it("空集合だけが全拒否になる", () => {
|
|
240
|
+
expect(deniesEverySourceType(normalizeVisibleSourceTypes([]))).toBe(true)
|
|
241
|
+
expect(deniesEverySourceType(normalizeVisibleSourceTypes(["ni"]))).toBe(false)
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
describe("buildVisibleSourceTypeCondition の三値", () => {
|
|
246
|
+
// 実カラムが要るため最小のテスト用テーブルを組む (getColumns / sql テンプレートが実体を要求する)
|
|
247
|
+
const testTable = mssqlSchema("test").table("Hub", { id: bigint("id", { mode: "number" }).notNull(), sourceType: nvarchar("source_type", { length: 20 }).notNull() }, (t) => [primaryKey({ columns: [t.id] })])
|
|
248
|
+
const dialect = new MsSqlDialect()
|
|
249
|
+
|
|
250
|
+
it("無制限は条件を生成しない (既存述語をそのまま残す)", () => {
|
|
251
|
+
expect(buildVisibleSourceTypeCondition(testTable.sourceType, null)).toBeUndefined()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it("全拒否は 1 = 0 を生成する (呼び出し側の短絡が漏れても認可が反転しない)", () => {
|
|
255
|
+
const condition = buildVisibleSourceTypeCondition(testTable.sourceType, normalizeVisibleSourceTypes([]))
|
|
256
|
+
expect(condition).toBeDefined()
|
|
257
|
+
expect(dialect.sqlToQuery(condition as NonNullable<typeof condition>).sql).toContain("1 = 0")
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it("限定は LOWER(列) IN (畳み込み済みトークン) を生成する", () => {
|
|
261
|
+
const condition = buildVisibleSourceTypeCondition(testTable.sourceType, normalizeVisibleSourceTypes(["NI", "kintone"]))
|
|
262
|
+
const rendered = dialect.sqlToQuery(condition as NonNullable<typeof condition>)
|
|
263
|
+
expect(rendered.sql).toContain("LOWER(")
|
|
264
|
+
expect(rendered.sql).toContain("[source_type]")
|
|
265
|
+
expect(rendered.sql).toContain(" in ")
|
|
266
|
+
expect(rendered.params).toEqual(expect.arrayContaining(["kintone", "ni"]))
|
|
267
|
+
})
|
|
268
|
+
})
|