@tuturuuu/utils 0.0.3 → 0.6.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 (186) hide show
  1. package/CHANGELOG.md +305 -0
  2. package/biome.json +5 -0
  3. package/jsr.json +8 -8
  4. package/package.json +63 -32
  5. package/src/__tests__/ai-temp-auth.test.ts +309 -0
  6. package/src/__tests__/api-proxy-guard.test.ts +1451 -0
  7. package/src/__tests__/app-url.test.ts +270 -0
  8. package/src/__tests__/avatar-url.test.ts +97 -0
  9. package/src/__tests__/color-helper.test.ts +179 -0
  10. package/src/__tests__/constants.test.ts +351 -0
  11. package/src/__tests__/crypto.test.ts +107 -0
  12. package/src/__tests__/date-helper.test.ts +408 -0
  13. package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
  14. package/src/__tests__/format.test.ts +317 -0
  15. package/src/__tests__/html-sanitizer.test.ts +360 -0
  16. package/src/__tests__/interest-calculator.test.ts +336 -0
  17. package/src/__tests__/interest-detector.test.ts +222 -0
  18. package/src/__tests__/label-colors.test.ts +241 -0
  19. package/src/__tests__/name-helper.test.ts +158 -0
  20. package/src/__tests__/node-diff.test.ts +576 -0
  21. package/src/__tests__/notification-service.test.ts +210 -0
  22. package/src/__tests__/onboarding-helper.test.ts +331 -0
  23. package/src/__tests__/path-helper.test.ts +152 -0
  24. package/src/__tests__/permissions.test.tsx +81 -0
  25. package/src/__tests__/request-emoji-limit.test.ts +172 -0
  26. package/src/__tests__/search-helper.test.ts +51 -0
  27. package/src/__tests__/storage-display-name.test.ts +37 -0
  28. package/src/__tests__/storage-path.test.ts +238 -0
  29. package/src/__tests__/tag-utils.test.ts +205 -0
  30. package/src/__tests__/task-description-yjs-state.test.ts +581 -0
  31. package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
  32. package/src/__tests__/task-helper-create-task.test.ts +129 -0
  33. package/src/__tests__/task-helpers.test.ts +464 -0
  34. package/src/__tests__/task-overrides.test.ts +305 -0
  35. package/src/__tests__/task-reorder-cache.test.ts +74 -0
  36. package/src/__tests__/task-sort-keys.test.ts +36 -0
  37. package/src/__tests__/task-transformers.test.ts +62 -0
  38. package/src/__tests__/text-helper.test.ts +776 -0
  39. package/src/__tests__/time-helper.test.ts +70 -0
  40. package/src/__tests__/time-tracker-period.test.ts +55 -0
  41. package/src/__tests__/timezone.test.ts +117 -0
  42. package/src/__tests__/upstash-rest.test.ts +77 -0
  43. package/src/__tests__/uuid-helper.test.ts +133 -0
  44. package/src/__tests__/workspace-helper.test.ts +859 -0
  45. package/src/__tests__/workspace-limits.test.ts +255 -0
  46. package/src/__tests__/yjs-helper.test.ts +581 -0
  47. package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
  48. package/src/abuse-protection/__tests__/edge.test.ts +136 -0
  49. package/src/abuse-protection/__tests__/index.test.ts +562 -0
  50. package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
  51. package/src/abuse-protection/backend-rate-limit.ts +44 -0
  52. package/src/abuse-protection/constants.ts +117 -0
  53. package/src/abuse-protection/edge.ts +223 -0
  54. package/src/abuse-protection/index.ts +1545 -0
  55. package/src/abuse-protection/reputation.ts +587 -0
  56. package/src/abuse-protection/types.ts +97 -0
  57. package/src/abuse-protection/user-agent.ts +124 -0
  58. package/src/abuse-protection/user-suspension.ts +231 -0
  59. package/src/ai-temp-auth.ts +315 -0
  60. package/src/api-proxy-guard.ts +965 -0
  61. package/src/app-url.ts +96 -0
  62. package/src/avatar-url.ts +64 -0
  63. package/src/break-duration.ts +84 -0
  64. package/src/calendar-auth-token.test.ts +37 -0
  65. package/src/calendar-auth-token.ts +19 -0
  66. package/src/calendar-sync-coordination.md +197 -0
  67. package/src/calendar-utils.test.ts +169 -0
  68. package/src/calendar-utils.ts +91 -0
  69. package/src/color-helper.ts +110 -0
  70. package/src/common/nextjs.tsx +99 -0
  71. package/src/common/scan.tsx +15 -0
  72. package/src/configs/reports.ts +160 -0
  73. package/src/constants.ts +85 -0
  74. package/src/crypto.ts +21 -0
  75. package/src/currencies.ts +97 -0
  76. package/src/date-helper.ts +313 -0
  77. package/src/editor/convert-to-task.ts +264 -0
  78. package/src/editor/index.ts +5 -0
  79. package/src/email/__tests__/client.test.ts +141 -0
  80. package/src/email/__tests__/validation.test.ts +46 -0
  81. package/src/email/client.ts +92 -0
  82. package/src/email/server.ts +128 -0
  83. package/src/email/validation.ts +11 -0
  84. package/src/encryption/__tests__/calendar-events.test.ts +411 -0
  85. package/src/encryption/__tests__/configuration.test.ts +114 -0
  86. package/src/encryption/__tests__/field-encryption.test.ts +232 -0
  87. package/src/encryption/__tests__/key-generation.test.ts +30 -0
  88. package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
  89. package/src/encryption/__tests__/test-helpers.ts +22 -0
  90. package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
  91. package/src/encryption/encryption-service.ts +343 -0
  92. package/src/encryption/index.ts +25 -0
  93. package/src/encryption/types.ts +57 -0
  94. package/src/exchange-rates.ts +49 -0
  95. package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
  96. package/src/feature-flags/core.ts +322 -0
  97. package/src/feature-flags/data.ts +16 -0
  98. package/src/feature-flags/default.ts +18 -0
  99. package/src/feature-flags/index.ts +7 -0
  100. package/src/feature-flags/requestable-features.ts +79 -0
  101. package/src/feature-flags/types.ts +4 -0
  102. package/src/fetcher.ts +2 -0
  103. package/src/finance/index.ts +4 -0
  104. package/src/finance/interest-calculator.ts +456 -0
  105. package/src/finance/interest-detector.ts +141 -0
  106. package/src/finance/transform-invoice-results.ts +219 -0
  107. package/src/finance/wallet-permissions.test.ts +169 -0
  108. package/src/finance/wallet-permissions.ts +82 -0
  109. package/src/format.ts +120 -1
  110. package/src/generated/platform-build-metadata.ts +11 -0
  111. package/src/hooks/use-platform.ts +64 -0
  112. package/src/html-sanitizer.ts +155 -0
  113. package/src/internal-domains.ts +497 -0
  114. package/src/keyboard-preset.ts +109 -0
  115. package/src/label-colors.ts +213 -0
  116. package/src/launchable-apps.test.ts +126 -0
  117. package/src/launchable-apps.ts +490 -0
  118. package/src/name-helper.ts +269 -0
  119. package/src/next-config.test.ts +234 -0
  120. package/src/next-config.ts +203 -0
  121. package/src/node-diff.ts +375 -0
  122. package/src/notification-service.ts +379 -0
  123. package/src/nova/scores/__tests__/calculate.test.ts +254 -0
  124. package/src/nova/scores/calculate.ts +132 -0
  125. package/src/nova/submissions/check-permission.ts +132 -0
  126. package/src/onboarding-helper.ts +213 -0
  127. package/src/path-helper.ts +93 -0
  128. package/src/permissions.tsx +1170 -0
  129. package/src/plan-helpers.test.ts +188 -0
  130. package/src/plan-helpers.ts +80 -0
  131. package/src/platform-release.test.ts +74 -0
  132. package/src/platform-release.ts +155 -0
  133. package/src/portless.ts +124 -0
  134. package/src/priority-styles.ts +42 -0
  135. package/src/request-emoji-limit.ts +335 -0
  136. package/src/search-helper.ts +18 -0
  137. package/src/search.test.ts +89 -0
  138. package/src/search.ts +355 -0
  139. package/src/storage-display-name.ts +30 -0
  140. package/src/storage-path.ts +147 -0
  141. package/src/tag-utils.ts +159 -0
  142. package/src/task/reorder.ts +245 -0
  143. package/src/task/transformers.ts +149 -0
  144. package/src/task-date-timezone.ts +133 -0
  145. package/src/task-description-content.ts +240 -0
  146. package/src/task-helper/board.ts +193 -0
  147. package/src/task-helper/bulk-actions.ts +564 -0
  148. package/src/task-helper/personal-external-staging.ts +21 -0
  149. package/src/task-helper/recycle-bin.ts +202 -0
  150. package/src/task-helper/relationships.ts +346 -0
  151. package/src/task-helper/shared.ts +109 -0
  152. package/src/task-helper/sort-keys.ts +337 -0
  153. package/src/task-helper/task-hooks-basic.ts +342 -0
  154. package/src/task-helper/task-hooks-move.ts +264 -0
  155. package/src/task-helper/task-operations.ts +278 -0
  156. package/src/task-helper.ts +12 -0
  157. package/src/task-helpers.ts +241 -0
  158. package/src/task-list-status.ts +62 -0
  159. package/src/task-overrides.ts +82 -0
  160. package/src/task-snapshot.ts +374 -0
  161. package/src/text-diff.ts +81 -0
  162. package/src/text-helper.ts +537 -0
  163. package/src/time-helper.ts +63 -0
  164. package/src/time-tracker-period.ts +73 -0
  165. package/src/timeblock-helper.ts +418 -0
  166. package/src/timezone.ts +190 -0
  167. package/src/timezones.json +1271 -0
  168. package/src/upstash-rest.ts +56 -0
  169. package/src/user-helper.ts +296 -0
  170. package/src/uuid-helper.ts +11 -0
  171. package/src/workspace-handle.ts +10 -0
  172. package/src/workspace-helper.ts +1408 -0
  173. package/src/workspace-limits.ts +68 -0
  174. package/src/yjs-helper.ts +217 -0
  175. package/src/yjs-task-description.ts +81 -0
  176. package/tsconfig.json +3 -5
  177. package/tsconfig.typecheck.json +33 -0
  178. package/vitest.config.ts +36 -0
  179. package/dist/index.d.ts +0 -8
  180. package/dist/index.js +0 -2
  181. package/dist/index.js.map +0 -1
  182. package/dist/index.mjs +0 -2
  183. package/dist/index.mjs.map +0 -1
  184. package/eslint.config.mjs +0 -20
  185. package/rollup.config.js +0 -41
  186. package/src/index.ts +0 -1
@@ -0,0 +1,81 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { permissions } from '../permissions';
5
+
6
+ describe('workspace permission catalog', () => {
7
+ const workspaceId = '11111111-1111-4111-8111-111111111111';
8
+ const repoRoot = process.cwd().endsWith('/packages/utils')
9
+ ? resolve(process.cwd(), '../..')
10
+ : process.cwd();
11
+ const invoiceProductPermissionIds = [
12
+ 'adjust_inventory_stock',
13
+ 'create_inventory_sales',
14
+ 'manage_inventory_catalog',
15
+ 'manage_inventory_setup',
16
+ 'view_inventory_analytics',
17
+ 'view_inventory_audit_logs',
18
+ 'view_inventory_catalog',
19
+ 'view_inventory_dashboard',
20
+ 'view_inventory_sales',
21
+ 'view_inventory_stock',
22
+ ];
23
+
24
+ it('keeps workspace role forms scoped by default', () => {
25
+ const permissionIds = permissions({
26
+ wsId: workspaceId,
27
+ user: null,
28
+ }).map((permission) => permission.id);
29
+
30
+ expect(permissionIds).toContain('admin');
31
+ expect(permissionIds).not.toContain('view_infrastructure');
32
+ expect(permissionIds).not.toContain('manage_infrastructure_stress_tests');
33
+ expect(permissionIds).not.toContain('manage_workspace_secrets');
34
+ });
35
+
36
+ it('exposes the full shared catalog for typed defaults', () => {
37
+ const permissionIds = permissions({
38
+ catalog: 'full',
39
+ wsId: workspaceId,
40
+ user: null,
41
+ }).map((permission) => permission.id);
42
+
43
+ expect(permissionIds).toContain('admin');
44
+ expect(permissionIds).toContain('view_infrastructure');
45
+ expect(permissionIds).toContain('manage_infrastructure_stress_tests');
46
+ expect(permissionIds).toContain('manage_external_migrations');
47
+ expect(permissionIds).toContain('manage_workspace_secrets');
48
+ expect(new Set(permissionIds).size).toBe(permissionIds.length);
49
+ });
50
+
51
+ it('exposes invoice product troubleshooting permissions in workspace roles', () => {
52
+ const permissionIds = permissions({
53
+ wsId: workspaceId,
54
+ user: null,
55
+ }).map((permission) => permission.id);
56
+
57
+ for (const permissionId of invoiceProductPermissionIds) {
58
+ expect(permissionIds).toContain(permissionId);
59
+ }
60
+ });
61
+
62
+ it('keeps Finance app role translations for invoice product permissions', () => {
63
+ const locales = ['en', 'vi'];
64
+
65
+ for (const locale of locales) {
66
+ const messages = JSON.parse(
67
+ readFileSync(
68
+ resolve(repoRoot, `apps/finance/messages/${locale}.json`),
69
+ 'utf8'
70
+ )
71
+ ) as { 'ws-roles': Record<string, string> };
72
+
73
+ for (const permissionId of invoiceProductPermissionIds) {
74
+ expect(messages['ws-roles'][permissionId]?.length).toBeGreaterThan(0);
75
+ expect(
76
+ messages['ws-roles'][`${permissionId}_description`]?.length
77
+ ).toBeGreaterThan(0);
78
+ }
79
+ }
80
+ });
81
+ });
@@ -0,0 +1,172 @@
1
+ import { NextRequest } from 'next/server';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import {
4
+ countEmojisInString,
5
+ findRequestContentViolation,
6
+ getRequestContentViolationForRequest,
7
+ isTrustedEmojiBypassRequest,
8
+ MAX_EMOJIS_PER_FIELD,
9
+ MAX_SHORT_TEXT_FIELD_GRAPHEMES,
10
+ shouldValidateEmojiLimit,
11
+ } from '../request-emoji-limit';
12
+
13
+ function makeRequest(
14
+ body: string,
15
+ options?: {
16
+ contentType?: string;
17
+ headers?: Record<string, string>;
18
+ method?: string;
19
+ }
20
+ ) {
21
+ return new NextRequest('http://localhost/api/test', {
22
+ method: options?.method ?? 'POST',
23
+ headers: {
24
+ 'Content-Type': options?.contentType ?? 'application/json',
25
+ ...(options?.headers ?? {}),
26
+ },
27
+ body,
28
+ });
29
+ }
30
+
31
+ afterEach(() => {
32
+ vi.unstubAllEnvs();
33
+ });
34
+
35
+ describe('request emoji limit', () => {
36
+ it('counts pictographic emojis without counting plain numbers', () => {
37
+ expect(countEmojisInString('wallet 123')).toBe(0);
38
+ expect(countEmojisInString(`wallet ${'🎉'.repeat(3)}`)).toBe(3);
39
+ });
40
+
41
+ it('counts flag emoji graphemes correctly', () => {
42
+ expect(countEmojisInString('🇻🇳'.repeat(2))).toBe(2);
43
+ });
44
+
45
+ it('finds the first nested field over the limit', () => {
46
+ const violation = findRequestContentViolation({
47
+ wallet: {
48
+ name: '🎉'.repeat(MAX_EMOJIS_PER_FIELD + 1),
49
+ },
50
+ });
51
+
52
+ expect(violation).toEqual({
53
+ code: 'EMOJI_LIMIT_EXCEEDED',
54
+ path: 'body.wallet.name',
55
+ count: MAX_EMOJIS_PER_FIELD + 1,
56
+ limit: MAX_EMOJIS_PER_FIELD,
57
+ message: 'Field "body.wallet.name" cannot contain more than 10 emojis',
58
+ });
59
+ });
60
+
61
+ it('rejects text bombs in short-form metadata fields', () => {
62
+ const violation = findRequestContentViolation({
63
+ wallet: {
64
+ name: '漢字仮名交響曲'.repeat(
65
+ Math.ceil(
66
+ (MAX_SHORT_TEXT_FIELD_GRAPHEMES + 1) / '漢字仮名交響曲'.length
67
+ )
68
+ ),
69
+ },
70
+ });
71
+
72
+ expect(violation).toEqual({
73
+ code: 'TEXT_BOMB_DETECTED',
74
+ path: 'body.wallet.name',
75
+ count: 287,
76
+ limit: MAX_SHORT_TEXT_FIELD_GRAPHEMES,
77
+ message:
78
+ 'Field "body.wallet.name" exceeds the maximum short-field length',
79
+ });
80
+ });
81
+
82
+ it('allows repeated-character runs in description fields', () => {
83
+ const violation = findRequestContentViolation({
84
+ wallet: {
85
+ description: `legit ${'文'.repeat(195)}`,
86
+ },
87
+ });
88
+
89
+ expect(violation).toBeNull();
90
+ });
91
+
92
+ it('can skip validation for machine-generated snapshot fields', () => {
93
+ const violation = findRequestContentViolation(
94
+ {
95
+ snapshot: `{"elements":[{"text":"${'🎉'.repeat(
96
+ MAX_EMOJIS_PER_FIELD + 20
97
+ )}"}]}`,
98
+ title: 'Board',
99
+ },
100
+ 'body',
101
+ { skipValidationForFields: ['snapshot'] }
102
+ );
103
+
104
+ expect(violation).toBeNull();
105
+ });
106
+
107
+ it('skips validation for non-json requests', () => {
108
+ const request = makeRequest('name=test', {
109
+ contentType: 'application/x-www-form-urlencoded',
110
+ });
111
+
112
+ expect(shouldValidateEmojiLimit(request)).toBe(false);
113
+ });
114
+
115
+ it('detects trusted cron or service-role callers', () => {
116
+ vi.stubEnv('CRON_SECRET', 'cron-secret');
117
+ const request = makeRequest(JSON.stringify({ name: '🎉'.repeat(20) }), {
118
+ headers: { Authorization: 'Bearer cron-secret' },
119
+ });
120
+
121
+ expect(isTrustedEmojiBypassRequest(request)).toBe(true);
122
+ });
123
+
124
+ it('returns null for trusted callers even when payload exceeds the limit', async () => {
125
+ vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', 'service-role-secret');
126
+ const request = makeRequest(JSON.stringify({ name: '🎉'.repeat(20) }), {
127
+ headers: { Authorization: 'Bearer service-role-secret' },
128
+ });
129
+
130
+ await expect(
131
+ getRequestContentViolationForRequest(request)
132
+ ).resolves.toBeNull();
133
+ });
134
+
135
+ it('returns the offending field for untrusted JSON mutations', async () => {
136
+ const request = makeRequest(
137
+ JSON.stringify({
138
+ wallets: [
139
+ { name: 'ok' },
140
+ { name: '🎉'.repeat(MAX_EMOJIS_PER_FIELD + 2) },
141
+ ],
142
+ })
143
+ );
144
+
145
+ await expect(
146
+ getRequestContentViolationForRequest(request)
147
+ ).resolves.toEqual({
148
+ code: 'EMOJI_LIMIT_EXCEEDED',
149
+ path: 'body.wallets[1].name',
150
+ count: MAX_EMOJIS_PER_FIELD + 2,
151
+ limit: MAX_EMOJIS_PER_FIELD,
152
+ message:
153
+ 'Field "body.wallets[1].name" cannot contain more than 10 emojis',
154
+ });
155
+ });
156
+
157
+ it('skips whiteboard snapshot validation when configured for the request', async () => {
158
+ const request = makeRequest(
159
+ JSON.stringify({
160
+ snapshot: `{"elements":[{"text":"${'🎉'.repeat(
161
+ MAX_EMOJIS_PER_FIELD + 20
162
+ )}"}]}`,
163
+ })
164
+ );
165
+
166
+ await expect(
167
+ getRequestContentViolationForRequest(request, {
168
+ skipValidationForFields: ['snapshot'],
169
+ })
170
+ ).resolves.toBeNull();
171
+ });
172
+ });
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { escapeLikePattern, sanitizeSearchQuery } from '../search-helper';
3
+
4
+ describe('search-helper', () => {
5
+ describe('sanitizeSearchQuery', () => {
6
+ it('should return null for null, undefined, or empty strings', () => {
7
+ expect(sanitizeSearchQuery(null)).toBeNull();
8
+ expect(sanitizeSearchQuery(undefined)).toBeNull();
9
+ expect(sanitizeSearchQuery('')).toBeNull();
10
+ expect(sanitizeSearchQuery(' ')).toBeNull();
11
+ });
12
+
13
+ it('should trim whitespace', () => {
14
+ expect(sanitizeSearchQuery(' hello ')).toBe('hello');
15
+ });
16
+
17
+ it('should remove control characters', () => {
18
+ // \x00 is a control character (NULL)
19
+ // \x1F is a control character (INFORMATION SEPARATOR ONE)
20
+ // \x7F is a control character (DELETE)
21
+ const input = 'hello\x00world\x1F!\x7F';
22
+ expect(sanitizeSearchQuery(input)).toBe('helloworld!');
23
+ });
24
+
25
+ it('should return null if the string only contains control characters and whitespace', () => {
26
+ expect(sanitizeSearchQuery(' \x00 \x1F ')).toBeNull();
27
+ });
28
+ });
29
+
30
+ describe('escapeLikePattern', () => {
31
+ it('should escape backslashes', () => {
32
+ expect(escapeLikePattern('a\\b')).toBe('a\\\\b');
33
+ });
34
+
35
+ it('should escape percent signs', () => {
36
+ expect(escapeLikePattern('a%b')).toBe('a\\%b');
37
+ });
38
+
39
+ it('should escape underscores', () => {
40
+ expect(escapeLikePattern('a_b')).toBe('a\\_b');
41
+ });
42
+
43
+ it('should escape multiple special characters', () => {
44
+ expect(escapeLikePattern('a%b_c\\d')).toBe('a\\%b\\_c\\\\d');
45
+ });
46
+
47
+ it('should leave normal characters unchanged', () => {
48
+ expect(escapeLikePattern('hello world 123')).toBe('hello world 123');
49
+ });
50
+ });
51
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ getStorageObjectDisplayName,
4
+ getStoragePathSegmentDisplayName,
5
+ stripGeneratedStorageNamePrefix,
6
+ } from '../storage-display-name';
7
+
8
+ describe('storage display names', () => {
9
+ it('hides generated single and double UUID prefixes', () => {
10
+ expect(
11
+ stripGeneratedStorageNamePrefix(
12
+ 'e602ce48-3fa5-4b07-8508-befb78c41819_Mine Blast WebGL.zip'
13
+ )
14
+ ).toBe('Mine Blast WebGL.zip');
15
+ expect(
16
+ stripGeneratedStorageNamePrefix(
17
+ 'e602ce48-3fa5-4b07-8508-befb78c41819-49f380d5-b07d-4fa8-8087-6a5f63d3e5a8_Mine Blast WebGL.zip'
18
+ )
19
+ ).toBe('Mine Blast WebGL.zip');
20
+ });
21
+
22
+ it('hides generated UUID prefixes from URL-encoded path segments', () => {
23
+ expect(
24
+ getStoragePathSegmentDisplayName(
25
+ '9ba40a0d-8550-4ef0-8e21-ac333301ec5a-05cbb174-034d-4897-bdb4-3caea67e446b_Mine%20Blast%20WebGL'
26
+ )
27
+ ).toBe('Mine Blast WebGL');
28
+ });
29
+
30
+ it('returns a display name for storage objects', () => {
31
+ expect(
32
+ getStorageObjectDisplayName({
33
+ name: 'e602ce48-3fa5-4b07-8508-befb78c41819_receipt.pdf',
34
+ })
35
+ ).toBe('receipt.pdf');
36
+ });
37
+ });
@@ -0,0 +1,238 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ sanitizeFilename,
4
+ sanitizeFolderName,
5
+ sanitizePath,
6
+ } from '../storage-path';
7
+
8
+ describe('Storage Path Sanitization', () => {
9
+ describe('sanitizePath', () => {
10
+ it('returns empty string for empty input', () => {
11
+ expect(sanitizePath('')).toBe('');
12
+ });
13
+
14
+ it('handles simple paths', () => {
15
+ expect(sanitizePath('folder/subfolder')).toBe('folder/subfolder');
16
+ });
17
+
18
+ it('handles single segment paths', () => {
19
+ expect(sanitizePath('folder')).toBe('folder');
20
+ });
21
+
22
+ it('normalizes backslashes to forward slashes', () => {
23
+ expect(sanitizePath('folder\\subfolder')).toBe('folder/subfolder');
24
+ expect(sanitizePath('folder\\sub\\deep')).toBe('folder/sub/deep');
25
+ });
26
+
27
+ it('removes leading slashes', () => {
28
+ expect(sanitizePath('/folder/subfolder')).toBe('folder/subfolder');
29
+ expect(sanitizePath('///folder/subfolder')).toBe('folder/subfolder');
30
+ });
31
+
32
+ it('removes trailing slashes', () => {
33
+ expect(sanitizePath('folder/subfolder/')).toBe('folder/subfolder');
34
+ expect(sanitizePath('folder/subfolder///')).toBe('folder/subfolder');
35
+ });
36
+
37
+ it('trims whitespace', () => {
38
+ expect(sanitizePath(' folder/subfolder ')).toBe('folder/subfolder');
39
+ });
40
+
41
+ it('rejects path traversal with double dots', () => {
42
+ expect(sanitizePath('../../../etc/passwd')).toBeNull();
43
+ expect(sanitizePath('folder/../other')).toBeNull();
44
+ expect(sanitizePath('folder/..hidden')).toBeNull();
45
+ });
46
+
47
+ it('rejects single dot segments', () => {
48
+ expect(sanitizePath('./folder')).toBeNull();
49
+ expect(sanitizePath('folder/./subfolder')).toBeNull();
50
+ });
51
+
52
+ it('rejects standalone double dots', () => {
53
+ expect(sanitizePath('..')).toBeNull();
54
+ });
55
+
56
+ it('rejects standalone single dot', () => {
57
+ expect(sanitizePath('.')).toBeNull();
58
+ });
59
+
60
+ it('handles paths with multiple consecutive slashes', () => {
61
+ expect(sanitizePath('folder//subfolder')).toBe('folder/subfolder');
62
+ expect(sanitizePath('folder///sub///deep')).toBe('folder/sub/deep');
63
+ });
64
+
65
+ it('handles mixed separators', () => {
66
+ expect(sanitizePath('folder\\sub/deep\\deeper')).toBe(
67
+ 'folder/sub/deep/deeper'
68
+ );
69
+ });
70
+
71
+ it('handles complex valid paths', () => {
72
+ expect(sanitizePath('users/uploads/documents/2024')).toBe(
73
+ 'users/uploads/documents/2024'
74
+ );
75
+ });
76
+
77
+ it('handles paths with underscores and hyphens', () => {
78
+ expect(sanitizePath('my-folder/sub_folder')).toBe('my-folder/sub_folder');
79
+ });
80
+
81
+ it('handles paths with numbers', () => {
82
+ expect(sanitizePath('folder1/2024/files123')).toBe(
83
+ 'folder1/2024/files123'
84
+ );
85
+ });
86
+ });
87
+
88
+ describe('sanitizeFolderName', () => {
89
+ it('returns null for empty input', () => {
90
+ expect(sanitizeFolderName('')).toBeNull();
91
+ });
92
+
93
+ it('handles simple folder names', () => {
94
+ expect(sanitizeFolderName('my-folder')).toBe('my-folder');
95
+ });
96
+
97
+ it('handles folder names with underscores', () => {
98
+ expect(sanitizeFolderName('my_folder')).toBe('my_folder');
99
+ });
100
+
101
+ it('trims whitespace', () => {
102
+ expect(sanitizeFolderName(' folder ')).toBe('folder');
103
+ });
104
+
105
+ it('strips leading slashes and returns folder name', () => {
106
+ expect(sanitizeFolderName('/folder')).toBe('folder');
107
+ });
108
+
109
+ it('strips trailing slashes and returns folder name', () => {
110
+ expect(sanitizeFolderName('folder/')).toBe('folder');
111
+ });
112
+
113
+ it('rejects folder names containing slashes', () => {
114
+ expect(sanitizeFolderName('folder/subfolder')).toBeNull();
115
+ });
116
+
117
+ it('rejects folder names containing backslashes', () => {
118
+ expect(sanitizeFolderName('folder\\subfolder')).toBeNull();
119
+ });
120
+
121
+ it('rejects double dot path traversal', () => {
122
+ expect(sanitizeFolderName('..')).toBeNull();
123
+ });
124
+
125
+ it('rejects single dot', () => {
126
+ expect(sanitizeFolderName('.')).toBeNull();
127
+ });
128
+
129
+ it('rejects folder names containing double dots', () => {
130
+ expect(sanitizeFolderName('folder..name')).toBeNull();
131
+ expect(sanitizeFolderName('..hidden')).toBeNull();
132
+ });
133
+
134
+ it('handles folder names with spaces', () => {
135
+ expect(sanitizeFolderName('my folder')).toBe('my folder');
136
+ });
137
+
138
+ it('handles folder names with numbers', () => {
139
+ expect(sanitizeFolderName('folder123')).toBe('folder123');
140
+ expect(sanitizeFolderName('2024')).toBe('2024');
141
+ });
142
+ });
143
+
144
+ describe('sanitizeFilename', () => {
145
+ it('returns null for empty input', () => {
146
+ expect(sanitizeFilename('')).toBeNull();
147
+ });
148
+
149
+ it('handles simple filenames', () => {
150
+ expect(sanitizeFilename('document.pdf')).toBe('document.pdf');
151
+ });
152
+
153
+ it('handles filenames without extensions', () => {
154
+ expect(sanitizeFilename('README')).toBe('README');
155
+ });
156
+
157
+ it('handles filenames with multiple dots', () => {
158
+ expect(sanitizeFilename('file.backup.txt')).toBe('file.backup.txt');
159
+ });
160
+
161
+ it('handles filenames with underscores and hyphens', () => {
162
+ expect(sanitizeFilename('my-file_name.txt')).toBe('my-file_name.txt');
163
+ });
164
+
165
+ it('handles filenames with spaces', () => {
166
+ expect(sanitizeFilename('my file.txt')).toBe('my file.txt');
167
+ });
168
+
169
+ it('handles filenames with numbers', () => {
170
+ expect(sanitizeFilename('file123.txt')).toBe('file123.txt');
171
+ expect(sanitizeFilename('2024-01-01.log')).toBe('2024-01-01.log');
172
+ });
173
+
174
+ it('rejects path traversal attempts', () => {
175
+ expect(sanitizeFilename('../../../etc/passwd')).toBeNull();
176
+ expect(sanitizeFilename('folder/file.txt')).toBeNull();
177
+ });
178
+
179
+ it('rejects backslash paths', () => {
180
+ expect(sanitizeFilename('folder\\file.txt')).toBeNull();
181
+ });
182
+
183
+ it('rejects control characters', () => {
184
+ expect(sanitizeFilename('file\x00name.txt')).toBeNull();
185
+ expect(sanitizeFilename('file\x1fname.txt')).toBeNull();
186
+ });
187
+
188
+ it('rejects filenames starting with space', () => {
189
+ expect(sanitizeFilename(' hidden.txt')).toBeNull();
190
+ });
191
+
192
+ it('rejects filenames ending with space', () => {
193
+ expect(sanitizeFilename('file.txt ')).toBeNull();
194
+ });
195
+
196
+ it('rejects filenames starting with dot', () => {
197
+ expect(sanitizeFilename('.hidden')).toBeNull();
198
+ expect(sanitizeFilename('.gitignore')).toBeNull();
199
+ });
200
+
201
+ it('rejects filenames ending with dot', () => {
202
+ expect(sanitizeFilename('file.')).toBeNull();
203
+ });
204
+
205
+ it('rejects filenames exceeding 255 characters', () => {
206
+ const longName = `${'a'.repeat(256)}.txt`;
207
+ expect(sanitizeFilename(longName)).toBeNull();
208
+ });
209
+
210
+ it('accepts filenames at exactly 255 characters', () => {
211
+ const maxName = `${'a'.repeat(251)}.txt`;
212
+ expect(sanitizeFilename(maxName)).toBe(maxName);
213
+ });
214
+
215
+ it('rejects non-ASCII characters', () => {
216
+ expect(sanitizeFilename('файл.txt')).toBeNull();
217
+ expect(sanitizeFilename('文件.txt')).toBeNull();
218
+ expect(sanitizeFilename('résumé.pdf')).toBeNull();
219
+ });
220
+
221
+ it('rejects special characters not in allowlist', () => {
222
+ expect(sanitizeFilename('file@name.txt')).toBeNull();
223
+ expect(sanitizeFilename('file#name.txt')).toBeNull();
224
+ expect(sanitizeFilename('file$name.txt')).toBeNull();
225
+ expect(sanitizeFilename('file%name.txt')).toBeNull();
226
+ expect(sanitizeFilename('file&name.txt')).toBeNull();
227
+ expect(sanitizeFilename('file*name.txt')).toBeNull();
228
+ });
229
+
230
+ it('handles uppercase filenames', () => {
231
+ expect(sanitizeFilename('DOCUMENT.PDF')).toBe('DOCUMENT.PDF');
232
+ });
233
+
234
+ it('handles mixed case filenames', () => {
235
+ expect(sanitizeFilename('MyDocument.Pdf')).toBe('MyDocument.Pdf');
236
+ });
237
+ });
238
+ });