@tuturuuu/utils 0.0.3 → 0.6.1

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 +313 -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,411 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import {
3
+ decryptCalendarEventFields,
4
+ decryptCalendarEvents,
5
+ encryptCalendarEventFields,
6
+ encryptCalendarEvents,
7
+ } from '../encryption-service';
8
+ import type { CalendarEventWithEncryption } from '../types';
9
+ import { generateWorkspaceKey } from './test-helpers';
10
+
11
+ describe('encryption-service', () => {
12
+ // ============================================================================
13
+ // Calendar Event Field Encryption Tests
14
+ // ============================================================================
15
+ describe('calendar event field encryption', () => {
16
+ let workspaceKey: Buffer;
17
+
18
+ beforeEach(() => {
19
+ workspaceKey = generateWorkspaceKey();
20
+ });
21
+
22
+ it('should encrypt and decrypt calendar event fields', () => {
23
+ const event = {
24
+ title: 'Team Standup',
25
+ description: 'Daily standup meeting',
26
+ location: 'Conference Room A',
27
+ };
28
+
29
+ const encrypted = encryptCalendarEventFields(event, workspaceKey);
30
+
31
+ expect(encrypted.title).not.toBe(event.title);
32
+ expect(encrypted.description).not.toBe(event.description);
33
+ expect(encrypted.location).not.toBe(event.location);
34
+
35
+ const decrypted = decryptCalendarEventFields(encrypted, workspaceKey);
36
+
37
+ expect(decrypted.title).toBe(event.title);
38
+ expect(decrypted.description).toBe(event.description);
39
+ expect(decrypted.location).toBe(event.location);
40
+ });
41
+
42
+ it('should handle undefined location', () => {
43
+ const event = {
44
+ title: 'Phone Call',
45
+ description: 'Quick sync call',
46
+ location: undefined,
47
+ };
48
+
49
+ const encrypted = encryptCalendarEventFields(event, workspaceKey);
50
+ expect(encrypted.location).toBeUndefined();
51
+
52
+ const decrypted = decryptCalendarEventFields(encrypted, workspaceKey);
53
+ expect(decrypted.location).toBeUndefined();
54
+ });
55
+
56
+ it('should handle empty title and description', () => {
57
+ const event = {
58
+ title: '',
59
+ description: '',
60
+ location: undefined,
61
+ };
62
+
63
+ const encrypted = encryptCalendarEventFields(event, workspaceKey);
64
+ const decrypted = decryptCalendarEventFields(encrypted, workspaceKey);
65
+
66
+ expect(decrypted.title).toBe('');
67
+ expect(decrypted.description).toBe('');
68
+ expect(decrypted.location).toBeUndefined();
69
+ });
70
+
71
+ it('should handle null title and description', () => {
72
+ const event = {
73
+ title: null as unknown as string,
74
+ description: null as unknown as string,
75
+ location: undefined,
76
+ };
77
+
78
+ const encrypted = encryptCalendarEventFields(event, workspaceKey);
79
+ const decrypted = decryptCalendarEventFields(encrypted, workspaceKey);
80
+
81
+ expect(decrypted.title).toBe('');
82
+ expect(decrypted.description).toBe('');
83
+ });
84
+
85
+ it('should preserve non-encrypted fields', () => {
86
+ const event = {
87
+ title: 'Meeting',
88
+ description: 'Important meeting',
89
+ location: 'Room 101',
90
+ };
91
+
92
+ const encrypted = encryptCalendarEventFields(event, workspaceKey);
93
+
94
+ // Only title, description, location should be in the result
95
+ expect(Object.keys(encrypted)).toEqual([
96
+ 'title',
97
+ 'description',
98
+ 'location',
99
+ ]);
100
+ });
101
+ });
102
+
103
+ // ============================================================================
104
+ // Batch Encryption/Decryption Tests
105
+ // ============================================================================
106
+ describe('batch calendar event operations', () => {
107
+ let workspaceKey: Buffer;
108
+
109
+ beforeEach(() => {
110
+ workspaceKey = generateWorkspaceKey();
111
+ });
112
+
113
+ describe('encryptCalendarEvents', () => {
114
+ it('should encrypt multiple events', () => {
115
+ const events = [
116
+ { title: 'Event 1', description: 'Desc 1', location: 'Loc 1' },
117
+ { title: 'Event 2', description: 'Desc 2', location: undefined },
118
+ { title: 'Event 3', description: '', location: 'Loc 3' },
119
+ ];
120
+
121
+ const encrypted = encryptCalendarEvents(events, workspaceKey);
122
+
123
+ expect(encrypted).toHaveLength(3);
124
+ encrypted.forEach((event, index) => {
125
+ expect(event.is_encrypted).toBe(true);
126
+ // Non-empty fields should be encrypted (different from original)
127
+ if (events[index]!.title) {
128
+ expect(event.title).not.toBe(events[index]!.title);
129
+ }
130
+ if (events[index]!.description) {
131
+ expect(event.description).not.toBe(events[index]!.description);
132
+ }
133
+ });
134
+ });
135
+
136
+ it('should handle empty array', () => {
137
+ const encrypted = encryptCalendarEvents([], workspaceKey);
138
+ expect(encrypted).toEqual([]);
139
+ });
140
+
141
+ it('should preserve additional event properties', () => {
142
+ const events = [
143
+ {
144
+ title: 'Event',
145
+ description: 'Desc',
146
+ location: 'Loc',
147
+ start_at: '2024-01-01T10:00:00Z',
148
+ end_at: '2024-01-01T11:00:00Z',
149
+ color: 'blue',
150
+ },
151
+ ];
152
+
153
+ const encrypted = encryptCalendarEvents(events, workspaceKey);
154
+
155
+ expect(encrypted[0]!.start_at).toBe('2024-01-01T10:00:00Z');
156
+ expect(encrypted[0]!.end_at).toBe('2024-01-01T11:00:00Z');
157
+ expect(encrypted[0]!.color).toBe('blue');
158
+ expect(encrypted[0]!.is_encrypted).toBe(true);
159
+ });
160
+ });
161
+
162
+ describe('decryptCalendarEvents', () => {
163
+ it('should decrypt multiple encrypted events', () => {
164
+ const originalEvents = [
165
+ { title: 'Event 1', description: 'Desc 1', location: 'Loc 1' },
166
+ { title: 'Event 2', description: 'Desc 2', location: 'Loc 2' },
167
+ ];
168
+
169
+ const encrypted = encryptCalendarEvents(originalEvents, workspaceKey);
170
+
171
+ // Add required fields for CalendarEventWithEncryption
172
+ const eventsWithMetadata = encrypted.map((event, index) => ({
173
+ ...event,
174
+ id: `event-${index}`,
175
+ start_at: '2024-01-01T10:00:00Z',
176
+ end_at: '2024-01-01T11:00:00Z',
177
+ color: 'blue' as const,
178
+ ws_id: 'ws-123',
179
+ }));
180
+
181
+ const decrypted = decryptCalendarEvents(
182
+ eventsWithMetadata,
183
+ workspaceKey
184
+ );
185
+
186
+ expect(decrypted).toHaveLength(2);
187
+ decrypted.forEach((event, index) => {
188
+ expect(event.title).toBe(originalEvents[index]!.title);
189
+ expect(event.description).toBe(originalEvents[index]!.description);
190
+ expect(event.location).toBe(originalEvents[index]!.location);
191
+ });
192
+ });
193
+
194
+ it('should set is_encrypted to false after successful decryption', () => {
195
+ // This test verifies that after decryption, is_encrypted is set to false
196
+ // so that filtering logic can correctly identify decrypted vs failed events
197
+ const originalEvents = [
198
+ { title: 'Event 1', description: 'Desc 1', location: 'Loc 1' },
199
+ ];
200
+
201
+ const encrypted = encryptCalendarEvents(originalEvents, workspaceKey);
202
+
203
+ // Verify encrypted event has is_encrypted: true
204
+ expect(encrypted[0]!.is_encrypted).toBe(true);
205
+
206
+ const eventsWithMetadata = encrypted.map((event, index) => ({
207
+ ...event,
208
+ id: `event-${index}`,
209
+ start_at: '2024-01-01T10:00:00Z',
210
+ end_at: '2024-01-01T11:00:00Z',
211
+ color: 'blue' as const,
212
+ ws_id: 'ws-123',
213
+ }));
214
+
215
+ const decrypted = decryptCalendarEvents(
216
+ eventsWithMetadata,
217
+ workspaceKey
218
+ );
219
+
220
+ // After successful decryption, is_encrypted should be false
221
+ expect(decrypted[0]!.is_encrypted).toBe(false);
222
+ expect(decrypted[0]!.title).toBe('Event 1');
223
+ });
224
+
225
+ it('should skip non-encrypted events', () => {
226
+ const events = [
227
+ {
228
+ id: 'event-1',
229
+ title: 'Plaintext Event',
230
+ description: 'Plaintext Desc',
231
+ location: 'Plaintext Loc',
232
+ start_at: '2024-01-01T10:00:00Z',
233
+ end_at: '2024-01-01T11:00:00Z',
234
+ color: 'blue' as const,
235
+ ws_id: 'ws-123',
236
+ is_encrypted: false,
237
+ },
238
+ ];
239
+
240
+ const result = decryptCalendarEvents(events, workspaceKey);
241
+
242
+ expect(result[0]!.title).toBe('Plaintext Event');
243
+ expect(result[0]!.description).toBe('Plaintext Desc');
244
+ });
245
+
246
+ it('should handle mixed encrypted and non-encrypted events', () => {
247
+ const encryptedEvent = encryptCalendarEventFields(
248
+ { title: 'Encrypted', description: 'Secret', location: 'Hidden' },
249
+ workspaceKey
250
+ );
251
+
252
+ const events = [
253
+ {
254
+ id: 'event-1',
255
+ ...encryptedEvent,
256
+ location: encryptedEvent.location ?? null,
257
+ start_at: '2024-01-01T10:00:00Z',
258
+ end_at: '2024-01-01T11:00:00Z',
259
+ color: 'blue' as const,
260
+ ws_id: 'ws-123',
261
+ is_encrypted: true,
262
+ },
263
+ {
264
+ id: 'event-2',
265
+ title: 'Public',
266
+ description: 'Visible',
267
+ location: 'Open' as string | null,
268
+ start_at: '2024-01-01T12:00:00Z',
269
+ end_at: '2024-01-01T13:00:00Z',
270
+ color: 'red' as const,
271
+ ws_id: 'ws-123',
272
+ is_encrypted: false,
273
+ },
274
+ ];
275
+
276
+ const decrypted = decryptCalendarEvents(events, workspaceKey);
277
+
278
+ expect(decrypted[0]!.title).toBe('Encrypted');
279
+ expect(decrypted[1]!.title).toBe('Public');
280
+ });
281
+
282
+ it('should handle empty array', () => {
283
+ const result = decryptCalendarEvents([], workspaceKey);
284
+ expect(result).toEqual([]);
285
+ });
286
+
287
+ it('should handle events with null fields and return normalized values', () => {
288
+ const events = [
289
+ {
290
+ id: 'event-1',
291
+ title: null as unknown as string,
292
+ description: null as unknown as string,
293
+ location: null,
294
+ start_at: '2024-01-01T10:00:00Z',
295
+ end_at: '2024-01-01T11:00:00Z',
296
+ color: 'blue' as const,
297
+ ws_id: 'ws-123',
298
+ is_encrypted: true, // Marked as encrypted but with null values
299
+ },
300
+ ];
301
+
302
+ // Call decryptCalendarEvents and capture result
303
+ const result = decryptCalendarEvents(
304
+ events as unknown as CalendarEventWithEncryption[],
305
+ workspaceKey
306
+ );
307
+
308
+ // Verify the result is an array with one event
309
+ expect(result).toHaveLength(1);
310
+
311
+ const decryptedEvent = result[0]!;
312
+
313
+ // Non-sensitive fields should remain unchanged
314
+ expect(decryptedEvent.id).toBe('event-1');
315
+ expect(decryptedEvent.start_at).toBe('2024-01-01T10:00:00Z');
316
+ expect(decryptedEvent.end_at).toBe('2024-01-01T11:00:00Z');
317
+ expect(decryptedEvent.color).toBe('blue');
318
+ expect(decryptedEvent.ws_id).toBe('ws-123');
319
+
320
+ // Null fields get normalized to empty strings by decryptField
321
+ expect(decryptedEvent.title).toBe('');
322
+ expect(decryptedEvent.description).toBe('');
323
+ expect(decryptedEvent.location).toBeUndefined(); // null location becomes undefined
324
+ });
325
+
326
+ describe('negative tests and degradations', () => {
327
+ it('should return original ciphertext when decryption fails due to incorrect key', () => {
328
+ const events = [
329
+ { title: 'Secret', description: 'Desc', location: 'Loc' },
330
+ ];
331
+ const encrypted = encryptCalendarEvents(events, workspaceKey);
332
+
333
+ // Use a different key
334
+ const wrongKey = generateWorkspaceKey();
335
+
336
+ // Add metadata
337
+ const eventsWithMetadata = encrypted.map((e) => ({
338
+ ...e,
339
+ id: '1',
340
+ ws_id: 'ws',
341
+ start_at: '2024-01-01T10:00:00Z',
342
+ end_at: '2024-01-01T11:00:00Z',
343
+ color: 'blue' as const,
344
+ }));
345
+
346
+ const result = decryptCalendarEvents(eventsWithMetadata, wrongKey);
347
+
348
+ // Should contain ciphertext (not decrypted, not empty)
349
+ // The service returns the original ciphertext on failure check
350
+ expect(result[0]!.title).toBe(encrypted[0]!.title);
351
+ expect(result[0]!.title).not.toBe('Secret');
352
+ });
353
+
354
+ it('should handle corrupted encrypted fields gracefully', () => {
355
+ const events = [{ title: 'Secret', description: 'Desc' }];
356
+ const encrypted = encryptCalendarEvents(events, workspaceKey);
357
+
358
+ // Corrupt the title
359
+ // We modify the middle of the string to invalidate the auth tag or ciphertext
360
+ const originalTitleCipher = encrypted[0]!.title!;
361
+ // Ensure we have enough length to splice. Base64 is usually long enough.
362
+ const corruptedTitle =
363
+ originalTitleCipher.substring(0, 10) +
364
+ (originalTitleCipher.charAt(10) === 'A' ? 'B' : 'A') +
365
+ originalTitleCipher.substring(11);
366
+
367
+ const eventsWithMetadata = [
368
+ {
369
+ ...encrypted[0],
370
+ title: corruptedTitle,
371
+ id: '1',
372
+ ws_id: 'ws',
373
+ start_at: '2024-01-01T10:00:00Z',
374
+ end_at: '2024-01-01T11:00:00Z',
375
+ color: 'blue' as const,
376
+ location: undefined,
377
+ },
378
+ ];
379
+
380
+ const result = decryptCalendarEvents(
381
+ eventsWithMetadata as unknown as CalendarEventWithEncryption[],
382
+ workspaceKey
383
+ );
384
+
385
+ // Should return the corrupted string (fallback)
386
+ expect(result[0]!.title).toBe(corruptedTitle);
387
+ });
388
+
389
+ it('should correct invalid input formats even if marked encrypted', () => {
390
+ const events = [
391
+ {
392
+ id: '1',
393
+ ws_id: 'ws',
394
+ start_at: '2024-01-01T10:00:00Z',
395
+ end_at: '2024-01-01T11:00:00Z',
396
+ color: 'blue' as const,
397
+ title: 'too-short', // Short string, not valid encrypted format
398
+ description: 'not-base-64-!!', // Likely fails invalid length or decode
399
+ location: null,
400
+ is_encrypted: true,
401
+ } as unknown as CalendarEventWithEncryption,
402
+ ];
403
+
404
+ const result = decryptCalendarEvents(events, workspaceKey);
405
+ // Short string returns as is because length check fails
406
+ expect(result[0]!.title).toBe('too-short');
407
+ });
408
+ });
409
+ });
410
+ });
411
+ });
@@ -0,0 +1,114 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import {
3
+ encryptField,
4
+ getMasterKey,
5
+ isEncryptionEnabled,
6
+ } from '../encryption-service';
7
+ import {
8
+ generateWorkspaceKey,
9
+ setupEncryptionEnv,
10
+ TEST_MASTER_KEY,
11
+ } from './test-helpers';
12
+
13
+ describe('encryption-service', () => {
14
+ setupEncryptionEnv();
15
+
16
+ // ============================================================================
17
+ // Environment and Configuration Tests
18
+ // ============================================================================
19
+ describe('isEncryptionEnabled', () => {
20
+ it('should return false when master key is not set', () => {
21
+ delete process.env.ENCRYPTION_MASTER_KEY;
22
+ expect(isEncryptionEnabled()).toBe(false);
23
+ });
24
+
25
+ it('should return true when master key is set', () => {
26
+ process.env.ENCRYPTION_MASTER_KEY = TEST_MASTER_KEY;
27
+ expect(isEncryptionEnabled()).toBe(true);
28
+ });
29
+
30
+ it('should return false for empty string', () => {
31
+ process.env.ENCRYPTION_MASTER_KEY = '';
32
+ expect(isEncryptionEnabled()).toBe(false);
33
+ });
34
+
35
+ it('should return false for whitespace-only key', () => {
36
+ process.env.ENCRYPTION_MASTER_KEY = ' ';
37
+ expect(isEncryptionEnabled()).toBe(false);
38
+ });
39
+ });
40
+
41
+ describe('getMasterKey', () => {
42
+ it('should return master key when set', () => {
43
+ process.env.ENCRYPTION_MASTER_KEY = TEST_MASTER_KEY;
44
+ expect(getMasterKey()).toBe(TEST_MASTER_KEY);
45
+ });
46
+
47
+ it('should throw when master key is not set', () => {
48
+ delete process.env.ENCRYPTION_MASTER_KEY;
49
+ expect(() => getMasterKey()).toThrow(
50
+ 'ENCRYPTION_MASTER_KEY environment variable is not configured'
51
+ );
52
+ });
53
+
54
+ it('should throw for empty string', () => {
55
+ process.env.ENCRYPTION_MASTER_KEY = '';
56
+ expect(() => getMasterKey()).toThrow(
57
+ 'ENCRYPTION_MASTER_KEY environment variable is not configured'
58
+ );
59
+ });
60
+ });
61
+
62
+ // ============================================================================
63
+ // Ciphertext Format and Security Tests
64
+ // ============================================================================
65
+ describe('ciphertext format validation', () => {
66
+ let workspaceKey: Buffer;
67
+
68
+ beforeEach(() => {
69
+ workspaceKey = generateWorkspaceKey();
70
+ });
71
+
72
+ it('should produce base64-encoded ciphertext', () => {
73
+ const ciphertext = encryptField('test', workspaceKey);
74
+
75
+ // Valid base64 pattern
76
+ expect(ciphertext).toMatch(/^[A-Za-z0-9+/]+=*$/);
77
+ });
78
+
79
+ it('should produce ciphertext with minimum length', () => {
80
+ const ciphertext = encryptField('x', workspaceKey);
81
+ const decoded = Buffer.from(ciphertext, 'base64');
82
+
83
+ // Minimum: 12 (IV) + 1 (ciphertext for 'x') + 16 (auth tag)
84
+ expect(decoded.length).toBeGreaterThanOrEqual(29);
85
+ });
86
+
87
+ it('ciphertext length should grow with plaintext length', () => {
88
+ const short = encryptField('a', workspaceKey);
89
+ const medium = encryptField('a'.repeat(100), workspaceKey);
90
+ const long = encryptField('a'.repeat(1000), workspaceKey);
91
+
92
+ const shortLen = Buffer.from(short, 'base64').length;
93
+ const mediumLen = Buffer.from(medium, 'base64').length;
94
+ const longLen = Buffer.from(long, 'base64').length;
95
+
96
+ expect(mediumLen).toBeGreaterThan(shortLen);
97
+ expect(longLen).toBeGreaterThan(mediumLen);
98
+ });
99
+
100
+ it('should not leak plaintext in ciphertext', () => {
101
+ const plaintext = 'TOP SECRET MESSAGE';
102
+ const ciphertext = encryptField(plaintext, workspaceKey);
103
+
104
+ // Plaintext should not appear in ciphertext
105
+ expect(ciphertext).not.toContain('TOP');
106
+ expect(ciphertext).not.toContain('SECRET');
107
+ expect(ciphertext).not.toContain('MESSAGE');
108
+
109
+ // Also check the raw bytes
110
+ const decoded = Buffer.from(ciphertext, 'base64');
111
+ expect(decoded.toString('utf8')).not.toContain('TOP');
112
+ });
113
+ });
114
+ });