@edge-base/server 0.1.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.
- package/admin-build/.gitkeep +0 -0
- package/admin-build/_app/env.js +1 -0
- package/admin-build/_app/immutable/assets/0.Bm6cF078.css +1 -0
- package/admin-build/_app/immutable/assets/1.BfW3pUNa.css +1 -0
- package/admin-build/_app/immutable/assets/11.CVmQOewb.css +1 -0
- package/admin-build/_app/immutable/assets/12.B1EhbRZT.css +1 -0
- package/admin-build/_app/immutable/assets/13.BvwYeuwE.css +1 -0
- package/admin-build/_app/immutable/assets/14.CdVfcO0R.css +1 -0
- package/admin-build/_app/immutable/assets/15.2yeZ66b-.css +1 -0
- package/admin-build/_app/immutable/assets/17.BVg0JEVu.css +1 -0
- package/admin-build/_app/immutable/assets/18.Rwnl3x_i.css +1 -0
- package/admin-build/_app/immutable/assets/20.DsPWA9AV.css +1 -0
- package/admin-build/_app/immutable/assets/21.Dz2RJ56c.css +1 -0
- package/admin-build/_app/immutable/assets/22.DwNLk5Ai.css +1 -0
- package/admin-build/_app/immutable/assets/23.CFpu0gOO.css +1 -0
- package/admin-build/_app/immutable/assets/24.Cy5LBeoJ.css +1 -0
- package/admin-build/_app/immutable/assets/25.pUyLVf-h.css +1 -0
- package/admin-build/_app/immutable/assets/26.DBcGrlXa.css +1 -0
- package/admin-build/_app/immutable/assets/27.BswYyAJD.css +1 -0
- package/admin-build/_app/immutable/assets/28.B4ueB1Kf.css +1 -0
- package/admin-build/_app/immutable/assets/29.B-qU6PdF.css +1 -0
- package/admin-build/_app/immutable/assets/3.Dg81Pgmd.css +1 -0
- package/admin-build/_app/immutable/assets/30.CsdWum94.css +1 -0
- package/admin-build/_app/immutable/assets/31.U6OwIp50.css +1 -0
- package/admin-build/_app/immutable/assets/4.CyawCCux.css +1 -0
- package/admin-build/_app/immutable/assets/5.C0YO2HTk.css +1 -0
- package/admin-build/_app/immutable/assets/8.Br5jd6kD.css +1 -0
- package/admin-build/_app/immutable/assets/Badge.EMYLHBxE.css +1 -0
- package/admin-build/_app/immutable/assets/Button.DpzMRTjK.css +1 -0
- package/admin-build/_app/immutable/assets/ConfirmDialog.DAnaWRRk.css +1 -0
- package/admin-build/_app/immutable/assets/EmptyState.CwKsu57Y.css +1 -0
- package/admin-build/_app/immutable/assets/Input.BDUSenmU.css +1 -0
- package/admin-build/_app/immutable/assets/Modal.Dm5B0Xie.css +1 -0
- package/admin-build/_app/immutable/assets/PageShell.CmU-Xh-b.css +1 -0
- package/admin-build/_app/immutable/assets/SchemaFieldEditor.g4NsCdno.css +1 -0
- package/admin-build/_app/immutable/assets/Select.BW4Keufm.css +1 -0
- package/admin-build/_app/immutable/assets/Skeleton.KWUulTKJ.css +1 -0
- package/admin-build/_app/immutable/assets/Tabs.CniGYb67.css +1 -0
- package/admin-build/_app/immutable/assets/TimeChart.BTCDAvmT.css +1 -0
- package/admin-build/_app/immutable/assets/Toggle.Cy_K12OM.css +1 -0
- package/admin-build/_app/immutable/assets/TopList.ClFzmPlA.css +1 -0
- package/admin-build/_app/immutable/chunks/7B47DvSx.js +1 -0
- package/admin-build/_app/immutable/chunks/7f08Id8e.js +1 -0
- package/admin-build/_app/immutable/chunks/8wJeQ7LN.js +1 -0
- package/admin-build/_app/immutable/chunks/B-h2afW5.js +1 -0
- package/admin-build/_app/immutable/chunks/B8vJP3wz.js +1 -0
- package/admin-build/_app/immutable/chunks/BR_fL5Yv.js +1 -0
- package/admin-build/_app/immutable/chunks/BY92tFS2.js +1 -0
- package/admin-build/_app/immutable/chunks/BcR-Rdj9.js +1 -0
- package/admin-build/_app/immutable/chunks/BdrwyZv8.js +1 -0
- package/admin-build/_app/immutable/chunks/Bh56EfQ_.js +1 -0
- package/admin-build/_app/immutable/chunks/BkrCkgYp.js +1 -0
- package/admin-build/_app/immutable/chunks/BmRjiP5k.js +1 -0
- package/admin-build/_app/immutable/chunks/BsokvhWC.js +1 -0
- package/admin-build/_app/immutable/chunks/C4D51vTW.js +1 -0
- package/admin-build/_app/immutable/chunks/C6puvcoR.js +2 -0
- package/admin-build/_app/immutable/chunks/CCKNu7m7.js +1 -0
- package/admin-build/_app/immutable/chunks/CWj6FrbW.js +1 -0
- package/admin-build/_app/immutable/chunks/Ce-ngf4p.js +5 -0
- package/admin-build/_app/immutable/chunks/Cs0GwzJA.js +1 -0
- package/admin-build/_app/immutable/chunks/CwROoZK0.js +1 -0
- package/admin-build/_app/immutable/chunks/CxCPv_Ut.js +1 -0
- package/admin-build/_app/immutable/chunks/CxbRue-5.js +1 -0
- package/admin-build/_app/immutable/chunks/CyqB6g-D.js +1 -0
- package/admin-build/_app/immutable/chunks/D5h5A1cc.js +2 -0
- package/admin-build/_app/immutable/chunks/DnyL7Zq-.js +1 -0
- package/admin-build/_app/immutable/chunks/DoPXzH7F.js +1 -0
- package/admin-build/_app/immutable/chunks/DrQSgw-f.js +1 -0
- package/admin-build/_app/immutable/chunks/DttM2zNO.js +1 -0
- package/admin-build/_app/immutable/chunks/DuXuUBWN.js +1 -0
- package/admin-build/_app/immutable/chunks/MdeqaOQx.js +10 -0
- package/admin-build/_app/immutable/chunks/NuUjtcO2.js +1 -0
- package/admin-build/_app/immutable/chunks/Q2nPFxS6.js +1 -0
- package/admin-build/_app/immutable/chunks/R6arueIl.js +1 -0
- package/admin-build/_app/immutable/chunks/UUazaC_N.js +1 -0
- package/admin-build/_app/immutable/chunks/cOYbrQxx.js +1 -0
- package/admin-build/_app/immutable/chunks/eFQHTGwA.js +1 -0
- package/admin-build/_app/immutable/chunks/ehbppgYb.js +1 -0
- package/admin-build/_app/immutable/chunks/glwixJlP.js +1 -0
- package/admin-build/_app/immutable/chunks/vApWTCBs.js +1 -0
- package/admin-build/_app/immutable/chunks/w89G9Xpi.js +1 -0
- package/admin-build/_app/immutable/chunks/wJsUhbfZ.js +1 -0
- package/admin-build/_app/immutable/chunks/zfauFM8P.js +1 -0
- package/admin-build/_app/immutable/entry/app.CcO-Uos3.js +2 -0
- package/admin-build/_app/immutable/entry/start.COebYq3I.js +1 -0
- package/admin-build/_app/immutable/nodes/0.CjtHKU-6.js +1 -0
- package/admin-build/_app/immutable/nodes/1.DEisjlM0.js +1 -0
- package/admin-build/_app/immutable/nodes/10.CvhdyWVB.js +1 -0
- package/admin-build/_app/immutable/nodes/11.DjHqcOvy.js +1 -0
- package/admin-build/_app/immutable/nodes/12.mQLz4Mj_.js +1 -0
- package/admin-build/_app/immutable/nodes/13.CBonZZyP.js +110 -0
- package/admin-build/_app/immutable/nodes/14.d-oiZL0j.js +3 -0
- package/admin-build/_app/immutable/nodes/15.CKPQsUYF.js +1 -0
- package/admin-build/_app/immutable/nodes/16.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/17.DayhKyEZ.js +1 -0
- package/admin-build/_app/immutable/nodes/18.DKwS0Ir0.js +1 -0
- package/admin-build/_app/immutable/nodes/19.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/2.BKoKrw1i.js +1 -0
- package/admin-build/_app/immutable/nodes/20.BvIkkkrW.js +1 -0
- package/admin-build/_app/immutable/nodes/21.DMaFhdHk.js +128 -0
- package/admin-build/_app/immutable/nodes/22.3xdgwuK1.js +1 -0
- package/admin-build/_app/immutable/nodes/23.8Bvgjbsl.js +112 -0
- package/admin-build/_app/immutable/nodes/24.DzSSzRhG.js +2 -0
- package/admin-build/_app/immutable/nodes/25.9KKYBnAE.js +2 -0
- package/admin-build/_app/immutable/nodes/26.Bhn9dfhY.js +1 -0
- package/admin-build/_app/immutable/nodes/27.kRLiC24G.js +1 -0
- package/admin-build/_app/immutable/nodes/28.BVIN1-7N.js +1 -0
- package/admin-build/_app/immutable/nodes/29.3yabZWj4.js +1 -0
- package/admin-build/_app/immutable/nodes/3.BFtSOkX7.js +2 -0
- package/admin-build/_app/immutable/nodes/30.CyCQlwaP.js +1 -0
- package/admin-build/_app/immutable/nodes/31.C4LDXjES.js +1 -0
- package/admin-build/_app/immutable/nodes/4.CvbiMlCa.js +1 -0
- package/admin-build/_app/immutable/nodes/5.C6BLv2eM.js +1 -0
- package/admin-build/_app/immutable/nodes/6.BcXvfl2P.js +1 -0
- package/admin-build/_app/immutable/nodes/7.CIuqhPiK.js +1 -0
- package/admin-build/_app/immutable/nodes/8.BQOR_JfO.js +1 -0
- package/admin-build/_app/immutable/nodes/9.NZqXQxPy.js +1 -0
- package/admin-build/_app/version.json +1 -0
- package/admin-build/favicon.svg +26 -0
- package/admin-build/index.html +45 -0
- package/openapi.json +19543 -0
- package/package.json +66 -0
- package/src/__tests__/admin-assets.test.ts +55 -0
- package/src/__tests__/admin-data-routes.test.ts +488 -0
- package/src/__tests__/admin-db-target.test.ts +103 -0
- package/src/__tests__/admin-routing.test.ts +31 -0
- package/src/__tests__/admin-user-management.test.ts +311 -0
- package/src/__tests__/analytics-query.test.ts +75 -0
- package/src/__tests__/auth-d1.test.ts +749 -0
- package/src/__tests__/auth-db-adapter.test.ts +73 -0
- package/src/__tests__/auth-jwt.test.ts +440 -0
- package/src/__tests__/auth-oauth.test.ts +389 -0
- package/src/__tests__/auth-password.test.ts +367 -0
- package/src/__tests__/auth-redirect.test.ts +87 -0
- package/src/__tests__/backup-restore.test.ts +711 -0
- package/src/__tests__/broadcast.test.ts +128 -0
- package/src/__tests__/cli.test.ts +178 -0
- package/src/__tests__/cloudflare-realtime.test.ts +113 -0
- package/src/__tests__/config.test.ts +469 -0
- package/src/__tests__/cors.test.ts +154 -0
- package/src/__tests__/cron.test.ts +302 -0
- package/src/__tests__/d1-handler.test.ts +402 -0
- package/src/__tests__/d1-sql.test.ts +120 -0
- package/src/__tests__/database-live-config.test.ts +42 -0
- package/src/__tests__/database-live-emitter.test.ts +56 -0
- package/src/__tests__/database-live-filters.test.ts +63 -0
- package/src/__tests__/database-live-route.test.ts +113 -0
- package/src/__tests__/db-sql.test.ts +163 -0
- package/src/__tests__/do-lifecycle.test.ts +263 -0
- package/src/__tests__/do-router.test.ts +729 -0
- package/src/__tests__/email-provider.test.ts +128 -0
- package/src/__tests__/email-templates.test.ts +528 -0
- package/src/__tests__/error-format.test.ts +250 -0
- package/src/__tests__/field-ops.test.ts +242 -0
- package/src/__tests__/functions-context.test.ts +334 -0
- package/src/__tests__/functions-d1-proxy.test.ts +229 -0
- package/src/__tests__/functions-registry-runtime-config.test.ts +17 -0
- package/src/__tests__/functions-route.test.ts +139 -0
- package/src/__tests__/internal-request.test.ts +77 -0
- package/src/__tests__/log-writer.test.ts +44 -0
- package/src/__tests__/logger.test.ts +58 -0
- package/src/__tests__/meta-admin-proxy.test.ts +48 -0
- package/src/__tests__/meta-export-coverage.test.ts +191 -0
- package/src/__tests__/meta-route-registration.test.ts +47 -0
- package/src/__tests__/namespace-dump.test.ts +28 -0
- package/src/__tests__/oauth-providers.test.ts +337 -0
- package/src/__tests__/openapi-coverage.test.ts +144 -0
- package/src/__tests__/pagination.test.ts +59 -0
- package/src/__tests__/password-policy.test.ts +191 -0
- package/src/__tests__/plugin-migrations.test.ts +379 -0
- package/src/__tests__/postgres-batch-compat.test.ts +133 -0
- package/src/__tests__/postgres-dialect.test.ts +328 -0
- package/src/__tests__/postgres-executor.test.ts +79 -0
- package/src/__tests__/postgres-field-ops-compat.test.ts +222 -0
- package/src/__tests__/postgres-schema-init.test.ts +105 -0
- package/src/__tests__/postgres-table-utils.test.ts +107 -0
- package/src/__tests__/presence.test.ts +199 -0
- package/src/__tests__/provider.test.ts +550 -0
- package/src/__tests__/public-user-profile.test.ts +339 -0
- package/src/__tests__/push-handlers.test.ts +179 -0
- package/src/__tests__/push-provider.test.ts +80 -0
- package/src/__tests__/push-token.test.ts +418 -0
- package/src/__tests__/query.test.ts +771 -0
- package/src/__tests__/rate-limit.test.ts +260 -0
- package/src/__tests__/room-access-policy.test.ts +101 -0
- package/src/__tests__/room-handler-context.test.ts +130 -0
- package/src/__tests__/room-monitoring.test.ts +138 -0
- package/src/__tests__/room-runtime-routing.test.ts +222 -0
- package/src/__tests__/room.test.ts +254 -0
- package/src/__tests__/route-parser.test.ts +490 -0
- package/src/__tests__/rules.test.ts +234 -0
- package/src/__tests__/runtime-surface-accounting.test.ts +120 -0
- package/src/__tests__/scheduled.test.ts +80 -0
- package/src/__tests__/schema.test.ts +1273 -0
- package/src/__tests__/security-hardening.test.ts +312 -0
- package/src/__tests__/server.unit.test.ts +333 -0
- package/src/__tests__/service-key-db-proxy.test.ts +650 -0
- package/src/__tests__/service-key-provider-bypass.test.ts +138 -0
- package/src/__tests__/service-key.test.ts +757 -0
- package/src/__tests__/smoke-skip-report.test.ts +72 -0
- package/src/__tests__/sms-provider.test.ts +39 -0
- package/src/__tests__/sql-route.test.ts +218 -0
- package/src/__tests__/storage-hook-context.test.ts +115 -0
- package/src/__tests__/totp.test.ts +200 -0
- package/src/__tests__/uuid.test.ts +144 -0
- package/src/__tests__/validation.test.ts +773 -0
- package/src/__tests__/websocket-pending.test.ts +163 -0
- package/src/_functions-registry.ts +51 -0
- package/src/bench-entry.ts +9 -0
- package/src/cloudflare-test.d.ts +1 -0
- package/src/durable-objects/auth-do.ts +49 -0
- package/src/durable-objects/database-do.ts +2240 -0
- package/src/durable-objects/database-live-do.ts +949 -0
- package/src/durable-objects/logs-do.ts +1200 -0
- package/src/durable-objects/room-runtime-base.ts +1604 -0
- package/src/durable-objects/rooms-do.ts +2191 -0
- package/src/generated-config.ts +6 -0
- package/src/index.ts +382 -0
- package/src/lib/admin-assets.ts +54 -0
- package/src/lib/admin-db-target.ts +301 -0
- package/src/lib/admin-routing.ts +35 -0
- package/src/lib/admin-user-management.ts +464 -0
- package/src/lib/analytics-adapter.ts +103 -0
- package/src/lib/analytics-query.ts +579 -0
- package/src/lib/auth-d1-service.ts +1193 -0
- package/src/lib/auth-d1.ts +1056 -0
- package/src/lib/auth-db-adapter.ts +289 -0
- package/src/lib/auth-redirect.ts +116 -0
- package/src/lib/cidr.ts +115 -0
- package/src/lib/client-ip.ts +51 -0
- package/src/lib/cloudflare-realtime.ts +251 -0
- package/src/lib/control-db.ts +36 -0
- package/src/lib/cron.ts +163 -0
- package/src/lib/d1-handler.ts +1425 -0
- package/src/lib/d1-schema-init.ts +255 -0
- package/src/lib/d1-sql.ts +33 -0
- package/src/lib/database-live-config.ts +24 -0
- package/src/lib/database-live-emitter.ts +111 -0
- package/src/lib/db-sql.ts +66 -0
- package/src/lib/do-retry.ts +36 -0
- package/src/lib/do-router.ts +270 -0
- package/src/lib/do-sql.ts +73 -0
- package/src/lib/email-provider.ts +379 -0
- package/src/lib/email-templates.ts +285 -0
- package/src/lib/email-translations.ts +422 -0
- package/src/lib/errors.ts +151 -0
- package/src/lib/functions.ts +2091 -0
- package/src/lib/hono.ts +56 -0
- package/src/lib/internal-request.ts +56 -0
- package/src/lib/jwt.ts +354 -0
- package/src/lib/log-writer.ts +272 -0
- package/src/lib/namespace-dump.ts +125 -0
- package/src/lib/oauth-providers.ts +1225 -0
- package/src/lib/op-parser.ts +99 -0
- package/src/lib/openapi.ts +146 -0
- package/src/lib/pagination.ts +19 -0
- package/src/lib/password-policy.ts +102 -0
- package/src/lib/password.ts +145 -0
- package/src/lib/plugin-migrations.ts +612 -0
- package/src/lib/postgres-executor.ts +203 -0
- package/src/lib/postgres-handler.ts +1102 -0
- package/src/lib/postgres-schema-init.ts +341 -0
- package/src/lib/postgres-table-utils.ts +87 -0
- package/src/lib/public-user-profile.ts +187 -0
- package/src/lib/push-provider.ts +409 -0
- package/src/lib/push-token.ts +294 -0
- package/src/lib/query-engine.ts +768 -0
- package/src/lib/room-monitoring.ts +97 -0
- package/src/lib/room-runtime.ts +14 -0
- package/src/lib/route-parser.ts +434 -0
- package/src/lib/schema.ts +538 -0
- package/src/lib/schemas.ts +152 -0
- package/src/lib/service-key.ts +419 -0
- package/src/lib/sms-provider.ts +230 -0
- package/src/lib/startup-config.ts +99 -0
- package/src/lib/totp.ts +242 -0
- package/src/lib/uuid.ts +87 -0
- package/src/lib/validation.ts +205 -0
- package/src/lib/version.ts +2 -0
- package/src/lib/websocket-pending.ts +40 -0
- package/src/middleware/auth.ts +169 -0
- package/src/middleware/captcha-verify.ts +217 -0
- package/src/middleware/cors.ts +159 -0
- package/src/middleware/error-handler.ts +54 -0
- package/src/middleware/internal-guard.ts +26 -0
- package/src/middleware/logger.ts +126 -0
- package/src/middleware/rate-limit.ts +283 -0
- package/src/middleware/rules.ts +475 -0
- package/src/routes/admin-auth.ts +447 -0
- package/src/routes/admin.ts +3501 -0
- package/src/routes/analytics-api.ts +290 -0
- package/src/routes/auth.ts +4222 -0
- package/src/routes/backup.ts +1466 -0
- package/src/routes/config.ts +53 -0
- package/src/routes/d1.ts +109 -0
- package/src/routes/database-live.ts +281 -0
- package/src/routes/functions.ts +155 -0
- package/src/routes/health.ts +32 -0
- package/src/routes/kv.ts +167 -0
- package/src/routes/oauth.ts +1055 -0
- package/src/routes/push.ts +1465 -0
- package/src/routes/room.ts +639 -0
- package/src/routes/schema-endpoint.ts +76 -0
- package/src/routes/sql.ts +176 -0
- package/src/routes/storage.ts +1674 -0
- package/src/routes/tables.ts +699 -0
- package/src/routes/users.ts +21 -0
- package/src/routes/vectorize.ts +372 -0
- package/src/types.ts +99 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security hardening tests — validates all fixes from security audit.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* 1. Sort direction allowlist (SQL injection defense)
|
|
6
|
+
* 2. Query limit cap (DoS prevention)
|
|
7
|
+
* 3. parseDuration max value (jwt.ts + storage.ts)
|
|
8
|
+
* 4. evalStringRule / evalStorageStringRule fail-closed
|
|
9
|
+
* 5. Service Key constraint fail-closed (context missing → deny)
|
|
10
|
+
* 6. send-many userIds array size limit
|
|
11
|
+
* 7. SQL identifier escaping in hook context / backup
|
|
12
|
+
* 8. X-Content-Type-Options header
|
|
13
|
+
* 9. HMAC timing-safe comparison (signed URL)
|
|
14
|
+
* 10. Phone OTP production exposure guard
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from 'vitest';
|
|
18
|
+
import { parseQueryParams, buildListQuery } from '../lib/query-engine.js';
|
|
19
|
+
import { parseDuration } from '../lib/jwt.js';
|
|
20
|
+
import { parseDuration as parseStorageDuration } from '../routes/storage.js';
|
|
21
|
+
import { timingSafeEqual } from '../lib/service-key.js';
|
|
22
|
+
import { validateScopedKey } from '../lib/service-key.js';
|
|
23
|
+
import type { ServiceKeyEntry } from '@edge-base/shared';
|
|
24
|
+
|
|
25
|
+
// ─── Helpers ───
|
|
26
|
+
|
|
27
|
+
function makeEntry(overrides: Partial<ServiceKeyEntry> & { kid: string }): ServiceKeyEntry {
|
|
28
|
+
return {
|
|
29
|
+
tier: 'root',
|
|
30
|
+
scopes: ['*'],
|
|
31
|
+
secretSource: 'inline',
|
|
32
|
+
inlineSecret: 'test-secret',
|
|
33
|
+
...overrides,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── 1. Sort direction allowlist ─────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
describe('sort direction allowlist', () => {
|
|
40
|
+
it('accepts ASC (case-insensitive)', () => {
|
|
41
|
+
const opts = parseQueryParams({ sort: 'name:asc' });
|
|
42
|
+
expect(opts.sort![0].direction).toBe('asc');
|
|
43
|
+
// buildListQuery should produce safe SQL
|
|
44
|
+
const { sql } = buildListQuery('posts', opts);
|
|
45
|
+
expect(sql).toContain('ASC');
|
|
46
|
+
expect(sql).not.toContain(';');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('accepts DESC (case-insensitive)', () => {
|
|
50
|
+
const opts = parseQueryParams({ sort: 'name:desc' });
|
|
51
|
+
const { sql } = buildListQuery('posts', opts);
|
|
52
|
+
expect(sql).toContain('DESC');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('rejects malicious direction — falls back to ASC', () => {
|
|
56
|
+
const opts = parseQueryParams({ sort: 'name:ASC;DROP TABLE posts--' });
|
|
57
|
+
const { sql } = buildListQuery('posts', opts);
|
|
58
|
+
// Should NOT contain the injected SQL
|
|
59
|
+
expect(sql).not.toContain('DROP');
|
|
60
|
+
expect(sql).not.toContain(';');
|
|
61
|
+
// Should fall back to ASC
|
|
62
|
+
expect(sql).toContain('ASC');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('empty direction defaults to ASC', () => {
|
|
66
|
+
const opts = parseQueryParams({ sort: 'name' });
|
|
67
|
+
const { sql } = buildListQuery('posts', opts);
|
|
68
|
+
expect(sql).toContain('ASC');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ─── 2. Query limit cap ─────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
describe('query limit cap', () => {
|
|
75
|
+
it('normal limit passes through', () => {
|
|
76
|
+
const opts = parseQueryParams({ limit: '50' });
|
|
77
|
+
expect(opts.pagination!.limit).toBe(50);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('limit capped at 1000', () => {
|
|
81
|
+
const opts = parseQueryParams({ limit: '999999' });
|
|
82
|
+
expect(opts.pagination!.limit).toBe(1000);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('limit of exactly 1000 passes', () => {
|
|
86
|
+
const opts = parseQueryParams({ limit: '1000' });
|
|
87
|
+
expect(opts.pagination!.limit).toBe(1000);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('limit of 1001 capped to 1000', () => {
|
|
91
|
+
const opts = parseQueryParams({ limit: '1001' });
|
|
92
|
+
expect(opts.pagination!.limit).toBe(1000);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ─── 3. parseDuration max value ──────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
describe('parseDuration (jwt.ts) — max value', () => {
|
|
99
|
+
it('365d is allowed (max boundary)', () => {
|
|
100
|
+
expect(parseDuration('365d')).toBe(365 * 86400);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('366d exceeds max → throws', () => {
|
|
104
|
+
expect(() => parseDuration('366d')).toThrow('exceeds maximum');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('99999d exceeds max → throws', () => {
|
|
108
|
+
expect(() => parseDuration('99999d')).toThrow('exceeds maximum');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('8761h exceeds 365d equivalent → throws', () => {
|
|
112
|
+
// 365 * 24 + 1 = 8761 hours > 365 days
|
|
113
|
+
expect(() => parseDuration('8761h')).toThrow('exceeds maximum');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('normal values still work', () => {
|
|
117
|
+
expect(parseDuration('15m')).toBe(900);
|
|
118
|
+
expect(parseDuration('1h')).toBe(3600);
|
|
119
|
+
expect(parseDuration('7d')).toBe(604800);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('parseDuration (storage.ts) — max value', () => {
|
|
124
|
+
it('caps at 7 days (ms)', () => {
|
|
125
|
+
const sevenDaysMs = 7 * 86400 * 1000;
|
|
126
|
+
// 30d should be capped to 7d
|
|
127
|
+
expect(parseStorageDuration('30d')).toBe(sevenDaysMs);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('7d returns exactly 7 days', () => {
|
|
131
|
+
expect(parseStorageDuration('7d')).toBe(7 * 86400 * 1000);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('1h returns 1h (within limit)', () => {
|
|
135
|
+
expect(parseStorageDuration('1h')).toBe(3600 * 1000);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ─── 4. evalStringRule / evalStorageStringRule fail-closed ────────────────────
|
|
140
|
+
// These are internal functions, tested indirectly via normalizeRule behavior.
|
|
141
|
+
// The rules.test.ts already tests that string rules → deny by default.
|
|
142
|
+
// Here we verify the specific pattern from the security audit.
|
|
143
|
+
|
|
144
|
+
describe('string rule fail-closed (via evaluateRule pattern)', () => {
|
|
145
|
+
// Simulates what evalStringRule does internally
|
|
146
|
+
function evalStringRule(
|
|
147
|
+
expr: string,
|
|
148
|
+
auth: { id: string } | null,
|
|
149
|
+
resource?: Record<string, unknown>,
|
|
150
|
+
): boolean {
|
|
151
|
+
const e = expr.trim().replace(/\s+/g, ' ');
|
|
152
|
+
if (e === 'true') return true;
|
|
153
|
+
if (e === 'false') return false;
|
|
154
|
+
if (e === 'auth != null' || e === 'auth !== null') return auth !== null;
|
|
155
|
+
if (e === 'auth == null' || e === 'auth === null') return auth === null;
|
|
156
|
+
const authIdEqResource = /^auth\.id ===? resource\.(\w+)$/.exec(e);
|
|
157
|
+
if (authIdEqResource) {
|
|
158
|
+
const field = authIdEqResource[1];
|
|
159
|
+
return auth !== null && resource !== undefined && auth.id === resource[field];
|
|
160
|
+
}
|
|
161
|
+
// CRITICAL: must be false (fail-closed), not true (fail-open)
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
it('recognized: "true" → allow', () => {
|
|
166
|
+
expect(evalStringRule('true', null)).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('recognized: "false" → deny', () => {
|
|
170
|
+
expect(evalStringRule('false', null)).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('recognized: "auth != null" with auth → allow', () => {
|
|
174
|
+
expect(evalStringRule('auth != null', { id: 'u1' })).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('recognized: "auth != null" without auth → deny', () => {
|
|
178
|
+
expect(evalStringRule('auth != null', null)).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('recognized: "auth.id == resource.authorId" — match', () => {
|
|
182
|
+
expect(evalStringRule('auth.id == resource.authorId', { id: 'u1' }, { authorId: 'u1' })).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('recognized: "auth.id == resource.authorId" — mismatch', () => {
|
|
186
|
+
expect(evalStringRule('auth.id == resource.authorId', { id: 'u1' }, { authorId: 'u2' })).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('CRITICAL: unrecognized expression → deny (fail-closed)', () => {
|
|
190
|
+
expect(evalStringRule('auth.role == "admin"', { id: 'u1' })).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('CRITICAL: arbitrary string → deny (fail-closed)', () => {
|
|
194
|
+
expect(evalStringRule('anything goes here', null)).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('CRITICAL: SQL-like injection attempt → deny', () => {
|
|
198
|
+
expect(evalStringRule('true; DROP TABLE users', null)).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ─── 5. Service Key constraint fail-closed ───────────────────────────────────
|
|
203
|
+
|
|
204
|
+
describe('Service Key constraint fail-closed', () => {
|
|
205
|
+
it('env constraint: missing ctx.env → denied', () => {
|
|
206
|
+
const entry = makeEntry({
|
|
207
|
+
kid: 'fc-env',
|
|
208
|
+
inlineSecret: 'jb_fc-env_payload',
|
|
209
|
+
tier: 'root',
|
|
210
|
+
constraints: { env: ['production'] },
|
|
211
|
+
});
|
|
212
|
+
const keymap = new Map([['fc-env', { entry, secret: 'jb_fc-env_payload' }]]);
|
|
213
|
+
expect(validateScopedKey('jb_fc-env_payload', 'any:scope', keymap, {})).toBe('invalid');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('ipCidr constraint: missing ctx.ip → denied', () => {
|
|
217
|
+
const entry = makeEntry({
|
|
218
|
+
kid: 'fc-ip',
|
|
219
|
+
inlineSecret: 'jb_fc-ip_payload',
|
|
220
|
+
tier: 'root',
|
|
221
|
+
constraints: { ipCidr: ['10.0.0.0/8'] },
|
|
222
|
+
});
|
|
223
|
+
const keymap = new Map([['fc-ip', { entry, secret: 'jb_fc-ip_payload' }]]);
|
|
224
|
+
expect(validateScopedKey('jb_fc-ip_payload', 'any:scope', keymap, {})).toBe('invalid');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('tenant constraint: missing ctx.tenantId → denied', () => {
|
|
228
|
+
const entry = makeEntry({
|
|
229
|
+
kid: 'fc-tn',
|
|
230
|
+
inlineSecret: 'jb_fc-tn_payload',
|
|
231
|
+
tier: 'root',
|
|
232
|
+
constraints: { tenant: 'tenant-abc' },
|
|
233
|
+
});
|
|
234
|
+
const keymap = new Map([['fc-tn', { entry, secret: 'jb_fc-tn_payload' }]]);
|
|
235
|
+
expect(validateScopedKey('jb_fc-tn_payload', 'any:scope', keymap, {})).toBe('invalid');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('no constraints → valid', () => {
|
|
239
|
+
const entry = makeEntry({
|
|
240
|
+
kid: 'fc-none',
|
|
241
|
+
inlineSecret: 'jb_fc-none_payload',
|
|
242
|
+
tier: 'root',
|
|
243
|
+
});
|
|
244
|
+
const keymap = new Map([['fc-none', { entry, secret: 'jb_fc-none_payload' }]]);
|
|
245
|
+
expect(validateScopedKey('jb_fc-none_payload', 'any:scope', keymap, {})).toBe('valid');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('matching env constraint → valid', () => {
|
|
249
|
+
const entry = makeEntry({
|
|
250
|
+
kid: 'fc-env2',
|
|
251
|
+
inlineSecret: 'jb_fc-env2_payload',
|
|
252
|
+
tier: 'root',
|
|
253
|
+
constraints: { env: ['production'] },
|
|
254
|
+
});
|
|
255
|
+
const keymap = new Map([['fc-env2', { entry, secret: 'jb_fc-env2_payload' }]]);
|
|
256
|
+
expect(validateScopedKey('jb_fc-env2_payload', 'any:scope', keymap, { env: 'production' })).toBe('valid');
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ─── 6. timingSafeEqual ──────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
describe('timingSafeEqual', () => {
|
|
263
|
+
it('equal strings → true', () => {
|
|
264
|
+
expect(timingSafeEqual('abc123', 'abc123')).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('different strings → false', () => {
|
|
268
|
+
expect(timingSafeEqual('abc123', 'abc124')).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('different lengths → false', () => {
|
|
272
|
+
expect(timingSafeEqual('short', 'longer-string')).toBe(false);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('empty strings → true', () => {
|
|
276
|
+
expect(timingSafeEqual('', '')).toBe(true);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('hex strings (HMAC-like) → correct comparison', () => {
|
|
280
|
+
const hex = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2';
|
|
281
|
+
expect(timingSafeEqual(hex, hex)).toBe(true);
|
|
282
|
+
const hexOff = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b3';
|
|
283
|
+
expect(timingSafeEqual(hex, hexOff)).toBe(false);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ─── 7. SQL identifier escaping ──────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
describe('SQL identifier escaping', () => {
|
|
290
|
+
// Test the escaping pattern used in database-do.ts and backup.ts
|
|
291
|
+
const escId = (n: string) => `"${n.replace(/"/g, '""')}"`;
|
|
292
|
+
|
|
293
|
+
it('normal column name', () => {
|
|
294
|
+
expect(escId('name')).toBe('"name"');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('column with double-quote', () => {
|
|
298
|
+
expect(escId('col"name')).toBe('"col""name"');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('column with multiple double-quotes', () => {
|
|
302
|
+
expect(escId('a"b"c')).toBe('"a""b""c"');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('injection attempt via double-quote breakout', () => {
|
|
306
|
+
const malicious = 'id") VALUES (1); DROP TABLE _users; --';
|
|
307
|
+
const escaped = escId(malicious);
|
|
308
|
+
// Should be a single quoted identifier, not breaking out
|
|
309
|
+
expect(escaped).toBe('"id"") VALUES (1); DROP TABLE _users; --"');
|
|
310
|
+
// The " in the middle is escaped to "" — SQLite treats this as a literal double-quote
|
|
311
|
+
});
|
|
312
|
+
});
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 서버 단위 테스트 — lib/op-parser.ts + lib/errors.ts + lib/validation.ts
|
|
3
|
+
*
|
|
4
|
+
* 실행:
|
|
5
|
+
* cd packages/server && npx vitest run src/__tests__/server.unit.test.ts
|
|
6
|
+
*
|
|
7
|
+
* 원칙: 서버 로직 순수 단위 테스트 — 네트워크 불필요
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from 'vitest';
|
|
11
|
+
import { parseUpdateBody } from '../lib/op-parser.js';
|
|
12
|
+
import {
|
|
13
|
+
validationError,
|
|
14
|
+
unauthorizedError,
|
|
15
|
+
forbiddenError,
|
|
16
|
+
notFoundError,
|
|
17
|
+
methodNotAllowedError,
|
|
18
|
+
rateLimitError,
|
|
19
|
+
} from '../lib/errors.js';
|
|
20
|
+
import {
|
|
21
|
+
validateInsert,
|
|
22
|
+
validateUpdate,
|
|
23
|
+
isFieldOperator,
|
|
24
|
+
} from '../lib/validation.js';
|
|
25
|
+
import { EdgeBaseError } from '@edge-base/shared';
|
|
26
|
+
|
|
27
|
+
// ─── A. op-parser.ts ──────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
describe('parseUpdateBody', () => {
|
|
30
|
+
it('plain field → SET clause with ?', () => {
|
|
31
|
+
const { setClauses, params } = parseUpdateBody({ title: 'Hello' });
|
|
32
|
+
expect(setClauses).toContain('"title" = ?');
|
|
33
|
+
expect(params).toContain('Hello');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('increment op → COALESCE SQL', () => {
|
|
37
|
+
const { setClauses, params } = parseUpdateBody({
|
|
38
|
+
viewCount: { $op: 'increment', value: 5 },
|
|
39
|
+
});
|
|
40
|
+
expect(setClauses[0]).toContain('COALESCE');
|
|
41
|
+
expect(setClauses[0]).toContain('+ ?');
|
|
42
|
+
expect(params).toContain(5);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('deleteField op → NULL SQL', () => {
|
|
46
|
+
const { setClauses, params } = parseUpdateBody({
|
|
47
|
+
extra: { $op: 'deleteField' },
|
|
48
|
+
});
|
|
49
|
+
expect(setClauses[0]).toContain('= NULL');
|
|
50
|
+
expect(params).toHaveLength(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('excludes id field by default', () => {
|
|
54
|
+
const { setClauses } = parseUpdateBody({ id: 'should-exclude', title: 'OK' });
|
|
55
|
+
const hasId = setClauses.some((c) => c.includes('"id"'));
|
|
56
|
+
expect(hasId).toBe(false);
|
|
57
|
+
expect(setClauses.some((c) => c.includes('"title"'))).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('custom excludeFields', () => {
|
|
61
|
+
const { setClauses } = parseUpdateBody(
|
|
62
|
+
{ title: 'OK', skipMe: 'X' },
|
|
63
|
+
['skipMe'],
|
|
64
|
+
);
|
|
65
|
+
expect(setClauses.some((c) => c.includes('"skipMe"'))).toBe(false);
|
|
66
|
+
expect(setClauses.some((c) => c.includes('"title"'))).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('multiple fields returns N clauses', () => {
|
|
70
|
+
const { setClauses, params } = parseUpdateBody({ a: 1, b: 2, c: 3 });
|
|
71
|
+
expect(setClauses).toHaveLength(3);
|
|
72
|
+
expect(params).toHaveLength(3);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('empty body → empty clauses and params', () => {
|
|
76
|
+
const { setClauses, params } = parseUpdateBody({});
|
|
77
|
+
expect(setClauses).toHaveLength(0);
|
|
78
|
+
expect(params).toHaveLength(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('increment value=0 → param 0', () => {
|
|
82
|
+
const { params } = parseUpdateBody({ val: { $op: 'increment', value: 0 } });
|
|
83
|
+
expect(params).toContain(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('unknown op → throws', () => {
|
|
87
|
+
expect(() =>
|
|
88
|
+
parseUpdateBody({ x: { $op: 'unknownOp' as any } }),
|
|
89
|
+
).toThrow(/Unknown field operator/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('mixed fields: increment + plain + deleteField', () => {
|
|
93
|
+
const { setClauses, params } = parseUpdateBody({
|
|
94
|
+
views: { $op: 'increment', value: 1 },
|
|
95
|
+
title: 'Updated',
|
|
96
|
+
legacy: { $op: 'deleteField' },
|
|
97
|
+
});
|
|
98
|
+
expect(setClauses).toHaveLength(3);
|
|
99
|
+
// increment creates 1 param, plain creates 1, deleteField creates 0
|
|
100
|
+
expect(params).toHaveLength(2);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ─── B. errors.ts ─────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
describe('errors helpers', () => {
|
|
107
|
+
it('validationError returns EdgeBaseError(400)', () => {
|
|
108
|
+
const err = validationError('Bad input');
|
|
109
|
+
expect(err).toBeInstanceOf(EdgeBaseError);
|
|
110
|
+
expect(err.code).toBe(400);
|
|
111
|
+
expect(err.message).toBe('Bad input');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('unauthorizedError returns 401', () => {
|
|
115
|
+
const err = unauthorizedError();
|
|
116
|
+
expect(err.code).toBe(401);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('unauthorizedError with custom message', () => {
|
|
120
|
+
const err = unauthorizedError('Token expired');
|
|
121
|
+
expect(err.message).toBe('Token expired');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('forbiddenError returns 403', () => {
|
|
125
|
+
const err = forbiddenError();
|
|
126
|
+
expect(err.code).toBe(403);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('notFoundError returns 404', () => {
|
|
130
|
+
const err = notFoundError();
|
|
131
|
+
expect(err.code).toBe(404);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('methodNotAllowedError returns 405', () => {
|
|
135
|
+
const err = methodNotAllowedError();
|
|
136
|
+
expect(err.code).toBe(405);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('rateLimitError returns 429 with retryAfter', () => {
|
|
140
|
+
const err = rateLimitError(30);
|
|
141
|
+
expect(err.code).toBe(429);
|
|
142
|
+
expect(err.message).toContain('30');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('forbiddenError custom message', () => {
|
|
146
|
+
const err = forbiddenError('Service Key required');
|
|
147
|
+
expect(err.message).toBe('Service Key required');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('notFoundError custom message', () => {
|
|
151
|
+
const err = notFoundError('Record not found');
|
|
152
|
+
expect(err.message).toBe('Record not found');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ─── C. validation.ts ─────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
describe('isFieldOperator', () => {
|
|
159
|
+
it('increment → true', () => {
|
|
160
|
+
expect(isFieldOperator({ $op: 'increment', value: 5 })).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('deleteField → true', () => {
|
|
164
|
+
expect(isFieldOperator({ $op: 'deleteField' })).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('plain string → false', () => {
|
|
168
|
+
expect(isFieldOperator('hello')).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('null → false', () => {
|
|
172
|
+
expect(isFieldOperator(null)).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('number → false', () => {
|
|
176
|
+
expect(isFieldOperator(42)).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('plain object without $op → false', () => {
|
|
180
|
+
expect(isFieldOperator({ op: 'increment' })).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('validateInsert (schemaless)', () => {
|
|
185
|
+
it('no schema → valid = true', () => {
|
|
186
|
+
const result = validateInsert({ title: 'any', unknownField: 123 });
|
|
187
|
+
expect(result.valid).toBe(true);
|
|
188
|
+
expect(result.errors).toEqual({});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('validateInsert (with schema)', () => {
|
|
193
|
+
const schema = {
|
|
194
|
+
title: { type: 'string' as const, required: true },
|
|
195
|
+
views: { type: 'number' as const },
|
|
196
|
+
isActive: { type: 'boolean' as const },
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
it('valid data → valid=true', () => {
|
|
200
|
+
const result = validateInsert({ title: 'Hello' }, schema);
|
|
201
|
+
expect(result.valid).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('missing required field → invalid', () => {
|
|
205
|
+
const result = validateInsert({}, schema);
|
|
206
|
+
expect(result.valid).toBe(false);
|
|
207
|
+
expect(result.errors).toHaveProperty('title');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('wrong type string → invalid', () => {
|
|
211
|
+
const result = validateInsert({ title: 123 as any }, schema);
|
|
212
|
+
expect(result.valid).toBe(false);
|
|
213
|
+
expect(result.errors.title).toContain('string');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('wrong type number → invalid', () => {
|
|
217
|
+
const result = validateInsert({ title: 'OK', views: 'not-a-num' as any }, schema);
|
|
218
|
+
expect(result.valid).toBe(false);
|
|
219
|
+
expect(result.errors.views).toContain('number');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('unknown field → silently ignored (valid)', () => {
|
|
223
|
+
const result = validateInsert({ title: 'OK', unknownXYZ: 'val' }, schema);
|
|
224
|
+
expect(result.valid).toBe(true);
|
|
225
|
+
expect(Object.keys(result.errors)).toHaveLength(0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('auto-managed fields skip validation — id, createdAt, updatedAt', () => {
|
|
229
|
+
const result = validateInsert({ title: 'OK', id: 'any', createdAt: 'any', updatedAt: 'any' }, schema);
|
|
230
|
+
expect(result.valid).toBe(true);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('boolean type validation', () => {
|
|
234
|
+
const result = validateInsert({ title: 'OK', isActive: 'true' as any }, schema);
|
|
235
|
+
expect(result.valid).toBe(false);
|
|
236
|
+
expect(result.errors.isActive).toContain('boolean');
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('validateInsert — string constraints', () => {
|
|
241
|
+
const schema = {
|
|
242
|
+
title: {
|
|
243
|
+
type: 'string' as const,
|
|
244
|
+
required: true,
|
|
245
|
+
min: 3,
|
|
246
|
+
max: 10,
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
it('value below min → invalid', () => {
|
|
251
|
+
const result = validateInsert({ title: 'ab' }, schema);
|
|
252
|
+
expect(result.valid).toBe(false);
|
|
253
|
+
expect(result.errors.title).toContain('3');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('value above max → invalid', () => {
|
|
257
|
+
const result = validateInsert({ title: 'a'.repeat(11) }, schema);
|
|
258
|
+
expect(result.valid).toBe(false);
|
|
259
|
+
expect(result.errors.title).toContain('10');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('valid length → valid', () => {
|
|
263
|
+
const result = validateInsert({ title: 'Hello' }, schema);
|
|
264
|
+
expect(result.valid).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe('validateInsert — enum constraint', () => {
|
|
269
|
+
const schema = {
|
|
270
|
+
status: {
|
|
271
|
+
type: 'string' as const,
|
|
272
|
+
enum: ['draft', 'published', 'archived'],
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
it('valid enum value → valid', () => {
|
|
277
|
+
const result = validateInsert({ status: 'draft' }, schema);
|
|
278
|
+
expect(result.valid).toBe(true);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('invalid enum value → invalid', () => {
|
|
282
|
+
const result = validateInsert({ status: 'invalid' }, schema);
|
|
283
|
+
expect(result.valid).toBe(false);
|
|
284
|
+
expect(result.errors.status).toContain('draft');
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe('validateUpdate (schemaless)', () => {
|
|
289
|
+
it('no schema → valid = true', () => {
|
|
290
|
+
const result = validateUpdate({ title: 'any', extra: 123 });
|
|
291
|
+
expect(result.valid).toBe(true);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe('validateUpdate (with schema)', () => {
|
|
296
|
+
const schema = {
|
|
297
|
+
title: { type: 'string' as const, required: true },
|
|
298
|
+
views: { type: 'number' as const },
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
it('valid partial update → valid', () => {
|
|
302
|
+
const result = validateUpdate({ views: 5 }, schema);
|
|
303
|
+
expect(result.valid).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('$op field operator passes through', () => {
|
|
307
|
+
const result = validateUpdate({ views: { $op: 'increment', value: 1 } }, schema);
|
|
308
|
+
expect(result.valid).toBe(true);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('required field deleteField → invalid', () => {
|
|
312
|
+
const result = validateUpdate({ title: { $op: 'deleteField' } }, schema);
|
|
313
|
+
expect(result.valid).toBe(false);
|
|
314
|
+
expect(result.errors.title).toContain('required');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('unknown field in update → silently ignored (valid)', () => {
|
|
318
|
+
const result = validateUpdate({ unknownXYZ: 'bad' }, schema);
|
|
319
|
+
expect(result.valid).toBe(true);
|
|
320
|
+
expect(Object.keys(result.errors)).toHaveLength(0);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('wrong type in update → invalid', () => {
|
|
324
|
+
const result = validateUpdate({ views: 'not-num' as any }, schema);
|
|
325
|
+
expect(result.valid).toBe(false);
|
|
326
|
+
expect(result.errors.views).toContain('number');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('auto-managed fields skipped in update', () => {
|
|
330
|
+
const result = validateUpdate({ id: 'any', title: 'Valid' }, schema);
|
|
331
|
+
expect(result.valid).toBe(true);
|
|
332
|
+
});
|
|
333
|
+
});
|