@cosmicdrift/kumiko-framework 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -0
- package/package.json +91 -0
- package/src/__tests__/anonymous-access.integration.ts +325 -0
- package/src/__tests__/error-contract.integration.ts +435 -0
- package/src/__tests__/field-access.integration.ts +269 -0
- package/src/__tests__/full-stack.integration.ts +914 -0
- package/src/__tests__/ownership.integration.ts +449 -0
- package/src/__tests__/reference-data.integration.ts +198 -0
- package/src/__tests__/transition-guard.integration.ts +340 -0
- package/src/api/__tests__/api.test.ts +337 -0
- package/src/api/__tests__/auth-middleware-transport.test.ts +80 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +179 -0
- package/src/api/__tests__/batch.integration.ts +404 -0
- package/src/api/__tests__/body-limit.test.ts +88 -0
- package/src/api/__tests__/csrf-middleware.test.ts +97 -0
- package/src/api/__tests__/dispatcher-live.integration.ts +216 -0
- package/src/api/__tests__/metrics-endpoint.test.ts +126 -0
- package/src/api/__tests__/nested-write.integration.ts +213 -0
- package/src/api/__tests__/readiness.test.ts +76 -0
- package/src/api/__tests__/request-id-middleware.test.ts +72 -0
- package/src/api/__tests__/sse-broker.test.ts +58 -0
- package/src/api/__tests__/sse-route.test.ts +112 -0
- package/src/api/anonymous-cookie.ts +60 -0
- package/src/api/api-constants.ts +64 -0
- package/src/api/auth-middleware.ts +418 -0
- package/src/api/auth-routes.ts +982 -0
- package/src/api/csrf-middleware.ts +77 -0
- package/src/api/index.ts +31 -0
- package/src/api/jwt.ts +66 -0
- package/src/api/observability-middleware.ts +89 -0
- package/src/api/readiness.ts +132 -0
- package/src/api/request-context.ts +49 -0
- package/src/api/request-id-middleware.ts +50 -0
- package/src/api/route-registrars.ts +195 -0
- package/src/api/routes.ts +135 -0
- package/src/api/server.ts +640 -0
- package/src/api/sse-broker.ts +71 -0
- package/src/api/sse-route.ts +62 -0
- package/src/api/tokens.ts +16 -0
- package/src/db/__tests__/apply-entity-event-tenant.integration.ts +159 -0
- package/src/db/__tests__/compound-types.test.ts +114 -0
- package/src/db/__tests__/connection-options.test.ts +68 -0
- package/src/db/__tests__/cursor.test.ts +41 -0
- package/src/db/__tests__/db-helpers.test.ts +369 -0
- package/src/db/__tests__/dialect-instant.test.ts +50 -0
- package/src/db/__tests__/drizzle-helpers.integration.ts +186 -0
- package/src/db/__tests__/drizzle-table-types.test.ts +162 -0
- package/src/db/__tests__/encryption.test.ts +39 -0
- package/src/db/__tests__/event-store-executor-list.integration.ts +313 -0
- package/src/db/__tests__/event-store-executor.integration.ts +235 -0
- package/src/db/__tests__/implicit-projection-equivalence.integration.ts +304 -0
- package/src/db/__tests__/located-timestamp.test.ts +184 -0
- package/src/db/__tests__/money.test.ts +199 -0
- package/src/db/__tests__/multi-row-insert.integration.ts +76 -0
- package/src/db/__tests__/parse-auto-verb.test.ts +70 -0
- package/src/db/__tests__/required-not-null-migration-safety.integration.ts +105 -0
- package/src/db/__tests__/row-helpers.test.ts +59 -0
- package/src/db/__tests__/schema-migration.integration.ts +273 -0
- package/src/db/__tests__/table-builder-indexes.test.ts +153 -0
- package/src/db/__tests__/table-builder-required.test.ts +216 -0
- package/src/db/__tests__/tenant-db.integration.ts +606 -0
- package/src/db/__tests__/unique-violation-mapping.integration.ts +166 -0
- package/src/db/apply-entity-event.ts +188 -0
- package/src/db/assert-exists-in.ts +59 -0
- package/src/db/compound-types.ts +47 -0
- package/src/db/connection.ts +104 -0
- package/src/db/cursor.ts +83 -0
- package/src/db/dialect.ts +109 -0
- package/src/db/eagerload.ts +174 -0
- package/src/db/encryption.ts +39 -0
- package/src/db/event-store-executor.ts +906 -0
- package/src/db/index.ts +55 -0
- package/src/db/located-timestamp.ts +114 -0
- package/src/db/money.ts +120 -0
- package/src/db/pg-error.ts +46 -0
- package/src/db/reference-data.ts +77 -0
- package/src/db/row-helpers.ts +53 -0
- package/src/db/schema-inspection.ts +25 -0
- package/src/db/table-builder.ts +475 -0
- package/src/db/tenant-db.ts +434 -0
- package/src/engine/__tests__/auth-claims-registrar.test.ts +74 -0
- package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +108 -0
- package/src/engine/__tests__/boot-validator.test.ts +1865 -0
- package/src/engine/__tests__/build-app-schema.test.ts +154 -0
- package/src/engine/__tests__/claim-keys.test.ts +274 -0
- package/src/engine/__tests__/config-helpers.test.ts +236 -0
- package/src/engine/__tests__/effective-features.test.ts +86 -0
- package/src/engine/__tests__/engine.test.ts +1461 -0
- package/src/engine/__tests__/entity-handlers.test.ts +274 -0
- package/src/engine/__tests__/event-helpers.test.ts +68 -0
- package/src/engine/__tests__/extends-registrar.test.ts +159 -0
- package/src/engine/__tests__/factories-long-text.test.ts +84 -0
- package/src/engine/__tests__/factories-time.test.ts +158 -0
- package/src/engine/__tests__/field-predicates.test.ts +48 -0
- package/src/engine/__tests__/hook-phases.test.ts +132 -0
- package/src/engine/__tests__/identifiers.test.ts +35 -0
- package/src/engine/__tests__/lifecycle-hooks.test.ts +237 -0
- package/src/engine/__tests__/nav.test.ts +267 -0
- package/src/engine/__tests__/ownership.test.ts +421 -0
- package/src/engine/__tests__/parse-ref-target.test.ts +43 -0
- package/src/engine/__tests__/projection-helpers.test.ts +62 -0
- package/src/engine/__tests__/projection.test.ts +191 -0
- package/src/engine/__tests__/qualified-name.test.ts +264 -0
- package/src/engine/__tests__/resolve-config-or-param.test.ts +315 -0
- package/src/engine/__tests__/run-in.test.ts +38 -0
- package/src/engine/__tests__/schema-builder.test.ts +380 -0
- package/src/engine/__tests__/screen.test.ts +408 -0
- package/src/engine/__tests__/state-machine.test.ts +148 -0
- package/src/engine/__tests__/system-user.test.ts +57 -0
- package/src/engine/__tests__/validation-hooks.test.ts +71 -0
- package/src/engine/access.ts +23 -0
- package/src/engine/boot-validator.ts +1528 -0
- package/src/engine/build-app-schema.ts +125 -0
- package/src/engine/config-helpers.ts +115 -0
- package/src/engine/constants.ts +85 -0
- package/src/engine/create-app.ts +98 -0
- package/src/engine/define-feature.ts +702 -0
- package/src/engine/define-handler.ts +78 -0
- package/src/engine/define-roles.ts +19 -0
- package/src/engine/effective-features.ts +87 -0
- package/src/engine/entity-handlers.ts +364 -0
- package/src/engine/event-helpers.ts +73 -0
- package/src/engine/factories.ts +328 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +416 -0
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +197 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +128 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +888 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +360 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +469 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +287 -0
- package/src/engine/feature-ast/extractors.ts +2562 -0
- package/src/engine/feature-ast/index.ts +105 -0
- package/src/engine/feature-ast/parse.ts +369 -0
- package/src/engine/feature-ast/patch.ts +525 -0
- package/src/engine/feature-ast/patcher.ts +518 -0
- package/src/engine/feature-ast/patterns.ts +434 -0
- package/src/engine/feature-ast/render.ts +602 -0
- package/src/engine/feature-ast/source-location.ts +45 -0
- package/src/engine/field-access.ts +120 -0
- package/src/engine/index.ts +254 -0
- package/src/engine/ownership.ts +337 -0
- package/src/engine/parse-ref-target.ts +22 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +351 -0
- package/src/engine/pattern-library/index.ts +24 -0
- package/src/engine/pattern-library/library.ts +1117 -0
- package/src/engine/pattern-library/types.ts +255 -0
- package/src/engine/projection-helpers.ts +85 -0
- package/src/engine/qualified-name.ts +122 -0
- package/src/engine/read-claim.ts +31 -0
- package/src/engine/registry.ts +1325 -0
- package/src/engine/resolve-config-or-param.ts +153 -0
- package/src/engine/run-in.ts +29 -0
- package/src/engine/schema-builder.ts +175 -0
- package/src/engine/screen-filter-ops.ts +51 -0
- package/src/engine/state-machine.ts +70 -0
- package/src/engine/system-user.ts +32 -0
- package/src/engine/types/config.ts +306 -0
- package/src/engine/types/event-type-map.ts +37 -0
- package/src/engine/types/feature.ts +574 -0
- package/src/engine/types/fields.ts +422 -0
- package/src/engine/types/handlers.ts +742 -0
- package/src/engine/types/hooks.ts +142 -0
- package/src/engine/types/http-route.ts +54 -0
- package/src/engine/types/identifiers.ts +47 -0
- package/src/engine/types/index.ts +208 -0
- package/src/engine/types/nav.ts +46 -0
- package/src/engine/types/projection.ts +132 -0
- package/src/engine/types/relations.ts +51 -0
- package/src/engine/types/screen.ts +452 -0
- package/src/engine/types/workspace.ts +42 -0
- package/src/engine/validation.ts +33 -0
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.ts +173 -0
- package/src/entrypoint/__tests__/split-deploy.integration.ts +297 -0
- package/src/entrypoint/index.ts +442 -0
- package/src/errors/__tests__/classes.test.ts +371 -0
- package/src/errors/__tests__/write-failures.test.ts +109 -0
- package/src/errors/classes.ts +249 -0
- package/src/errors/i18n/de.yaml +83 -0
- package/src/errors/i18n/en.yaml +80 -0
- package/src/errors/index.ts +41 -0
- package/src/errors/kumiko-error.ts +67 -0
- package/src/errors/reasons.ts +36 -0
- package/src/errors/serialize.ts +136 -0
- package/src/errors/transition-details.ts +30 -0
- package/src/errors/write-error-info.ts +123 -0
- package/src/errors/zod-bridge.ts +49 -0
- package/src/event-store/__tests__/admin-api.integration.ts +361 -0
- package/src/event-store/__tests__/event-store.integration.ts +584 -0
- package/src/event-store/__tests__/get-stream-version-perf.integration.ts +83 -0
- package/src/event-store/__tests__/perf.integration.ts +255 -0
- package/src/event-store/__tests__/snapshot.integration.ts +267 -0
- package/src/event-store/__tests__/upcaster-dead-letter.integration.ts +204 -0
- package/src/event-store/__tests__/upcaster.integration.ts +460 -0
- package/src/event-store/admin-api.ts +257 -0
- package/src/event-store/archive.ts +106 -0
- package/src/event-store/errors.ts +35 -0
- package/src/event-store/event-store.ts +405 -0
- package/src/event-store/events-schema.ts +90 -0
- package/src/event-store/index.ts +50 -0
- package/src/event-store/snapshot.ts +210 -0
- package/src/event-store/upcaster-dead-letter.ts +119 -0
- package/src/event-store/upcaster.ts +147 -0
- package/src/files/__tests__/content-disposition.test.ts +123 -0
- package/src/files/__tests__/file-field-column.integration.ts +103 -0
- package/src/files/__tests__/file-field-pipeline.integration.ts +211 -0
- package/src/files/__tests__/file-handle.test.ts +122 -0
- package/src/files/__tests__/files.integration.ts +830 -0
- package/src/files/__tests__/storage-tracking.integration.ts +153 -0
- package/src/files/content-disposition.ts +55 -0
- package/src/files/file-handle.ts +63 -0
- package/src/files/file-ref-table.ts +22 -0
- package/src/files/file-routes.ts +353 -0
- package/src/files/in-memory-provider.ts +62 -0
- package/src/files/index.ts +29 -0
- package/src/files/local-provider.ts +35 -0
- package/src/files/storage-tracking.ts +60 -0
- package/src/files/types.ts +118 -0
- package/src/i18n/__tests__/i18n.test.ts +72 -0
- package/src/i18n/index.ts +29 -0
- package/src/jobs/__tests__/job-event-trigger.integration.ts +172 -0
- package/src/jobs/__tests__/job-multi-trigger.integration.ts +144 -0
- package/src/jobs/__tests__/jobs.integration.ts +566 -0
- package/src/jobs/index.ts +2 -0
- package/src/jobs/job-runner.ts +574 -0
- package/src/lifecycle/__tests__/create-test-lifecycle.ts +19 -0
- package/src/lifecycle/__tests__/lifecycle-server.integration.ts +108 -0
- package/src/lifecycle/__tests__/lifecycle.test.ts +212 -0
- package/src/lifecycle/__tests__/signal-handlers.test.ts +106 -0
- package/src/lifecycle/index.ts +13 -0
- package/src/lifecycle/lifecycle.ts +160 -0
- package/src/lifecycle/signal-handlers.ts +62 -0
- package/src/logging/__tests__/pino-trace-bridge.test.ts +50 -0
- package/src/logging/index.ts +3 -0
- package/src/logging/pino-logger.ts +64 -0
- package/src/logging/types.ts +7 -0
- package/src/migrations/__tests__/compare-snapshots.test.ts +150 -0
- package/src/migrations/__tests__/detect-drift.integration.ts +320 -0
- package/src/migrations/__tests__/detect-projections-to-rebuild.integration.ts +134 -0
- package/src/migrations/__tests__/rebuild-marker.test.ts +79 -0
- package/src/migrations/index.ts +28 -0
- package/src/migrations/projection-detection.ts +149 -0
- package/src/migrations/rebuild-marker.ts +64 -0
- package/src/migrations/schema-drift.ts +395 -0
- package/src/observability/__tests__/console-provider.test.ts +67 -0
- package/src/observability/__tests__/metric-validator.test.ts +87 -0
- package/src/observability/__tests__/noop-provider.test.ts +82 -0
- package/src/observability/__tests__/observability.integration.ts +559 -0
- package/src/observability/__tests__/prometheus-meter.test.ts +144 -0
- package/src/observability/__tests__/recording-meter.test.ts +101 -0
- package/src/observability/__tests__/recording-tracer.test.ts +110 -0
- package/src/observability/__tests__/sensitive-filter.test.ts +98 -0
- package/src/observability/console-provider.ts +130 -0
- package/src/observability/context.ts +26 -0
- package/src/observability/fallback.ts +34 -0
- package/src/observability/ids.ts +25 -0
- package/src/observability/index.ts +79 -0
- package/src/observability/metric-validator.ts +86 -0
- package/src/observability/metrics-handle.ts +56 -0
- package/src/observability/noop-provider.ts +146 -0
- package/src/observability/prometheus-meter.ts +284 -0
- package/src/observability/recording-meter.ts +156 -0
- package/src/observability/recording-tracer.ts +198 -0
- package/src/observability/redis-wrapper.ts +132 -0
- package/src/observability/sensitive-filter.ts +108 -0
- package/src/observability/standard-metrics.ts +213 -0
- package/src/observability/types/index.ts +29 -0
- package/src/observability/types/metric.ts +56 -0
- package/src/observability/types/provider.ts +32 -0
- package/src/observability/types/span.ts +64 -0
- package/src/pipeline/__tests__/archive-stream.integration.ts +220 -0
- package/src/pipeline/__tests__/auth-claims-resolver.test.ts +279 -0
- package/src/pipeline/__tests__/cascade-handler.integration.ts +419 -0
- package/src/pipeline/__tests__/cascade-handler.test.ts +52 -0
- package/src/pipeline/__tests__/causation-chain.integration.ts +206 -0
- package/src/pipeline/__tests__/ctx-bridge.integration.ts +234 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +379 -0
- package/src/pipeline/__tests__/distributed-lock.integration.ts +67 -0
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +323 -0
- package/src/pipeline/__tests__/event-dedup.integration.ts +153 -0
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +220 -0
- package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +423 -0
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +123 -0
- package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +290 -0
- package/src/pipeline/__tests__/event-dispatcher-strict.test.ts +65 -0
- package/src/pipeline/__tests__/event-dispatcher.integration.ts +287 -0
- package/src/pipeline/__tests__/event-retention.integration.ts +239 -0
- package/src/pipeline/__tests__/fetch-for-writing.integration.ts +281 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +430 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +266 -0
- package/src/pipeline/__tests__/msp-error-mode.integration.ts +149 -0
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +228 -0
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +368 -0
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +341 -0
- package/src/pipeline/__tests__/perf-rebuild.integration.ts +147 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.ts +551 -0
- package/src/pipeline/__tests__/query-projection.integration.ts +201 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.ts +306 -0
- package/src/pipeline/append-event-core.ts +117 -0
- package/src/pipeline/auth-claims-resolver.ts +103 -0
- package/src/pipeline/cascade-handler.ts +113 -0
- package/src/pipeline/dispatcher.ts +1585 -0
- package/src/pipeline/distributed-lock.ts +37 -0
- package/src/pipeline/entity-cache.ts +113 -0
- package/src/pipeline/event-consumer-state.ts +108 -0
- package/src/pipeline/event-dedup.ts +23 -0
- package/src/pipeline/event-dispatcher.ts +1016 -0
- package/src/pipeline/event-retention.ts +154 -0
- package/src/pipeline/idempotency.ts +76 -0
- package/src/pipeline/index.ts +66 -0
- package/src/pipeline/lifecycle-pipeline.ts +409 -0
- package/src/pipeline/msp-rebuild.ts +242 -0
- package/src/pipeline/multi-stream-apply-context.ts +115 -0
- package/src/pipeline/projection-rebuild.ts +334 -0
- package/src/pipeline/projection-state.ts +72 -0
- package/src/pipeline/projections-runner.ts +56 -0
- package/src/pipeline/redis-keys.ts +11 -0
- package/src/pipeline/system-hooks.ts +190 -0
- package/src/random/__tests__/generate.test.ts +149 -0
- package/src/random/generate.ts +141 -0
- package/src/random/index.ts +8 -0
- package/src/random/words.ts +392 -0
- package/src/rate-limit/__tests__/dispatcher-l3.integration.ts +111 -0
- package/src/rate-limit/__tests__/middleware.integration.ts +189 -0
- package/src/rate-limit/__tests__/resolver.integration.ts +189 -0
- package/src/rate-limit/bucket.ts +36 -0
- package/src/rate-limit/index.ts +14 -0
- package/src/rate-limit/middleware.ts +152 -0
- package/src/rate-limit/resolver.ts +267 -0
- package/src/redis/__tests__/redis-options.test.ts +54 -0
- package/src/redis/index.ts +74 -0
- package/src/search/__tests__/meilisearch-adapter.integration.ts +236 -0
- package/src/search/__tests__/search-adapter.test.ts +256 -0
- package/src/search/in-memory-adapter.ts +123 -0
- package/src/search/index.ts +12 -0
- package/src/search/meilisearch-adapter.ts +106 -0
- package/src/search/types.ts +39 -0
- package/src/secrets/__tests__/dek-cache.test.ts +213 -0
- package/src/secrets/__tests__/env-master-key-provider.test.ts +119 -0
- package/src/secrets/__tests__/envelope.test.ts +74 -0
- package/src/secrets/__tests__/leak-guard.test.ts +92 -0
- package/src/secrets/__tests__/rotation.test.ts +149 -0
- package/src/secrets/dek-cache.ts +116 -0
- package/src/secrets/env-master-key-provider.ts +162 -0
- package/src/secrets/envelope.ts +55 -0
- package/src/secrets/index.ts +19 -0
- package/src/secrets/leak-guard.ts +87 -0
- package/src/secrets/rotation.ts +34 -0
- package/src/secrets/types.ts +107 -0
- package/src/stack/db.ts +104 -0
- package/src/stack/event-collector.ts +23 -0
- package/src/stack/index.ts +32 -0
- package/src/stack/redis.ts +44 -0
- package/src/stack/request-helper.ts +168 -0
- package/src/stack/table-helpers.ts +104 -0
- package/src/stack/test-stack.ts +357 -0
- package/src/stack/test-users.ts +37 -0
- package/src/testing/__tests__/e2e-generator.test.ts +230 -0
- package/src/testing/__tests__/ensure-entity-table.integration.ts +54 -0
- package/src/testing/access-assertions.ts +15 -0
- package/src/testing/assertions.ts +35 -0
- package/src/testing/e2e-generator.ts +465 -0
- package/src/testing/expect-error.ts +25 -0
- package/src/testing/handler-context.ts +125 -0
- package/src/testing/http-cookies.ts +52 -0
- package/src/testing/index.ts +41 -0
- package/src/testing/late-bound.ts +39 -0
- package/src/testing/mutable-master-key-provider.ts +31 -0
- package/src/testing/observability-recorder.ts +54 -0
- package/src/testing/shared-entities.ts +49 -0
- package/src/testing/utils.ts +1 -0
- package/src/testing/wait-for.ts +31 -0
- package/src/time/__tests__/polyfill.test.ts +73 -0
- package/src/time/__tests__/tz-context.test.ts +121 -0
- package/src/time/index.ts +21 -0
- package/src/time/polyfill.ts +70 -0
- package/src/time/tz-context.ts +107 -0
- package/src/ui-types/app-schema.ts +57 -0
- package/src/ui-types/index.ts +65 -0
- package/src/utils/__tests__/assert.test.ts +17 -0
- package/src/utils/__tests__/env-parse.test.ts +54 -0
- package/src/utils/assert.ts +18 -0
- package/src/utils/env-parse.ts +16 -0
- package/src/utils/ids.ts +16 -0
- package/src/utils/index.ts +5 -0
- package/src/utils/safe-json.ts +30 -0
- package/src/utils/serialization.ts +7 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// Error-Contract — one test per KumikoError subclass through the full HTTP
|
|
2
|
+
// stack. This file is the wire-format goldstandard: every error class must
|
|
3
|
+
// come back with a stable code + i18nKey and the shape that the client SDK
|
|
4
|
+
// and docs promise. If you change the contract, this is the file that moves.
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { createEventStoreExecutor } from "../db/event-store-executor";
|
|
9
|
+
import { buildDrizzleTable } from "../db/table-builder";
|
|
10
|
+
import {
|
|
11
|
+
createEntity,
|
|
12
|
+
createNumberField,
|
|
13
|
+
createTextField,
|
|
14
|
+
defineFeature,
|
|
15
|
+
type TenantId,
|
|
16
|
+
} from "../engine";
|
|
17
|
+
import {
|
|
18
|
+
AccessDeniedError,
|
|
19
|
+
ConflictError,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
UnprocessableError,
|
|
22
|
+
writeFailure,
|
|
23
|
+
} from "../errors";
|
|
24
|
+
import {
|
|
25
|
+
createEntityTable,
|
|
26
|
+
createTestUser,
|
|
27
|
+
setupTestStack,
|
|
28
|
+
type TestStack,
|
|
29
|
+
TestUsers,
|
|
30
|
+
} from "../stack";
|
|
31
|
+
|
|
32
|
+
// --- Entity + handlers that deliberately raise each Kumiko error class ---
|
|
33
|
+
|
|
34
|
+
const itemEntity = createEntity({
|
|
35
|
+
table: "errctr_items",
|
|
36
|
+
fields: {
|
|
37
|
+
name: createTextField({ required: true }),
|
|
38
|
+
stock: createNumberField({ default: 0 }),
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
const itemTable = buildDrizzleTable("item", itemEntity);
|
|
42
|
+
|
|
43
|
+
const errorFeature = defineFeature("errctr", (r) => {
|
|
44
|
+
r.entity("item", itemEntity);
|
|
45
|
+
|
|
46
|
+
// CRUD create to prep update/delete scenarios (NotFound, VersionConflict).
|
|
47
|
+
r.writeHandler(
|
|
48
|
+
"item:create",
|
|
49
|
+
z.object({ name: z.string().min(1) }),
|
|
50
|
+
async (event, ctx) => {
|
|
51
|
+
const crud = createEventStoreExecutor(itemTable, itemEntity, { entityName: "item" });
|
|
52
|
+
return crud.create(event.payload, event.user, ctx.db);
|
|
53
|
+
},
|
|
54
|
+
{ access: { roles: ["Admin"] } },
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// VersionConflict: update with stale version → 409 via CrudExecutor.
|
|
58
|
+
r.writeHandler(
|
|
59
|
+
"item:update",
|
|
60
|
+
z.object({
|
|
61
|
+
id: z.uuid(),
|
|
62
|
+
version: z.number().optional(),
|
|
63
|
+
changes: z.record(z.string(), z.unknown()),
|
|
64
|
+
}),
|
|
65
|
+
async (event, ctx) => {
|
|
66
|
+
const crud = createEventStoreExecutor(itemTable, itemEntity, { entityName: "item" });
|
|
67
|
+
return crud.update(event.payload, event.user, ctx.db);
|
|
68
|
+
},
|
|
69
|
+
{ access: { roles: ["Admin"] } },
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// ValidationError via Zod schema (too-short string).
|
|
73
|
+
// The "name: z.string().min(3)" constraint triggers a ZodIssue which the
|
|
74
|
+
// dispatcher wraps into ValidationError.details.fields automatically.
|
|
75
|
+
r.writeHandler(
|
|
76
|
+
"item:create-strict",
|
|
77
|
+
z.object({ name: z.string().min(3) }),
|
|
78
|
+
async () => writeFailure(new UnprocessableError("unreachable")),
|
|
79
|
+
{ access: { roles: ["Admin"] } },
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// ValidationError via a hook (post-schema business validation).
|
|
83
|
+
const createForHook = r.writeHandler(
|
|
84
|
+
"item:create-for-hook",
|
|
85
|
+
z.object({ name: z.string() }),
|
|
86
|
+
async (event, ctx) => {
|
|
87
|
+
const crud = createEventStoreExecutor(itemTable, itemEntity, { entityName: "item" });
|
|
88
|
+
return crud.create(event.payload, event.user, ctx.db);
|
|
89
|
+
},
|
|
90
|
+
{ access: { roles: ["Admin"] } },
|
|
91
|
+
);
|
|
92
|
+
r.hook("validation", createForHook, (data) => {
|
|
93
|
+
if (data["name"] === "forbidden") {
|
|
94
|
+
return [{ field: "name", error: "name_is_forbidden" }];
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ConflictError: generic 409, used e.g. for delete_restricted.
|
|
100
|
+
r.writeHandler(
|
|
101
|
+
"item:conflict",
|
|
102
|
+
z.object({}),
|
|
103
|
+
async () =>
|
|
104
|
+
writeFailure(
|
|
105
|
+
new ConflictError({
|
|
106
|
+
message: "would leave dangling references",
|
|
107
|
+
i18nKey: "errctr.errors.dependencyConflict",
|
|
108
|
+
details: { reason: "has_dependencies", blocker: "order" },
|
|
109
|
+
}),
|
|
110
|
+
),
|
|
111
|
+
{ access: { roles: ["Admin"] } },
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// UnprocessableError: business rule, reason surfaces under details.reason.
|
|
115
|
+
r.writeHandler(
|
|
116
|
+
"item:unprocessable",
|
|
117
|
+
z.object({}),
|
|
118
|
+
async () =>
|
|
119
|
+
writeFailure(
|
|
120
|
+
new UnprocessableError("already_fulfilled", {
|
|
121
|
+
i18nKey: "errctr.errors.alreadyFulfilled",
|
|
122
|
+
details: { stage: "ship" },
|
|
123
|
+
}),
|
|
124
|
+
),
|
|
125
|
+
{ access: { roles: ["Admin"] } },
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// AccessDeniedError in the handler body (distinct from dispatcher-level
|
|
129
|
+
// role check, which also surfaces AccessDeniedError — both land on 403).
|
|
130
|
+
r.writeHandler(
|
|
131
|
+
"item:explicit-deny",
|
|
132
|
+
z.object({}),
|
|
133
|
+
async () =>
|
|
134
|
+
writeFailure(
|
|
135
|
+
new AccessDeniedError({
|
|
136
|
+
message: "manual access deny",
|
|
137
|
+
details: { reason: "manual_deny" },
|
|
138
|
+
}),
|
|
139
|
+
),
|
|
140
|
+
{ access: { roles: ["Admin"] } },
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// NotFoundError thrown directly as KumikoError (in query handler — proves
|
|
144
|
+
// the throw-based path, not the writeFailure return-based path).
|
|
145
|
+
r.queryHandler(
|
|
146
|
+
"item:detail-strict",
|
|
147
|
+
z.object({ id: z.uuid() }),
|
|
148
|
+
async (event) => {
|
|
149
|
+
throw new NotFoundError("item", event.payload.id);
|
|
150
|
+
},
|
|
151
|
+
{ access: { openToAll: true } },
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// InternalError auto-wrap: handler raises an unexpected TypeError; the
|
|
155
|
+
// dispatcher wraps it so the wire body is the sanitized InternalError shape.
|
|
156
|
+
r.writeHandler(
|
|
157
|
+
"item:boom",
|
|
158
|
+
z.object({}),
|
|
159
|
+
async () => {
|
|
160
|
+
throw new TypeError("cannot_read_prop");
|
|
161
|
+
},
|
|
162
|
+
{ access: { roles: ["Admin"] } },
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Nested cause: user-thrown KumikoError with a .cause chain. The
|
|
166
|
+
// forensic info should stay server-side (Log), while the response body
|
|
167
|
+
// carries only the top-level class.
|
|
168
|
+
r.writeHandler(
|
|
169
|
+
"item:caused",
|
|
170
|
+
z.object({}),
|
|
171
|
+
async () => {
|
|
172
|
+
const inner = new Error("upstream_service_blew_up");
|
|
173
|
+
throw new ConflictError({
|
|
174
|
+
message: "conflict caused by upstream",
|
|
175
|
+
i18nKey: "errctr.errors.upstream",
|
|
176
|
+
details: { reason: "upstream_blew_up" },
|
|
177
|
+
cause: inner,
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
{ access: { roles: ["Admin"] } },
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
let stack: TestStack;
|
|
185
|
+
const admin = TestUsers.admin;
|
|
186
|
+
const guest = createTestUser({ id: 2, roles: ["Guest"] });
|
|
187
|
+
|
|
188
|
+
beforeAll(async () => {
|
|
189
|
+
stack = await setupTestStack({ features: [errorFeature] });
|
|
190
|
+
await createEntityTable(stack.db, itemEntity);
|
|
191
|
+
});
|
|
192
|
+
afterAll(async () => stack.cleanup());
|
|
193
|
+
beforeEach(async () => {
|
|
194
|
+
stack.events.reset();
|
|
195
|
+
await stack.db.delete(itemTable);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// --- Helpers ---
|
|
199
|
+
|
|
200
|
+
type ErrorBody = {
|
|
201
|
+
readonly code: string;
|
|
202
|
+
readonly i18nKey: string;
|
|
203
|
+
readonly message: string;
|
|
204
|
+
readonly details?: unknown;
|
|
205
|
+
readonly requestId?: string;
|
|
206
|
+
readonly timestamp: string;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
type AnyUser = {
|
|
210
|
+
readonly id: string;
|
|
211
|
+
readonly tenantId: TenantId;
|
|
212
|
+
readonly roles: readonly string[];
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
async function writeErrorBody(
|
|
216
|
+
type: string,
|
|
217
|
+
payload: unknown,
|
|
218
|
+
user: AnyUser = admin,
|
|
219
|
+
): Promise<{ status: number; body: { isSuccess: false; error: ErrorBody } }> {
|
|
220
|
+
const res = await stack.http.write(type, payload, user);
|
|
221
|
+
const body = (await res.json()) as { isSuccess: false; error: ErrorBody };
|
|
222
|
+
return { status: res.status, body };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// The invariant the client SDK keys off. Every 4xx/5xx from /write + /batch
|
|
226
|
+
// must satisfy it — independent of which Kumiko class produced the error.
|
|
227
|
+
function expectErrorShape(error: ErrorBody): void {
|
|
228
|
+
expect(typeof error.code).toBe("string");
|
|
229
|
+
expect(error.code.length).toBeGreaterThan(0);
|
|
230
|
+
expect(typeof error.i18nKey).toBe("string");
|
|
231
|
+
expect(error.i18nKey).toMatch(/^[a-z]/); // namespaced key, not a sentence
|
|
232
|
+
expect(typeof error.message).toBe("string");
|
|
233
|
+
expect(error.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// =============================================================================
|
|
237
|
+
// One test per error class — status, code, i18nKey, details shape
|
|
238
|
+
// =============================================================================
|
|
239
|
+
|
|
240
|
+
describe("error contract: wire format per class", () => {
|
|
241
|
+
test("ValidationError (Zod) → 400, details.fields[] with path + code + i18nKey", async () => {
|
|
242
|
+
const { status, body } = await writeErrorBody("errctr:write:item:create-strict", { name: "x" });
|
|
243
|
+
expect(status).toBe(400);
|
|
244
|
+
expect(body.isSuccess).toBe(false);
|
|
245
|
+
expectErrorShape(body.error);
|
|
246
|
+
expect(body.error.code).toBe("validation_error");
|
|
247
|
+
|
|
248
|
+
const details = body.error.details as {
|
|
249
|
+
fields: Array<{ path: string; code: string; i18nKey: string }>;
|
|
250
|
+
};
|
|
251
|
+
expect(details.fields.length).toBeGreaterThan(0);
|
|
252
|
+
const nameIssue = details.fields.find((f) => f.path === "name");
|
|
253
|
+
expect(nameIssue).toBeDefined();
|
|
254
|
+
expect(nameIssue?.code).toBe("too_small");
|
|
255
|
+
expect(nameIssue?.i18nKey).toBe("errors.validation.too_small");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("ValidationError (hook) → 400, details.fields carries hook-reported errors", async () => {
|
|
259
|
+
const { status, body } = await writeErrorBody("errctr:write:item:create-for-hook", {
|
|
260
|
+
name: "forbidden",
|
|
261
|
+
});
|
|
262
|
+
expect(status).toBe(400);
|
|
263
|
+
expect(body.error.code).toBe("validation_error");
|
|
264
|
+
const details = body.error.details as { fields: Array<{ path: string; code: string }> };
|
|
265
|
+
expect(details.fields).toContainEqual(
|
|
266
|
+
expect.objectContaining({ path: "name", code: "name_is_forbidden" }),
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("AccessDeniedError (handler throws) → 403, code access_denied", async () => {
|
|
271
|
+
const { status, body } = await writeErrorBody("errctr:write:item:explicit-deny", {});
|
|
272
|
+
expect(status).toBe(403);
|
|
273
|
+
expect(body.error.code).toBe("access_denied");
|
|
274
|
+
expectErrorShape(body.error);
|
|
275
|
+
expect((body.error.details as { reason: string }).reason).toBe("manual_deny");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("AccessDeniedError (dispatcher role check) → 403", async () => {
|
|
279
|
+
const { status, body } = await writeErrorBody("errctr:write:item:create", { name: "x" }, guest);
|
|
280
|
+
expect(status).toBe(403);
|
|
281
|
+
expect(body.error.code).toBe("access_denied");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("NotFoundError (thrown from query handler) → 404 via /query", async () => {
|
|
285
|
+
const res = await stack.http.raw(
|
|
286
|
+
"POST",
|
|
287
|
+
"/api/query",
|
|
288
|
+
{
|
|
289
|
+
type: "errctr:query:item:detail-strict",
|
|
290
|
+
payload: { id: "00000000-0000-4000-8000-000000000999" },
|
|
291
|
+
},
|
|
292
|
+
await authHeaders(admin),
|
|
293
|
+
);
|
|
294
|
+
expect(res.status).toBe(404);
|
|
295
|
+
const body = (await res.json()) as { error: ErrorBody };
|
|
296
|
+
expectErrorShape(body.error);
|
|
297
|
+
expect(body.error.code).toBe("not_found");
|
|
298
|
+
expect(body.error.details).toMatchObject({
|
|
299
|
+
reason: "item_not_found",
|
|
300
|
+
entity: "item",
|
|
301
|
+
id: "00000000-0000-4000-8000-000000000999",
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("NotFoundError (unknown write handler) → 404", async () => {
|
|
306
|
+
const { status, body } = await writeErrorBody("errctr:write:item:nope", {});
|
|
307
|
+
expect(status).toBe(404);
|
|
308
|
+
expect(body.error.code).toBe("not_found");
|
|
309
|
+
expect(body.error.details).toMatchObject({ entity: "handler", id: expect.any(String) });
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("ConflictError → 409, details carries the business reason", async () => {
|
|
313
|
+
const { status, body } = await writeErrorBody("errctr:write:item:conflict", {});
|
|
314
|
+
expect(status).toBe(409);
|
|
315
|
+
expect(body.error.code).toBe("conflict");
|
|
316
|
+
expect(body.error.details).toMatchObject({ reason: "has_dependencies", blocker: "order" });
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("VersionConflictError → 409, details carries version info", async () => {
|
|
320
|
+
const created = await stack.http.writeOk<{ id: number }>(
|
|
321
|
+
"errctr:write:item:create",
|
|
322
|
+
{ name: "Widget" },
|
|
323
|
+
admin,
|
|
324
|
+
);
|
|
325
|
+
// First update succeeds — current version becomes 2.
|
|
326
|
+
await stack.http.writeOk(
|
|
327
|
+
"errctr:write:item:update",
|
|
328
|
+
{ id: created.id, version: 1, changes: { name: "Widget v2" } },
|
|
329
|
+
admin,
|
|
330
|
+
);
|
|
331
|
+
// Second update with the already-consumed version=1 → VersionConflictError.
|
|
332
|
+
const { status, body } = await writeErrorBody("errctr:write:item:update", {
|
|
333
|
+
id: created.id,
|
|
334
|
+
version: 1,
|
|
335
|
+
changes: { name: "Widget stale" },
|
|
336
|
+
});
|
|
337
|
+
expect(status).toBe(409);
|
|
338
|
+
expect(body.error.code).toBe("version_conflict");
|
|
339
|
+
expect(body.error.details).toMatchObject({
|
|
340
|
+
entityId: created.id,
|
|
341
|
+
expectedVersion: 1,
|
|
342
|
+
currentVersion: 2,
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("UnprocessableError → 422, details.reason preserves the business reason", async () => {
|
|
347
|
+
const { status, body } = await writeErrorBody("errctr:write:item:unprocessable", {});
|
|
348
|
+
expect(status).toBe(422);
|
|
349
|
+
expect(body.error.code).toBe("unprocessable");
|
|
350
|
+
expect(body.error.details).toMatchObject({ reason: "already_fulfilled", stage: "ship" });
|
|
351
|
+
expect(body.error.i18nKey).toBe("errctr.errors.alreadyFulfilled");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("InternalError (auto-wrap) → 500, generic message; dev exposes cause, prod hides it", async () => {
|
|
355
|
+
// Default test-Run läuft mit NODE_ENV !== "production" — dev-Modus exposed
|
|
356
|
+
// die cause-Details (causeName/causeMessage/causeStack), damit Handler-
|
|
357
|
+
// Bugs nicht als nackter "internal error" zurückkommen. Memory:
|
|
358
|
+
// Framework-DX Fixes 2026-04-25.
|
|
359
|
+
const dev = await writeErrorBody("errctr:write:item:boom", {});
|
|
360
|
+
expect(dev.status).toBe(500);
|
|
361
|
+
expect(dev.body.error.code).toBe("internal_error");
|
|
362
|
+
// Generic message für Client (keine Stack-Leak im message-Feld), aber
|
|
363
|
+
// details enthalten die cause-Zusammenfassung für DX.
|
|
364
|
+
expect(dev.body.error.message).not.toContain("cannot_read_prop");
|
|
365
|
+
expect(dev.body.error.details).toMatchObject({
|
|
366
|
+
causeName: "TypeError",
|
|
367
|
+
causeMessage: "cannot_read_prop",
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// Production-Modus: details werden gestripped, der Stack lebt nur im Log.
|
|
371
|
+
const prevEnv = process.env["NODE_ENV"];
|
|
372
|
+
process.env["NODE_ENV"] = "production";
|
|
373
|
+
try {
|
|
374
|
+
const prod = await writeErrorBody("errctr:write:item:boom", {});
|
|
375
|
+
expect(prod.status).toBe(500);
|
|
376
|
+
expect(prod.body.error.code).toBe("internal_error");
|
|
377
|
+
expect(prod.body.error).not.toHaveProperty("details");
|
|
378
|
+
expect(prod.body.error.message).not.toContain("cannot_read_prop");
|
|
379
|
+
} finally {
|
|
380
|
+
if (prevEnv === undefined) delete process.env["NODE_ENV"];
|
|
381
|
+
else process.env["NODE_ENV"] = prevEnv;
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// =============================================================================
|
|
387
|
+
// Cross-cutting: requestId, timestamp, cause-chain privacy
|
|
388
|
+
// =============================================================================
|
|
389
|
+
|
|
390
|
+
describe("error contract: cross-cutting guarantees", () => {
|
|
391
|
+
test("every error response includes requestId when a header is supplied", async () => {
|
|
392
|
+
const token = await stack.jwt.sign(admin);
|
|
393
|
+
const res = await stack.app.request("/api/write", {
|
|
394
|
+
method: "POST",
|
|
395
|
+
headers: {
|
|
396
|
+
Authorization: `Bearer ${token}`,
|
|
397
|
+
"Content-Type": "application/json",
|
|
398
|
+
"X-Request-ID": "custom-err-req",
|
|
399
|
+
},
|
|
400
|
+
body: JSON.stringify({
|
|
401
|
+
type: "errctr:write:item:conflict",
|
|
402
|
+
payload: {},
|
|
403
|
+
}),
|
|
404
|
+
});
|
|
405
|
+
const body = (await res.json()) as { isSuccess: false; error: ErrorBody };
|
|
406
|
+
expect(body.error.requestId).toBe("custom-err-req");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("error response contains an ISO timestamp", async () => {
|
|
410
|
+
const { body } = await writeErrorBody("errctr:write:item:conflict", {});
|
|
411
|
+
const parsed = new Date(body.error.timestamp);
|
|
412
|
+
expect(Number.isNaN(parsed.getTime())).toBe(false);
|
|
413
|
+
// Within a sensible window — not an epoch zero, not a year-3000 garbage
|
|
414
|
+
// value — proves the serializer ran now and didn't copy a stale value.
|
|
415
|
+
expect(Math.abs(Date.now() - parsed.getTime())).toBeLessThan(10_000);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("cause chain stays server-side: response body has no cause field", async () => {
|
|
419
|
+
const { body } = await writeErrorBody("errctr:write:item:caused", {});
|
|
420
|
+
expect(body.error).not.toHaveProperty("cause");
|
|
421
|
+
expect(body.error).not.toHaveProperty("stack");
|
|
422
|
+
// The top-level error is exposed, but the "upstream_service_blew_up"
|
|
423
|
+
// cause message must not leak through.
|
|
424
|
+
expect(JSON.stringify(body.error)).not.toContain("upstream_service_blew_up");
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
async function authHeaders(user: {
|
|
429
|
+
id: string;
|
|
430
|
+
tenantId: TenantId;
|
|
431
|
+
roles: readonly string[];
|
|
432
|
+
}): Promise<Record<string, string>> {
|
|
433
|
+
const token = await stack.jwt.sign(user);
|
|
434
|
+
return { Authorization: `Bearer ${token}` };
|
|
435
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { buildServer } from "../api/server";
|
|
4
|
+
import { createEventStoreExecutor } from "../db/event-store-executor";
|
|
5
|
+
import { buildDrizzleTable } from "../db/table-builder";
|
|
6
|
+
import {
|
|
7
|
+
createEntity,
|
|
8
|
+
createNumberField,
|
|
9
|
+
createRegistry,
|
|
10
|
+
createTextField,
|
|
11
|
+
defineFeature,
|
|
12
|
+
type SessionUser,
|
|
13
|
+
} from "../engine";
|
|
14
|
+
import {
|
|
15
|
+
createEntityTable,
|
|
16
|
+
createTestDb,
|
|
17
|
+
createTestRedis,
|
|
18
|
+
createTestUser,
|
|
19
|
+
type TestDb,
|
|
20
|
+
type TestRedis,
|
|
21
|
+
TestUsers,
|
|
22
|
+
} from "../stack";
|
|
23
|
+
|
|
24
|
+
// --- Entity with field-level access ---
|
|
25
|
+
|
|
26
|
+
const employeeEntity = createEntity({
|
|
27
|
+
table: "fa_employees",
|
|
28
|
+
fields: {
|
|
29
|
+
email: createTextField({ required: true }),
|
|
30
|
+
firstName: createTextField(),
|
|
31
|
+
salary: createNumberField({ access: { read: ["Admin", "Accounting"], write: ["Admin"] } }),
|
|
32
|
+
notes: createTextField({ access: { read: ["Admin"], write: ["Admin"] } }),
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const employeeTable = buildDrizzleTable("employee", employeeEntity);
|
|
37
|
+
|
|
38
|
+
// --- Test infra ---
|
|
39
|
+
|
|
40
|
+
const JWT_SECRET = "field-access-test-secret-minimum-32-chars!!";
|
|
41
|
+
|
|
42
|
+
let testDb: TestDb;
|
|
43
|
+
let testRedis: TestRedis;
|
|
44
|
+
let app: ReturnType<typeof buildServer>["app"];
|
|
45
|
+
let jwt: ReturnType<typeof buildServer>["jwt"];
|
|
46
|
+
|
|
47
|
+
const adminUser = TestUsers.admin;
|
|
48
|
+
const accountingUser = createTestUser({ id: 2, roles: ["Accounting"] });
|
|
49
|
+
const employeeUser = createTestUser({ id: 3, roles: ["Employee"] });
|
|
50
|
+
|
|
51
|
+
beforeAll(async () => {
|
|
52
|
+
testDb = await createTestDb();
|
|
53
|
+
testRedis = await createTestRedis();
|
|
54
|
+
|
|
55
|
+
await createEntityTable(testDb.db, employeeEntity);
|
|
56
|
+
|
|
57
|
+
const feature = defineFeature("employees", (r) => {
|
|
58
|
+
r.entity("employee", employeeEntity);
|
|
59
|
+
|
|
60
|
+
r.writeHandler(
|
|
61
|
+
"employee:create",
|
|
62
|
+
z.object({
|
|
63
|
+
email: z.string(),
|
|
64
|
+
firstName: z.string().optional(),
|
|
65
|
+
salary: z.number().optional(),
|
|
66
|
+
notes: z.string().optional(),
|
|
67
|
+
}),
|
|
68
|
+
async (event, ctx) => {
|
|
69
|
+
const db = ctx.db;
|
|
70
|
+
const crud = createEventStoreExecutor(employeeTable, employeeEntity, {
|
|
71
|
+
entityName: "employee",
|
|
72
|
+
});
|
|
73
|
+
return crud.create(event.payload, event.user, db);
|
|
74
|
+
},
|
|
75
|
+
{ access: { roles: ["Admin", "Accounting", "Employee"] } },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
r.writeHandler(
|
|
79
|
+
"employee:update",
|
|
80
|
+
z.object({
|
|
81
|
+
id: z.uuid(),
|
|
82
|
+
version: z.number().optional(),
|
|
83
|
+
changes: z.record(z.string(), z.unknown()),
|
|
84
|
+
}),
|
|
85
|
+
async (event, ctx) => {
|
|
86
|
+
const db = ctx.db;
|
|
87
|
+
const crud = createEventStoreExecutor(employeeTable, employeeEntity, {
|
|
88
|
+
entityName: "employee",
|
|
89
|
+
});
|
|
90
|
+
return crud.update(event.payload, event.user, db);
|
|
91
|
+
},
|
|
92
|
+
{ access: { roles: ["Admin", "Accounting", "Employee"] } },
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
r.queryHandler(
|
|
96
|
+
"employee:detail",
|
|
97
|
+
z.object({ id: z.uuid() }),
|
|
98
|
+
async (query, ctx) => {
|
|
99
|
+
const db = ctx.db;
|
|
100
|
+
const crud = createEventStoreExecutor(employeeTable, employeeEntity, {
|
|
101
|
+
entityName: "employee",
|
|
102
|
+
});
|
|
103
|
+
return crud.detail(query.payload, query.user, db);
|
|
104
|
+
},
|
|
105
|
+
{ access: { roles: ["Admin", "Accounting", "Employee"] } },
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const registry = createRegistry([feature]);
|
|
110
|
+
const server = buildServer({
|
|
111
|
+
registry,
|
|
112
|
+
context: { db: testDb.db, redis: testRedis.redis },
|
|
113
|
+
jwtSecret: JWT_SECRET,
|
|
114
|
+
});
|
|
115
|
+
app = server.app;
|
|
116
|
+
jwt = server.jwt;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
afterAll(async () => {
|
|
120
|
+
await testDb.cleanup();
|
|
121
|
+
await testRedis.cleanup();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
async function req(method: string, path: string, user: SessionUser, body?: unknown) {
|
|
125
|
+
const token = await jwt.sign(user);
|
|
126
|
+
const init: RequestInit = {
|
|
127
|
+
method,
|
|
128
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
129
|
+
};
|
|
130
|
+
if (body) init.body = JSON.stringify(body);
|
|
131
|
+
return app.request(path, init);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Seed a record as Admin (has all field access)
|
|
135
|
+
async function seedEmployee(): Promise<number> {
|
|
136
|
+
const res = await (
|
|
137
|
+
await req("POST", "/api/write", adminUser, {
|
|
138
|
+
type: "employees:write:employee:create",
|
|
139
|
+
payload: { email: "test@test.de", firstName: "Test", salary: 75000, notes: "Internal note" },
|
|
140
|
+
})
|
|
141
|
+
).json();
|
|
142
|
+
return res.data.id;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// =============================================================================
|
|
146
|
+
// READ: field filtering based on role
|
|
147
|
+
// =============================================================================
|
|
148
|
+
|
|
149
|
+
describe("field-level read access", () => {
|
|
150
|
+
let employeeId: number;
|
|
151
|
+
|
|
152
|
+
beforeAll(async () => {
|
|
153
|
+
employeeId = await seedEmployee();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("Admin sees all fields", async () => {
|
|
157
|
+
const res = await (
|
|
158
|
+
await req("POST", "/api/query", adminUser, {
|
|
159
|
+
type: "employees:query:employee:detail",
|
|
160
|
+
payload: { id: employeeId },
|
|
161
|
+
})
|
|
162
|
+
).json();
|
|
163
|
+
|
|
164
|
+
expect(res.data["email"]).toBe("test@test.de");
|
|
165
|
+
expect(res.data["salary"]).toBe(75000);
|
|
166
|
+
expect(res.data["notes"]).toBe("Internal note");
|
|
167
|
+
expect(res.data["firstName"]).toBe("Test");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("Accounting sees salary but not notes", async () => {
|
|
171
|
+
const res = await (
|
|
172
|
+
await req("POST", "/api/query", accountingUser, {
|
|
173
|
+
type: "employees:query:employee:detail",
|
|
174
|
+
payload: { id: employeeId },
|
|
175
|
+
})
|
|
176
|
+
).json();
|
|
177
|
+
|
|
178
|
+
expect(res.data["email"]).toBe("test@test.de");
|
|
179
|
+
expect(res.data["salary"]).toBe(75000);
|
|
180
|
+
expect(res.data["notes"]).toBeUndefined();
|
|
181
|
+
expect(res.data["firstName"]).toBe("Test");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("Employee sees neither salary nor notes", async () => {
|
|
185
|
+
const res = await (
|
|
186
|
+
await req("POST", "/api/query", employeeUser, {
|
|
187
|
+
type: "employees:query:employee:detail",
|
|
188
|
+
payload: { id: employeeId },
|
|
189
|
+
})
|
|
190
|
+
).json();
|
|
191
|
+
|
|
192
|
+
expect(res.data["email"]).toBe("test@test.de");
|
|
193
|
+
expect(res.data["firstName"]).toBe("Test");
|
|
194
|
+
expect(res.data["salary"]).toBeUndefined();
|
|
195
|
+
expect(res.data["notes"]).toBeUndefined();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// =============================================================================
|
|
200
|
+
// WRITE: reject forbidden field changes
|
|
201
|
+
// =============================================================================
|
|
202
|
+
|
|
203
|
+
describe("field-level write access", () => {
|
|
204
|
+
test("Admin can update salary", async () => {
|
|
205
|
+
const id = await seedEmployee();
|
|
206
|
+
const res = await (
|
|
207
|
+
await req("POST", "/api/write", adminUser, {
|
|
208
|
+
type: "employees:write:employee:update",
|
|
209
|
+
payload: { id, changes: { salary: 80000 }, version: 1 },
|
|
210
|
+
})
|
|
211
|
+
).json();
|
|
212
|
+
|
|
213
|
+
expect(res.isSuccess).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("Employee cannot update salary — error", async () => {
|
|
217
|
+
const id = await seedEmployee();
|
|
218
|
+
const res = await (
|
|
219
|
+
await req("POST", "/api/write", employeeUser, {
|
|
220
|
+
type: "employees:write:employee:update",
|
|
221
|
+
payload: { id, changes: { salary: 999999 }, version: 1 },
|
|
222
|
+
})
|
|
223
|
+
).json();
|
|
224
|
+
|
|
225
|
+
expect(res.isSuccess).toBe(false);
|
|
226
|
+
expect(res.error.code).toBe("access_denied");
|
|
227
|
+
expect(res.error.details).toMatchObject({ reason: "field_access_denied", field: "salary" });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("Employee can update firstName", async () => {
|
|
231
|
+
const id = await seedEmployee();
|
|
232
|
+
const res = await (
|
|
233
|
+
await req("POST", "/api/write", employeeUser, {
|
|
234
|
+
type: "employees:write:employee:update",
|
|
235
|
+
payload: { id, changes: { firstName: "Updated" }, version: 1 },
|
|
236
|
+
})
|
|
237
|
+
).json();
|
|
238
|
+
|
|
239
|
+
expect(res.isSuccess).toBe(true);
|
|
240
|
+
expect(res.data.data["firstName"]).toBe("Updated");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("Employee cannot create with salary — error", async () => {
|
|
244
|
+
const res = await (
|
|
245
|
+
await req("POST", "/api/write", employeeUser, {
|
|
246
|
+
type: "employees:write:employee:create",
|
|
247
|
+
payload: { email: "new@test.de", salary: 50000 },
|
|
248
|
+
})
|
|
249
|
+
).json();
|
|
250
|
+
|
|
251
|
+
expect(res.isSuccess).toBe(false);
|
|
252
|
+
expect(res.error.code).toBe("access_denied");
|
|
253
|
+
expect(res.error.details).toMatchObject({ reason: "field_access_denied", field: "salary" });
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("Accounting cannot update salary (only read)", async () => {
|
|
257
|
+
const id = await seedEmployee();
|
|
258
|
+
const res = await (
|
|
259
|
+
await req("POST", "/api/write", accountingUser, {
|
|
260
|
+
type: "employees:write:employee:update",
|
|
261
|
+
payload: { id, changes: { salary: 60000 }, version: 1 },
|
|
262
|
+
})
|
|
263
|
+
).json();
|
|
264
|
+
|
|
265
|
+
expect(res.isSuccess).toBe(false);
|
|
266
|
+
expect(res.error.code).toBe("access_denied");
|
|
267
|
+
expect(res.error.details).toMatchObject({ reason: "field_access_denied", field: "salary" });
|
|
268
|
+
});
|
|
269
|
+
});
|