@tuturuuu/utils 0.12.1 → 0.15.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/CHANGELOG.md +69 -0
- package/biome.json +1 -1
- package/package.json +10 -10
- package/src/__tests__/api-proxy-guard.test.ts +40 -17
- package/src/__tests__/app-url.test.ts +6 -0
- package/src/__tests__/task-transformers.test.ts +21 -0
- package/src/abuse-protection/__tests__/index-fail-open.test.ts +93 -0
- package/src/abuse-protection/__tests__/index.test.ts +76 -1
- package/src/abuse-protection/index.ts +136 -123
- package/src/api-proxy-guard.ts +15 -117
- package/src/calendar-settings-resolver.ts +200 -0
- package/src/internal-domains.ts +44 -0
- package/src/launchable-apps.test.ts +44 -0
- package/src/launchable-apps.ts +60 -47
- package/src/platform-release.test.ts +2 -2
- package/src/platform-release.ts +3 -1
- package/src/portless.ts +4 -0
- package/src/shared-cookie.test.ts +114 -0
- package/src/shared-cookie.ts +235 -0
- package/src/task/transformers.ts +2 -0
- package/src/topic-announcements.ts +1 -0
- package/src/user-helper.ts +6 -95
- package/src/workspace-user-link.test.ts +53 -0
- package/src/workspace-user-link.ts +122 -0
- package/src/yjs-helper.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,74 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.15.0](https://github.com/tutur3u/platform/compare/utils-v0.14.0...utils-v0.15.0) (2026-07-11)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **contacts:** migrate users attendance; add satellite-safe workspace-user link helper ([8f53453](https://github.com/tutur3u/platform/commit/8f534533793af8d25e7c2eca6e0147e459d78bbe))
|
|
9
|
+
* **contacts:** migrate users topic-announcements module ([01e19ab](https://github.com/tutur3u/platform/commit/01e19ab60461ccd94a0374bed74ce6e4d5d38a3b))
|
|
10
|
+
* **contacts:** scaffold contacts.tuturuuu.com satellite shell + monorepo registration ([7e335fc](https://github.com/tutur3u/platform/commit/7e335fc036c4a45ed189095ecd10a43ee002294b))
|
|
11
|
+
* **infrastructure:** add abuse protection controls ([0e09692](https://github.com/tutur3u/platform/commit/0e09692a553cb10fbbb521e921951c2a6863281d))
|
|
12
|
+
* **infrastructure:** add rate-limit appeals ([f1e50e9](https://github.com/tutur3u/platform/commit/f1e50e9fd805dca6c80da8daab60ed24199f24ab))
|
|
13
|
+
* **pay:** scaffold apps/pay satellite (pay.tuturuuu.com, port 7826) ([3d6e45c](https://github.com/tutur3u/platform/commit/3d6e45cca6a315fe67213e438144b73103bae2f5))
|
|
14
|
+
* **tools:** add apps/tools public tools app, remove apps/qr ([204aae8](https://github.com/tutur3u/platform/commit/204aae89a04b4163f2859561f8e832526642e271))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Bug Fixes
|
|
18
|
+
|
|
19
|
+
* **auth:** share account preference cookies ([8c1848a](https://github.com/tutur3u/platform/commit/8c1848a941c1b3f91337104c975e4bc0d8f68fc9))
|
|
20
|
+
* **ci:** keep cache components enabled in production ([75e6e2b](https://github.com/tutur3u/platform/commit/75e6e2bbbf6fa432bc496ee95b75ade5f8847f9f))
|
|
21
|
+
* **cli:** preserve markdown tables in task descriptions ([9faf8ba](https://github.com/tutur3u/platform/commit/9faf8ba44b92565a9dfc2949c496654fc1fea9b3))
|
|
22
|
+
* **infrastructure:** default docker web to native builds ([7d89f00](https://github.com/tutur3u/platform/commit/7d89f004f75ab12eddc3cb92df5a1e6a7255fe0d))
|
|
23
|
+
* **infrastructure:** restore canonical auth host access ([9f6715f](https://github.com/tutur3u/platform/commit/9f6715fb1c5a89598556cba33b16938b76995fc6))
|
|
24
|
+
* restore apps launcher categories ([5c5b571](https://github.com/tutur3u/platform/commit/5c5b571ce920013bcfa5f327c4bb208ea9fbf23e))
|
|
25
|
+
* **satellite:** widen apps launcher grid ([90e3106](https://github.com/tutur3u/platform/commit/90e31066830f535a7fdd0f7bc249e5bc8b273cfc))
|
|
26
|
+
* **security:** limit auth recovery code attempts ([72d454c](https://github.com/tutur3u/platform/commit/72d454c507b0d4b7e69c7b38ad2099dbb83193b2))
|
|
27
|
+
* **tasks:** route native personal task moves normally ([5bbba3c](https://github.com/tutur3u/platform/commit/5bbba3c3f193e722cc87cdfe2eb2734f61442cd0))
|
|
28
|
+
* update launchable app catalog ([cb31207](https://github.com/tutur3u/platform/commit/cb312076aee227de9a8f99105d681911d14a63ac))
|
|
29
|
+
* **web:** fail open without redis ([74bfcc7](https://github.com/tutur3u/platform/commit/74bfcc7ea5f188a56f49802c6a48bde0b109e02d))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Performance Improvements
|
|
33
|
+
|
|
34
|
+
* **ci:** enable repository-wide remote caching ([6250f91](https://github.com/tutur3u/platform/commit/6250f91d745ef987a4fc86c797aedf41542f421b))
|
|
35
|
+
|
|
36
|
+
## [0.14.0](https://github.com/tutur3u/platform/compare/utils-v0.13.0...utils-v0.14.0) (2026-07-11)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
### Features
|
|
40
|
+
|
|
41
|
+
* **contacts:** migrate users attendance; add satellite-safe workspace-user link helper ([8f53453](https://github.com/tutur3u/platform/commit/8f534533793af8d25e7c2eca6e0147e459d78bbe))
|
|
42
|
+
* **contacts:** migrate users topic-announcements module ([01e19ab](https://github.com/tutur3u/platform/commit/01e19ab60461ccd94a0374bed74ce6e4d5d38a3b))
|
|
43
|
+
* **contacts:** scaffold contacts.tuturuuu.com satellite shell + monorepo registration ([7e335fc](https://github.com/tutur3u/platform/commit/7e335fc036c4a45ed189095ecd10a43ee002294b))
|
|
44
|
+
* **pay:** scaffold apps/pay satellite (pay.tuturuuu.com, port 7826) ([3d6e45c](https://github.com/tutur3u/platform/commit/3d6e45cca6a315fe67213e438144b73103bae2f5))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
### Bug Fixes
|
|
48
|
+
|
|
49
|
+
* **auth:** share account preference cookies ([8c1848a](https://github.com/tutur3u/platform/commit/8c1848a941c1b3f91337104c975e4bc0d8f68fc9))
|
|
50
|
+
* restore apps launcher categories ([5c5b571](https://github.com/tutur3u/platform/commit/5c5b571ce920013bcfa5f327c4bb208ea9fbf23e))
|
|
51
|
+
* **satellite:** widen apps launcher grid ([90e3106](https://github.com/tutur3u/platform/commit/90e31066830f535a7fdd0f7bc249e5bc8b273cfc))
|
|
52
|
+
* update launchable app catalog ([cb31207](https://github.com/tutur3u/platform/commit/cb312076aee227de9a8f99105d681911d14a63ac))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
### Performance Improvements
|
|
56
|
+
|
|
57
|
+
* **ci:** enable repository-wide remote caching ([6250f91](https://github.com/tutur3u/platform/commit/6250f91d745ef987a4fc86c797aedf41542f421b))
|
|
58
|
+
|
|
59
|
+
## [0.13.0](https://github.com/tutur3u/platform/compare/utils-v0.12.1...utils-v0.13.0) (2026-07-05)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
### Features
|
|
63
|
+
|
|
64
|
+
* **tools:** add apps/tools public tools app, remove apps/qr ([204aae8](https://github.com/tutur3u/platform/commit/204aae89a04b4163f2859561f8e832526642e271))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
### Bug Fixes
|
|
68
|
+
|
|
69
|
+
* **tasks:** route native personal task moves normally ([5bbba3c](https://github.com/tutur3u/platform/commit/5bbba3c3f193e722cc87cdfe2eb2734f61442cd0))
|
|
70
|
+
* **web:** fail open without redis ([74bfcc7](https://github.com/tutur3u/platform/commit/74bfcc7ea5f188a56f49802c6a48bde0b109e02d))
|
|
71
|
+
|
|
3
72
|
## [0.12.1](https://github.com/tutur3u/platform/compare/utils-v0.12.0...utils-v0.12.1) (2026-07-03)
|
|
4
73
|
|
|
5
74
|
|
package/biome.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/utils",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.15.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/tutur3u/platform",
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
"@noble/ciphers": "^2.2.0",
|
|
21
21
|
"@noble/hashes": "^2.2.0",
|
|
22
22
|
"@tanstack/react-query": "^5.101.2",
|
|
23
|
-
"@tiptap/core": "3.27.
|
|
24
|
-
"@tiptap/react": "3.27.
|
|
23
|
+
"@tiptap/core": "3.27.3",
|
|
24
|
+
"@tiptap/react": "3.27.3",
|
|
25
25
|
"@tuturuuu/google": "0.0.3",
|
|
26
26
|
"@tuturuuu/icons": "0.0.6",
|
|
27
|
-
"@tuturuuu/internal-api": "0.
|
|
27
|
+
"@tuturuuu/internal-api": "0.17.0",
|
|
28
28
|
"@tuturuuu/supabase": "0.4.0",
|
|
29
29
|
"@upstash/ratelimit": "^2.0.8",
|
|
30
30
|
"@upstash/redis": "^1.38.0",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"effect": "^3.21.4",
|
|
36
36
|
"moment": "^2.30.1",
|
|
37
37
|
"next": "^16.3.0-preview.5",
|
|
38
|
-
"prosemirror-model": "^1.25.
|
|
38
|
+
"prosemirror-model": "^1.25.11",
|
|
39
39
|
"react": "^19.2.7",
|
|
40
40
|
"react-dom": "^19.2.7",
|
|
41
41
|
"tailwind-merge": "^3.6.0",
|
|
@@ -45,15 +45,15 @@
|
|
|
45
45
|
"zod": "^4.4.3"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@tuturuuu/types": "0.
|
|
48
|
+
"@tuturuuu/types": "0.16.0",
|
|
49
49
|
"@tuturuuu/typescript-config": "0.1.1",
|
|
50
50
|
"@types/diff": "^8.0.0",
|
|
51
|
-
"@types/node": "^26.
|
|
51
|
+
"@types/node": "^26.1.1",
|
|
52
52
|
"@types/react": "^19.2.17",
|
|
53
53
|
"@vitejs/plugin-react": "^6.0.3",
|
|
54
|
-
"typescript": "7.0.
|
|
55
|
-
"vite": "^8.1.
|
|
56
|
-
"vitest": "^4.1.
|
|
54
|
+
"typescript": "7.0.2",
|
|
55
|
+
"vite": "^8.1.4",
|
|
56
|
+
"vitest": "^4.1.10"
|
|
57
57
|
},
|
|
58
58
|
"exports": {
|
|
59
59
|
"./timezones": "./src/timezones.json",
|
|
@@ -278,6 +278,36 @@ describe('guardApiProxyRequest', () => {
|
|
|
278
278
|
expect(mocks.validateEmoji).toHaveBeenCalled();
|
|
279
279
|
});
|
|
280
280
|
|
|
281
|
+
it('skips proxy route buckets when Redis is not configured', async () => {
|
|
282
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
283
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
284
|
+
mocks.isBlocked.mockResolvedValue(null);
|
|
285
|
+
mocks.limit.mockResolvedValue({
|
|
286
|
+
success: false,
|
|
287
|
+
limit: 1,
|
|
288
|
+
remaining: 0,
|
|
289
|
+
reset: Date.now() + 60_000,
|
|
290
|
+
});
|
|
291
|
+
mocks.validateEmoji.mockResolvedValue(null);
|
|
292
|
+
|
|
293
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
294
|
+
await import('../api-proxy-guard.js');
|
|
295
|
+
clearApiProxyGuardLimiterCache();
|
|
296
|
+
|
|
297
|
+
const response = await guardApiProxyRequest(
|
|
298
|
+
makeRequest('/api/test', 'GET'),
|
|
299
|
+
{
|
|
300
|
+
prefixBase: 'proxy:test:api',
|
|
301
|
+
routePolicies: singleReadRoutePolicies(),
|
|
302
|
+
}
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
expect(response).toBeNull();
|
|
306
|
+
expect(mocks.redis).not.toHaveBeenCalled();
|
|
307
|
+
expect(mocks.limit).not.toHaveBeenCalled();
|
|
308
|
+
expect(mocks.validateEmoji).toHaveBeenCalled();
|
|
309
|
+
});
|
|
310
|
+
|
|
281
311
|
it('allows a blocked IP to submit the rate-limit appeal endpoint only', async () => {
|
|
282
312
|
vi.stubEnv('NODE_ENV', 'production');
|
|
283
313
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
@@ -1186,7 +1216,7 @@ describe('guardApiProxyRequest', () => {
|
|
|
1186
1216
|
expect(mocks.validateEmoji).not.toHaveBeenCalled();
|
|
1187
1217
|
});
|
|
1188
1218
|
|
|
1189
|
-
it('
|
|
1219
|
+
it('skips proxy route buckets when the Redis rate limiter is unreachable', async () => {
|
|
1190
1220
|
vi.stubEnv('NODE_ENV', 'production');
|
|
1191
1221
|
vi.stubEnv(
|
|
1192
1222
|
'UPSTASH_REDIS_REST_URL',
|
|
@@ -1196,7 +1226,7 @@ describe('guardApiProxyRequest', () => {
|
|
|
1196
1226
|
mocks.redis.mockReturnValue({});
|
|
1197
1227
|
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
1198
1228
|
mocks.isBlocked.mockResolvedValue(null);
|
|
1199
|
-
mocks.limit.
|
|
1229
|
+
mocks.limit.mockRejectedValue(new TypeError('fetch failed'));
|
|
1200
1230
|
mocks.validateEmoji.mockResolvedValue(null);
|
|
1201
1231
|
|
|
1202
1232
|
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
@@ -1216,22 +1246,17 @@ describe('guardApiProxyRequest', () => {
|
|
|
1216
1246
|
expect(response).toBeNull();
|
|
1217
1247
|
expect(mocks.limit).toHaveBeenCalledTimes(1);
|
|
1218
1248
|
|
|
1219
|
-
const
|
|
1249
|
+
const allowedResponse = await guardApiProxyRequest(
|
|
1220
1250
|
makeRequest('/api/test', 'GET'),
|
|
1221
1251
|
options
|
|
1222
1252
|
);
|
|
1223
1253
|
|
|
1224
|
-
expect(
|
|
1225
|
-
expect(
|
|
1226
|
-
|
|
1227
|
-
);
|
|
1228
|
-
expect(blockedResponse?.headers.get('X-RateLimit-Policy')).toBe(
|
|
1229
|
-
'redis-fallback-test'
|
|
1230
|
-
);
|
|
1231
|
-
expect(mocks.limit).toHaveBeenCalledTimes(1);
|
|
1254
|
+
expect(allowedResponse).toBeNull();
|
|
1255
|
+
expect(mocks.limit).toHaveBeenCalledTimes(2);
|
|
1256
|
+
expect(mocks.validateEmoji).toHaveBeenCalledTimes(2);
|
|
1232
1257
|
});
|
|
1233
1258
|
|
|
1234
|
-
it('
|
|
1259
|
+
it('skips proxy route buckets when Redis client initialization fails', async () => {
|
|
1235
1260
|
vi.stubEnv('NODE_ENV', 'production');
|
|
1236
1261
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
1237
1262
|
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
@@ -1258,16 +1283,14 @@ describe('guardApiProxyRequest', () => {
|
|
|
1258
1283
|
|
|
1259
1284
|
expect(response).toBeNull();
|
|
1260
1285
|
|
|
1261
|
-
const
|
|
1286
|
+
const allowedResponse = await guardApiProxyRequest(
|
|
1262
1287
|
makeRequest('/api/test', 'GET'),
|
|
1263
1288
|
options
|
|
1264
1289
|
);
|
|
1265
1290
|
|
|
1266
|
-
expect(
|
|
1267
|
-
expect(blockedResponse?.headers.get('X-Proxy-Block-Reason')).toBe(
|
|
1268
|
-
'route-rate-limit'
|
|
1269
|
-
);
|
|
1291
|
+
expect(allowedResponse).toBeNull();
|
|
1270
1292
|
expect(mocks.limit).not.toHaveBeenCalled();
|
|
1293
|
+
expect(mocks.validateEmoji).toHaveBeenCalledTimes(2);
|
|
1271
1294
|
});
|
|
1272
1295
|
|
|
1273
1296
|
it('rate limits anonymous users/me reads', async () => {
|
|
@@ -231,8 +231,10 @@ describe('getLocalInternalAppUrl', () => {
|
|
|
231
231
|
['cms', 'https://cms.tuturuuu.localhost'],
|
|
232
232
|
['calendar', 'https://calendar.tuturuuu.localhost'],
|
|
233
233
|
['chat', 'https://chat.tuturuuu.localhost'],
|
|
234
|
+
['docs', 'https://docs.tuturuuu.localhost'],
|
|
234
235
|
['drive', 'https://drive.tuturuuu.localhost'],
|
|
235
236
|
['qr', 'https://qr.tuturuuu.localhost'],
|
|
237
|
+
['tools', 'https://tools.tuturuuu.localhost'],
|
|
236
238
|
['nova', 'https://nova.tuturuuu.localhost'],
|
|
237
239
|
['rewise', 'https://rewise.tuturuuu.localhost'],
|
|
238
240
|
['tasks', 'https://tasks.tuturuuu.localhost'],
|
|
@@ -289,6 +291,8 @@ describe('Portless app origin registry', () => {
|
|
|
289
291
|
'https://calendar.tuturuuu.localhost',
|
|
290
292
|
'https://chat.tuturuuu.localhost',
|
|
291
293
|
'https://cms.tuturuuu.localhost',
|
|
294
|
+
'https://contacts.tuturuuu.localhost',
|
|
295
|
+
'https://docs.tuturuuu.localhost',
|
|
292
296
|
'https://drive.tuturuuu.localhost',
|
|
293
297
|
'https://external.tuturuuu.localhost',
|
|
294
298
|
'https://finance.tuturuuu.localhost',
|
|
@@ -301,10 +305,12 @@ describe('Portless app origin registry', () => {
|
|
|
301
305
|
'https://meet.tuturuuu.localhost',
|
|
302
306
|
'https://mind.tuturuuu.localhost',
|
|
303
307
|
'https://nova.tuturuuu.localhost',
|
|
308
|
+
'https://pay.tuturuuu.localhost',
|
|
304
309
|
'https://tuturuuu.localhost',
|
|
305
310
|
'https://playground.tuturuuu.localhost',
|
|
306
311
|
'https://qr.tuturuuu.localhost',
|
|
307
312
|
'https://rewise.tuturuuu.localhost',
|
|
313
|
+
'https://tools.tuturuuu.localhost',
|
|
308
314
|
'https://shortener.tuturuuu.localhost',
|
|
309
315
|
'https://storefront.tuturuuu.localhost',
|
|
310
316
|
'https://tanstack.tuturuuu.localhost',
|
|
@@ -58,5 +58,26 @@ describe('transformTaskRecord', () => {
|
|
|
58
58
|
expect(transformed.projects).toEqual([
|
|
59
59
|
{ id: 'project-1', name: 'Platform', status: 'active' },
|
|
60
60
|
]);
|
|
61
|
+
expect(transformed).toMatchObject({
|
|
62
|
+
is_personal_external: false,
|
|
63
|
+
is_personal_external_default: false,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('preserves explicit personal external flags from API responses', () => {
|
|
68
|
+
const transformed = transformTaskRecord({
|
|
69
|
+
id: 'task-1',
|
|
70
|
+
name: 'Task',
|
|
71
|
+
list_id: 'list-1',
|
|
72
|
+
display_number: 1,
|
|
73
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
74
|
+
is_personal_external: true,
|
|
75
|
+
is_personal_external_default: true,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(transformed).toMatchObject({
|
|
79
|
+
is_personal_external: true,
|
|
80
|
+
is_personal_external_default: true,
|
|
81
|
+
});
|
|
61
82
|
});
|
|
62
83
|
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
describe('abuse protection Redis fail-open behavior', () => {
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.doUnmock('../../upstash-rest');
|
|
6
|
+
vi.doUnmock('@tuturuuu/supabase/next/server');
|
|
7
|
+
vi.resetModules();
|
|
8
|
+
vi.restoreAllMocks();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('allows checks and skips persistent IP block lookups when Redis is not configured', async () => {
|
|
12
|
+
const from = vi.fn(() => ({
|
|
13
|
+
insert: vi.fn(async () => ({ error: null })),
|
|
14
|
+
}));
|
|
15
|
+
const rpc = vi.fn();
|
|
16
|
+
const createAdminClient = vi.fn(async () => ({ from, rpc }));
|
|
17
|
+
const getRedis = vi.fn();
|
|
18
|
+
|
|
19
|
+
vi.doMock('../../upstash-rest', () => ({
|
|
20
|
+
getUpstashRestRedisClient: getRedis,
|
|
21
|
+
hasUpstashRestEnv: () => false,
|
|
22
|
+
}));
|
|
23
|
+
vi.doMock('@tuturuuu/supabase/next/server', () => ({
|
|
24
|
+
createAdminClient,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const { checkPasswordLoginLimit, isIPBlocked, recordPasswordLoginFailure } =
|
|
28
|
+
await import('../index.js');
|
|
29
|
+
|
|
30
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
31
|
+
await recordPasswordLoginFailure('203.0.113.80', 'user@example.com');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await expect(isIPBlocked('203.0.113.80')).resolves.toBeNull();
|
|
35
|
+
await expect(
|
|
36
|
+
checkPasswordLoginLimit('203.0.113.80', 'user@example.com')
|
|
37
|
+
).resolves.toMatchObject({
|
|
38
|
+
allowed: true,
|
|
39
|
+
});
|
|
40
|
+
expect(getRedis).not.toHaveBeenCalled();
|
|
41
|
+
expect(rpc).not.toHaveBeenCalledWith('get_active_ip_block', {
|
|
42
|
+
p_ip_address: '203.0.113.80',
|
|
43
|
+
});
|
|
44
|
+
expect(from).not.toHaveBeenCalledWith('blocked_ips');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('allows checks and skips counter writes after Redis commands fail', async () => {
|
|
48
|
+
const from = vi.fn(() => ({
|
|
49
|
+
insert: vi.fn(async () => ({ error: null })),
|
|
50
|
+
}));
|
|
51
|
+
const rpc = vi.fn();
|
|
52
|
+
const createAdminClient = vi.fn(async () => ({ from, rpc }));
|
|
53
|
+
const redis = {
|
|
54
|
+
del: vi.fn(),
|
|
55
|
+
expire: vi.fn(),
|
|
56
|
+
get: vi.fn(async () => {
|
|
57
|
+
throw new Error('redis unavailable');
|
|
58
|
+
}),
|
|
59
|
+
incr: vi.fn(async () => {
|
|
60
|
+
throw new Error('redis unavailable');
|
|
61
|
+
}),
|
|
62
|
+
set: vi.fn(),
|
|
63
|
+
ttl: vi.fn(),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
vi.doMock('../../upstash-rest', () => ({
|
|
67
|
+
getUpstashRestRedisClient: async () => redis,
|
|
68
|
+
hasUpstashRestEnv: () => true,
|
|
69
|
+
}));
|
|
70
|
+
vi.doMock('@tuturuuu/supabase/next/server', () => ({
|
|
71
|
+
createAdminClient,
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
const { checkPasswordLoginLimit, isIPBlocked, recordPasswordLoginFailure } =
|
|
75
|
+
await import('../index.js');
|
|
76
|
+
|
|
77
|
+
await expect(isIPBlocked('203.0.113.81')).resolves.toBeNull();
|
|
78
|
+
await expect(
|
|
79
|
+
checkPasswordLoginLimit('203.0.113.81', 'user@example.com')
|
|
80
|
+
).resolves.toMatchObject({
|
|
81
|
+
allowed: true,
|
|
82
|
+
});
|
|
83
|
+
await expect(
|
|
84
|
+
recordPasswordLoginFailure('203.0.113.81', 'user@example.com')
|
|
85
|
+
).resolves.toBeUndefined();
|
|
86
|
+
expect(redis.expire).not.toHaveBeenCalled();
|
|
87
|
+
expect(redis.set).not.toHaveBeenCalled();
|
|
88
|
+
expect(rpc).not.toHaveBeenCalledWith('get_active_ip_block', {
|
|
89
|
+
p_ip_address: '203.0.113.81',
|
|
90
|
+
});
|
|
91
|
+
expect(from).not.toHaveBeenCalledWith('blocked_ips');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -2,7 +2,76 @@
|
|
|
2
2
|
* Unit tests for OTP Abuse Protection System
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
|
|
7
|
+
const mocks = vi.hoisted(() => {
|
|
8
|
+
const store = new Map<string, { expiresAt: number | null; value: unknown }>();
|
|
9
|
+
|
|
10
|
+
function getEntry(key: string) {
|
|
11
|
+
const entry = store.get(key);
|
|
12
|
+
if (!entry) return null;
|
|
13
|
+
|
|
14
|
+
if (entry.expiresAt != null && entry.expiresAt <= Date.now()) {
|
|
15
|
+
store.delete(key);
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return entry;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
getRedis: vi.fn(),
|
|
24
|
+
redis: {
|
|
25
|
+
del: vi.fn(async (...keys: string[]) => {
|
|
26
|
+
let deleted = 0;
|
|
27
|
+
for (const key of keys) {
|
|
28
|
+
if (store.delete(key)) deleted += 1;
|
|
29
|
+
}
|
|
30
|
+
return deleted;
|
|
31
|
+
}),
|
|
32
|
+
expire: vi.fn(async (key: string, seconds: number) => {
|
|
33
|
+
const entry = getEntry(key);
|
|
34
|
+
if (!entry) return 0;
|
|
35
|
+
entry.expiresAt = Date.now() + seconds * 1000;
|
|
36
|
+
return 1;
|
|
37
|
+
}),
|
|
38
|
+
get: vi.fn(async <T = unknown>(key: string): Promise<T | null> => {
|
|
39
|
+
return (getEntry(key)?.value as T | undefined) ?? null;
|
|
40
|
+
}),
|
|
41
|
+
incr: vi.fn(async (key: string) => {
|
|
42
|
+
const entry = getEntry(key);
|
|
43
|
+
const nextValue =
|
|
44
|
+
typeof entry?.value === 'number' ? entry.value + 1 : 1;
|
|
45
|
+
store.set(key, {
|
|
46
|
+
expiresAt: entry?.expiresAt ?? null,
|
|
47
|
+
value: nextValue,
|
|
48
|
+
});
|
|
49
|
+
return nextValue;
|
|
50
|
+
}),
|
|
51
|
+
set: vi.fn(
|
|
52
|
+
async (key: string, value: unknown, options?: { ex?: number }) => {
|
|
53
|
+
store.set(key, {
|
|
54
|
+
expiresAt: options?.ex ? Date.now() + options.ex * 1000 : null,
|
|
55
|
+
value,
|
|
56
|
+
});
|
|
57
|
+
return 'OK';
|
|
58
|
+
}
|
|
59
|
+
),
|
|
60
|
+
ttl: vi.fn(async (key: string) => {
|
|
61
|
+
const entry = getEntry(key);
|
|
62
|
+
if (!entry) return -2;
|
|
63
|
+
if (entry.expiresAt == null) return -1;
|
|
64
|
+
return Math.ceil((entry.expiresAt - Date.now()) / 1000);
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
67
|
+
store,
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
vi.mock('../../upstash-rest', () => ({
|
|
72
|
+
getUpstashRestRedisClient: () => mocks.getRedis(),
|
|
73
|
+
hasUpstashRestEnv: () => true,
|
|
74
|
+
}));
|
|
6
75
|
|
|
7
76
|
import {
|
|
8
77
|
ABUSE_THRESHOLDS,
|
|
@@ -29,6 +98,12 @@ import {
|
|
|
29
98
|
} from '../index';
|
|
30
99
|
|
|
31
100
|
describe('abuse-protection', () => {
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
vi.clearAllMocks();
|
|
103
|
+
mocks.store.clear();
|
|
104
|
+
mocks.getRedis.mockResolvedValue(mocks.redis);
|
|
105
|
+
});
|
|
106
|
+
|
|
32
107
|
afterEach(() => {
|
|
33
108
|
vi.useRealTimers();
|
|
34
109
|
vi.unstubAllEnvs();
|