@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,340 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createEventStoreExecutor } from "../db/event-store-executor";
|
|
4
|
+
import { buildDrizzleTable } from "../db/table-builder";
|
|
5
|
+
import {
|
|
6
|
+
createBooleanField,
|
|
7
|
+
createEntity,
|
|
8
|
+
createSelectField,
|
|
9
|
+
createTextField,
|
|
10
|
+
defineFeature,
|
|
11
|
+
} from "../engine";
|
|
12
|
+
import { createEntityTable, setupTestStack, type TestStack, TestUsers } from "../stack";
|
|
13
|
+
import { expectErrorIncludes } from "../testing";
|
|
14
|
+
|
|
15
|
+
// Two entities, both with a field named `status`, but different transitions.
|
|
16
|
+
// Before the fix, the dispatcher cached the transition map by `fieldName`
|
|
17
|
+
// alone — so whichever entity ran through the pipeline first would poison
|
|
18
|
+
// the cache, and the other entity would be validated against the wrong map.
|
|
19
|
+
|
|
20
|
+
const invoiceEntity = createEntity({
|
|
21
|
+
table: "tg_invoices",
|
|
22
|
+
fields: {
|
|
23
|
+
title: createTextField({ required: true }),
|
|
24
|
+
status: createSelectField({ options: ["draft", "sent", "paid"] as const, default: "draft" }),
|
|
25
|
+
},
|
|
26
|
+
transitions: {
|
|
27
|
+
status: {
|
|
28
|
+
draft: ["sent"],
|
|
29
|
+
sent: ["paid"],
|
|
30
|
+
paid: [],
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const orderEntity = createEntity({
|
|
36
|
+
table: "tg_orders",
|
|
37
|
+
fields: {
|
|
38
|
+
title: createTextField({ required: true }),
|
|
39
|
+
status: createSelectField({
|
|
40
|
+
options: ["open", "shipped", "delivered"] as const,
|
|
41
|
+
default: "open",
|
|
42
|
+
}),
|
|
43
|
+
},
|
|
44
|
+
transitions: {
|
|
45
|
+
status: {
|
|
46
|
+
open: ["shipped"],
|
|
47
|
+
shipped: ["delivered"],
|
|
48
|
+
delivered: [],
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// A soft-deletable entity to verify the auto-guard skips isDeleted rows.
|
|
54
|
+
const ticketEntity = createEntity({
|
|
55
|
+
table: "tg_tickets",
|
|
56
|
+
fields: {
|
|
57
|
+
title: createTextField({ required: true }),
|
|
58
|
+
status: createSelectField({ options: ["open", "closed"] as const, default: "open" }),
|
|
59
|
+
isDeleted: createBooleanField({ default: false }),
|
|
60
|
+
},
|
|
61
|
+
softDelete: true,
|
|
62
|
+
transitions: {
|
|
63
|
+
status: {
|
|
64
|
+
open: ["closed"],
|
|
65
|
+
closed: [],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const invoiceTable = buildDrizzleTable("invoice", invoiceEntity);
|
|
71
|
+
const orderTable = buildDrizzleTable("order", orderEntity);
|
|
72
|
+
const ticketTable = buildDrizzleTable("ticket", ticketEntity);
|
|
73
|
+
|
|
74
|
+
const feature = defineFeature("txguard", (r) => {
|
|
75
|
+
r.entity("invoice", invoiceEntity);
|
|
76
|
+
r.entity("order", orderEntity);
|
|
77
|
+
r.entity("ticket", ticketEntity);
|
|
78
|
+
|
|
79
|
+
r.writeHandler(
|
|
80
|
+
"invoice:create",
|
|
81
|
+
z.object({ title: z.string(), status: z.string().optional() }),
|
|
82
|
+
async (event, ctx) =>
|
|
83
|
+
createEventStoreExecutor(invoiceTable, invoiceEntity, { entityName: "invoice" }).create(
|
|
84
|
+
event.payload,
|
|
85
|
+
event.user,
|
|
86
|
+
ctx.db,
|
|
87
|
+
),
|
|
88
|
+
{ access: { openToAll: true } },
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
r.writeHandler(
|
|
92
|
+
"invoice:update",
|
|
93
|
+
z.object({
|
|
94
|
+
id: z.uuid(),
|
|
95
|
+
version: z.number().optional(),
|
|
96
|
+
changes: z.record(z.string(), z.unknown()),
|
|
97
|
+
}),
|
|
98
|
+
async (event, ctx) =>
|
|
99
|
+
createEventStoreExecutor(invoiceTable, invoiceEntity, { entityName: "invoice" }).update(
|
|
100
|
+
event.payload,
|
|
101
|
+
event.user,
|
|
102
|
+
ctx.db,
|
|
103
|
+
),
|
|
104
|
+
{ access: { openToAll: true } },
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
r.writeHandler(
|
|
108
|
+
"order:create",
|
|
109
|
+
z.object({ title: z.string(), status: z.string().optional() }),
|
|
110
|
+
async (event, ctx) =>
|
|
111
|
+
createEventStoreExecutor(orderTable, orderEntity, { entityName: "order" }).create(
|
|
112
|
+
event.payload,
|
|
113
|
+
event.user,
|
|
114
|
+
ctx.db,
|
|
115
|
+
),
|
|
116
|
+
{ access: { openToAll: true } },
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
r.writeHandler(
|
|
120
|
+
"order:update",
|
|
121
|
+
z.object({
|
|
122
|
+
id: z.uuid(),
|
|
123
|
+
version: z.number().optional(),
|
|
124
|
+
changes: z.record(z.string(), z.unknown()),
|
|
125
|
+
}),
|
|
126
|
+
async (event, ctx) =>
|
|
127
|
+
createEventStoreExecutor(orderTable, orderEntity, { entityName: "order" }).update(
|
|
128
|
+
event.payload,
|
|
129
|
+
event.user,
|
|
130
|
+
ctx.db,
|
|
131
|
+
),
|
|
132
|
+
{ access: { openToAll: true } },
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
r.writeHandler(
|
|
136
|
+
"ticket:create",
|
|
137
|
+
z.object({ title: z.string() }),
|
|
138
|
+
async (event, ctx) =>
|
|
139
|
+
createEventStoreExecutor(ticketTable, ticketEntity, { entityName: "ticket" }).create(
|
|
140
|
+
event.payload,
|
|
141
|
+
event.user,
|
|
142
|
+
ctx.db,
|
|
143
|
+
),
|
|
144
|
+
{ access: { openToAll: true } },
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
r.writeHandler(
|
|
148
|
+
"ticket:delete",
|
|
149
|
+
z.object({ id: z.uuid() }),
|
|
150
|
+
async (event, ctx) =>
|
|
151
|
+
createEventStoreExecutor(ticketTable, ticketEntity, { entityName: "ticket" }).delete(
|
|
152
|
+
event.payload,
|
|
153
|
+
event.user,
|
|
154
|
+
ctx.db,
|
|
155
|
+
),
|
|
156
|
+
{ access: { openToAll: true } },
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
r.writeHandler(
|
|
160
|
+
"ticket:update",
|
|
161
|
+
z.object({
|
|
162
|
+
id: z.uuid(),
|
|
163
|
+
version: z.number().optional(),
|
|
164
|
+
changes: z.record(z.string(), z.unknown()),
|
|
165
|
+
}),
|
|
166
|
+
async (event, ctx) =>
|
|
167
|
+
createEventStoreExecutor(ticketTable, ticketEntity, { entityName: "ticket" }).update(
|
|
168
|
+
event.payload,
|
|
169
|
+
event.user,
|
|
170
|
+
ctx.db,
|
|
171
|
+
),
|
|
172
|
+
{ access: { openToAll: true } },
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
let stack: TestStack;
|
|
177
|
+
const admin = TestUsers.admin;
|
|
178
|
+
|
|
179
|
+
beforeAll(async () => {
|
|
180
|
+
stack = await setupTestStack({ features: [feature] });
|
|
181
|
+
await createEntityTable(stack.db, invoiceEntity);
|
|
182
|
+
await createEntityTable(stack.db, orderEntity);
|
|
183
|
+
await createEntityTable(stack.db, ticketEntity);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
afterAll(async () => {
|
|
187
|
+
await stack.cleanup();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("auto transition guard: per-entity transition map (cache key includes entity)", () => {
|
|
191
|
+
test("entity A's transitions don't leak to entity B when both have `status`", async () => {
|
|
192
|
+
// Create both rows in their default states (draft / open)
|
|
193
|
+
const invoice = await stack.http.writeOk<{ id: number }>(
|
|
194
|
+
"txguard:write:invoice:create",
|
|
195
|
+
{ title: "Inv-1" },
|
|
196
|
+
admin,
|
|
197
|
+
);
|
|
198
|
+
const order = await stack.http.writeOk<{ id: number }>(
|
|
199
|
+
"txguard:write:order:create",
|
|
200
|
+
{ title: "Ord-1" },
|
|
201
|
+
admin,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// Invoice: draft → sent is ALLOWED by invoice transitions.
|
|
205
|
+
// If the cache collided with order's map (open→shipped), the dispatcher
|
|
206
|
+
// would reject "sent" as not a valid target from any known state.
|
|
207
|
+
const invoiceResult = await stack.http.writeOk<{ data: { status: string } }>(
|
|
208
|
+
"txguard:write:invoice:update",
|
|
209
|
+
{ id: invoice["id"], changes: { status: "sent" }, version: 1 },
|
|
210
|
+
admin,
|
|
211
|
+
);
|
|
212
|
+
expect(invoiceResult.data.status).toBe("sent");
|
|
213
|
+
|
|
214
|
+
// Order: open → shipped is ALLOWED by order transitions.
|
|
215
|
+
// If the cache now holds invoice's map, this would be rejected.
|
|
216
|
+
const orderResult = await stack.http.writeOk<{ data: { status: string } }>(
|
|
217
|
+
"txguard:write:order:update",
|
|
218
|
+
{ id: order["id"], changes: { status: "shipped" }, version: 1 },
|
|
219
|
+
admin,
|
|
220
|
+
);
|
|
221
|
+
expect(orderResult.data.status).toBe("shipped");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("invalid transition on entity A still rejects (guard actually fires)", async () => {
|
|
225
|
+
const invoice = await stack.http.writeOk<{ id: number }>(
|
|
226
|
+
"txguard:write:invoice:create",
|
|
227
|
+
{ title: "Inv-2" },
|
|
228
|
+
admin,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// draft → paid is NOT allowed (only draft → sent, sent → paid)
|
|
232
|
+
const err = await stack.http.writeErr(
|
|
233
|
+
"txguard:write:invoice:update",
|
|
234
|
+
{ id: invoice["id"], changes: { status: "paid" }, version: 1 },
|
|
235
|
+
admin,
|
|
236
|
+
);
|
|
237
|
+
expectErrorIncludes(err, "Invalid transition");
|
|
238
|
+
expectErrorIncludes(err, "draft");
|
|
239
|
+
expectErrorIncludes(err, "paid");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("invalid transition uses entity B's own map, not a leaked one", async () => {
|
|
243
|
+
const order = await stack.http.writeOk<{ id: number }>(
|
|
244
|
+
"txguard:write:order:create",
|
|
245
|
+
{ title: "Ord-2" },
|
|
246
|
+
admin,
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
// open → delivered is NOT allowed (only open → shipped, shipped → delivered)
|
|
250
|
+
const err = await stack.http.writeErr(
|
|
251
|
+
"txguard:write:order:update",
|
|
252
|
+
{ id: order["id"], changes: { status: "delivered" }, version: 1 },
|
|
253
|
+
admin,
|
|
254
|
+
);
|
|
255
|
+
expectErrorIncludes(err, "Invalid transition");
|
|
256
|
+
expectErrorIncludes(err, "open");
|
|
257
|
+
expectErrorIncludes(err, "delivered");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("soft-deleted rows bypass the guard (no state-machine enforcement on zombies)", async () => {
|
|
261
|
+
const ticket = await stack.http.writeOk<{ id: string }>(
|
|
262
|
+
"txguard:write:ticket:create",
|
|
263
|
+
{ title: "T-1" },
|
|
264
|
+
admin,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// Raw-DB-mark-deleted — we need a soft-deleted row whose status is a
|
|
268
|
+
// terminal state. If the guard fired, any status write would throw
|
|
269
|
+
// "Invalid transition: closed → <x>". We want it silently skipped.
|
|
270
|
+
const { eq } = await import("drizzle-orm");
|
|
271
|
+
await stack.db
|
|
272
|
+
.update(ticketTable)
|
|
273
|
+
.set({ status: "closed", isDeleted: true })
|
|
274
|
+
.where(eq(ticketTable["id"], ticket["id"]));
|
|
275
|
+
|
|
276
|
+
// Attempting to move a deleted ticket to "open" would normally violate
|
|
277
|
+
// "closed → []" (no allowed targets). With the softDelete skip, the
|
|
278
|
+
// guard steps aside and the request only fails because CrudExecutor
|
|
279
|
+
// filters deleted rows from updates — giving a `not_found`, not a
|
|
280
|
+
// transition error. That distinction proves the guard skipped.
|
|
281
|
+
const err = await stack.http.writeErr(
|
|
282
|
+
"txguard:write:ticket:update",
|
|
283
|
+
{ id: ticket["id"], changes: { status: "open" }, version: 1 },
|
|
284
|
+
admin,
|
|
285
|
+
);
|
|
286
|
+
// Guard was skipped → we don't see "Invalid transition", we see a different
|
|
287
|
+
// failure (soft-deleted row is filtered out of the lookup, so "not_found").
|
|
288
|
+
expect(JSON.stringify(err)).not.toContain("Invalid transition");
|
|
289
|
+
expectErrorIncludes(err, "not_found");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("concurrent writes on the same row serialize via SELECT FOR UPDATE", async () => {
|
|
293
|
+
// Two parallel requests both trying to move the SAME invoice from draft.
|
|
294
|
+
// One legal: draft → sent. One deliberately stale (would also be legal
|
|
295
|
+
// draft → sent, but since we hold the row lock, the second caller sees
|
|
296
|
+
// the updated state `sent` when its turn comes and must fail — either
|
|
297
|
+
// via the transition guard (sent → sent is not defined) or version
|
|
298
|
+
// conflict. Without the FOR UPDATE both could snapshot `draft` under
|
|
299
|
+
// READ COMMITTED and race past the guard; the second UPDATE would then
|
|
300
|
+
// fail only via the optimistic lock, losing the specific error signal.
|
|
301
|
+
const invoice = await stack.http.writeOk<{ id: number }>(
|
|
302
|
+
"txguard:write:invoice:create",
|
|
303
|
+
{ title: "Concurrent-1" },
|
|
304
|
+
admin,
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const [res1, res2] = await Promise.all([
|
|
308
|
+
stack.http.write(
|
|
309
|
+
"txguard:write:invoice:update",
|
|
310
|
+
{ id: invoice["id"], changes: { status: "sent" }, version: 1 },
|
|
311
|
+
admin,
|
|
312
|
+
),
|
|
313
|
+
stack.http.write(
|
|
314
|
+
"txguard:write:invoice:update",
|
|
315
|
+
{ id: invoice["id"], changes: { status: "sent" }, version: 1 },
|
|
316
|
+
admin,
|
|
317
|
+
),
|
|
318
|
+
]);
|
|
319
|
+
|
|
320
|
+
const body1 = (await res1.json()) as { isSuccess: boolean; error?: unknown };
|
|
321
|
+
const body2 = (await res2.json()) as { isSuccess: boolean; error?: unknown };
|
|
322
|
+
|
|
323
|
+
// Exactly one of the two must win. If both succeeded, the row lock
|
|
324
|
+
// didn't serialize — both wrote draft→sent using a stale snapshot.
|
|
325
|
+
const successes = [body1, body2].filter((b) => b.isSuccess);
|
|
326
|
+
const failures = [body1, body2].filter((b) => !b.isSuccess);
|
|
327
|
+
expect(successes).toHaveLength(1);
|
|
328
|
+
expect(failures).toHaveLength(1);
|
|
329
|
+
|
|
330
|
+
// The loser saw state `sent` on its (now serialized) guard read and
|
|
331
|
+
// rejected the transition — NOT a version_conflict, which would be the
|
|
332
|
+
// optimistic-lock fallback if the guard had race-passed.
|
|
333
|
+
// The loser's UnprocessableError carries reason "invalid_transition" plus
|
|
334
|
+
// the from/to states for debugging.
|
|
335
|
+
const loser = failures[0]?.error as { code: string; details: { reason: string; from: string } };
|
|
336
|
+
expect(loser.code).toBe("unprocessable");
|
|
337
|
+
expect(loser.details.reason).toBe("invalid_transition");
|
|
338
|
+
expect(loser.details.from).toBe("sent");
|
|
339
|
+
});
|
|
340
|
+
});
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
createEntity,
|
|
5
|
+
createRegistry,
|
|
6
|
+
createTextField,
|
|
7
|
+
defineFeature,
|
|
8
|
+
type TenantId,
|
|
9
|
+
} from "../../engine";
|
|
10
|
+
import { createTestUser, TestUsers } from "../../stack";
|
|
11
|
+
import { buildServer } from "../server";
|
|
12
|
+
|
|
13
|
+
const JWT_SECRET = "test-secret-at-least-32-chars-long!!";
|
|
14
|
+
|
|
15
|
+
const testFeature = defineFeature("test", (r) => {
|
|
16
|
+
r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
|
|
17
|
+
|
|
18
|
+
r.writeHandler(
|
|
19
|
+
"item:create",
|
|
20
|
+
z.object({ name: z.string().min(1) }),
|
|
21
|
+
async (event) => ({ isSuccess: true, data: { name: event.payload.name } }),
|
|
22
|
+
{ access: { roles: ["Admin"] } },
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
r.queryHandler(
|
|
26
|
+
"item:list",
|
|
27
|
+
z.object({ search: z.string().optional() }),
|
|
28
|
+
async () => [{ id: 1, name: "Test" }],
|
|
29
|
+
{ access: { openToAll: true } },
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const registry = createRegistry([testFeature]);
|
|
34
|
+
const { app, jwt } = buildServer({ registry, context: {}, jwtSecret: JWT_SECRET });
|
|
35
|
+
|
|
36
|
+
const adminUser = TestUsers.admin;
|
|
37
|
+
const guestUser = createTestUser({ id: 2, roles: ["Guest"] });
|
|
38
|
+
|
|
39
|
+
async function authHeader(user: {
|
|
40
|
+
id: string;
|
|
41
|
+
tenantId: TenantId;
|
|
42
|
+
roles: readonly string[];
|
|
43
|
+
}): Promise<Record<string, string>> {
|
|
44
|
+
const token = await jwt.sign(user);
|
|
45
|
+
return { Authorization: `Bearer ${token}` };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function req(method: string, path: string, body?: unknown, headers?: Record<string, string>) {
|
|
49
|
+
const init: RequestInit = {
|
|
50
|
+
method,
|
|
51
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
52
|
+
};
|
|
53
|
+
if (body) init.body = JSON.stringify(body);
|
|
54
|
+
return app.request(path, init);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- Health ---
|
|
58
|
+
|
|
59
|
+
describe("health", () => {
|
|
60
|
+
test("GET /health returns ok", async () => {
|
|
61
|
+
const res = await req("GET", "/health");
|
|
62
|
+
expect(res.status).toBe(200);
|
|
63
|
+
expect(await res.json()).toEqual({ status: "ok" });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// --- Auth ---
|
|
68
|
+
|
|
69
|
+
describe("auth middleware", () => {
|
|
70
|
+
test("rejects request without token", async () => {
|
|
71
|
+
const res = await req("POST", "/api/write", {
|
|
72
|
+
type: "test:write:item:create",
|
|
73
|
+
payload: { name: "x" },
|
|
74
|
+
});
|
|
75
|
+
expect(res.status).toBe(401);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("rejects invalid token", async () => {
|
|
79
|
+
const res = await req(
|
|
80
|
+
"POST",
|
|
81
|
+
"/api/write",
|
|
82
|
+
{ type: "test:write:item:create", payload: { name: "x" } },
|
|
83
|
+
{
|
|
84
|
+
Authorization: "Bearer invalid.token.here",
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
expect(res.status).toBe(401);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("accepts valid token", async () => {
|
|
91
|
+
const headers = await authHeader(adminUser);
|
|
92
|
+
const res = await req(
|
|
93
|
+
"POST",
|
|
94
|
+
"/api/write",
|
|
95
|
+
{ type: "test:write:item:create", payload: { name: "Test" } },
|
|
96
|
+
headers,
|
|
97
|
+
);
|
|
98
|
+
expect(res.status).toBe(200);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// --- Write ---
|
|
103
|
+
|
|
104
|
+
describe("POST /api/write", () => {
|
|
105
|
+
test("dispatches write and returns result", async () => {
|
|
106
|
+
const headers = await authHeader(adminUser);
|
|
107
|
+
const res = await req(
|
|
108
|
+
"POST",
|
|
109
|
+
"/api/write",
|
|
110
|
+
{ type: "test:write:item:create", payload: { name: "Hello" } },
|
|
111
|
+
headers,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(res.status).toBe(200);
|
|
115
|
+
const body = await res.json();
|
|
116
|
+
expect(body.isSuccess).toBe(true);
|
|
117
|
+
expect(body.data.name).toBe("Hello");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("returns 400 for validation error", async () => {
|
|
121
|
+
const headers = await authHeader(adminUser);
|
|
122
|
+
const res = await req(
|
|
123
|
+
"POST",
|
|
124
|
+
"/api/write",
|
|
125
|
+
{ type: "test:write:item:create", payload: { name: "" } },
|
|
126
|
+
headers,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
expect(res.status).toBe(400);
|
|
130
|
+
const body = await res.json();
|
|
131
|
+
expect(body.error).toMatchObject({ code: "validation_error", i18nKey: expect.any(String) });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("returns 403 for access denied", async () => {
|
|
135
|
+
const headers = await authHeader(guestUser);
|
|
136
|
+
const res = await req(
|
|
137
|
+
"POST",
|
|
138
|
+
"/api/write",
|
|
139
|
+
{ type: "test:write:item:create", payload: { name: "Test" } },
|
|
140
|
+
headers,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
expect(res.status).toBe(403);
|
|
144
|
+
const body = await res.json();
|
|
145
|
+
expect(body.error).toMatchObject({ code: "access_denied" });
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// --- Query ---
|
|
150
|
+
|
|
151
|
+
describe("POST /api/query", () => {
|
|
152
|
+
test("dispatches query and returns data", async () => {
|
|
153
|
+
const headers = await authHeader(adminUser);
|
|
154
|
+
const res = await req(
|
|
155
|
+
"POST",
|
|
156
|
+
"/api/query",
|
|
157
|
+
{ type: "test:query:item:list", payload: {} },
|
|
158
|
+
headers,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
expect(res.status).toBe(200);
|
|
162
|
+
const body = await res.json();
|
|
163
|
+
expect(body.data).toEqual([{ id: 1, name: "Test" }]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("returns 404 for unknown query", async () => {
|
|
167
|
+
const headers = await authHeader(adminUser);
|
|
168
|
+
const res = await req("POST", "/api/query", { type: "nope", payload: {} }, headers);
|
|
169
|
+
expect(res.status).toBe(404);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// --- Command ---
|
|
174
|
+
|
|
175
|
+
describe("POST /api/command", () => {
|
|
176
|
+
test("dispatches command and returns 202", async () => {
|
|
177
|
+
const headers = await authHeader(adminUser);
|
|
178
|
+
const res = await req(
|
|
179
|
+
"POST",
|
|
180
|
+
"/api/command",
|
|
181
|
+
{ type: "test:write:item:create", payload: { name: "Fire" } },
|
|
182
|
+
headers,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
expect(res.status).toBe(202);
|
|
186
|
+
const body = await res.json();
|
|
187
|
+
expect(body.ok).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("returns 403 for access denied", async () => {
|
|
191
|
+
const headers = await authHeader(guestUser);
|
|
192
|
+
const res = await req(
|
|
193
|
+
"POST",
|
|
194
|
+
"/api/command",
|
|
195
|
+
{ type: "test:write:item:create", payload: { name: "x" } },
|
|
196
|
+
headers,
|
|
197
|
+
);
|
|
198
|
+
expect(res.status).toBe(403);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// --- SSE ---
|
|
203
|
+
|
|
204
|
+
describe("GET /api/sse", () => {
|
|
205
|
+
test("rejects without auth", async () => {
|
|
206
|
+
const res = await app.request("/api/sse");
|
|
207
|
+
expect(res.status).toBe(401);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("returns event stream with auth", async () => {
|
|
211
|
+
const headers = await authHeader(adminUser);
|
|
212
|
+
const res = await app.request("/api/sse", { headers });
|
|
213
|
+
expect(res.status).toBe(200);
|
|
214
|
+
expect(res.headers.get("content-type")).toContain("text/event-stream");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// --- r.httpRoute (feature-deklarierte HTTP-Routes außerhalb /api/) ---
|
|
219
|
+
|
|
220
|
+
describe("feature-declared HTTP routes (r.httpRoute)", () => {
|
|
221
|
+
// Eigenes buildServer-Setup mit einem Feature das eine Route deklariert.
|
|
222
|
+
// Pinst die Verdrahtung end-to-end: r.httpRoute → registry → buildServer
|
|
223
|
+
// → Hono-app.{get,post}(path) → Response. deps.app erlaubt internal-call
|
|
224
|
+
// an /api/* (gleicher Auth-Pfad wie ein echter HTTP-Call).
|
|
225
|
+
const routeFeature = defineFeature("routes", (r) => {
|
|
226
|
+
r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
|
|
227
|
+
r.queryHandler("item:list", z.object({}), async () => [{ id: 7 }], {
|
|
228
|
+
access: { openToAll: true },
|
|
229
|
+
});
|
|
230
|
+
r.httpRoute({
|
|
231
|
+
method: "GET",
|
|
232
|
+
path: "/version",
|
|
233
|
+
anonymous: true,
|
|
234
|
+
handler: (c) => c.json({ version: "1.2.3" }),
|
|
235
|
+
});
|
|
236
|
+
r.httpRoute({
|
|
237
|
+
method: "GET",
|
|
238
|
+
path: "/probe-deps",
|
|
239
|
+
anonymous: true,
|
|
240
|
+
handler: (c, deps) => {
|
|
241
|
+
// Beweist dass deps.app die Hono-App-Instanz ist — Handler kann
|
|
242
|
+
// sie für internal app.fetch(...)-Calls nutzen (typischer
|
|
243
|
+
// Use-Case: feed.xml ruft /api/query intern auf).
|
|
244
|
+
return c.json({
|
|
245
|
+
hasApp: typeof deps.app === "object" && typeof deps.app.fetch === "function",
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
const routeRegistry = createRegistry([routeFeature]);
|
|
251
|
+
const { app: routeApp } = buildServer({
|
|
252
|
+
registry: routeRegistry,
|
|
253
|
+
context: {},
|
|
254
|
+
jwtSecret: JWT_SECRET,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("GET /version returnt deklarierten JSON-Response", async () => {
|
|
258
|
+
const res = await routeApp.request("/version");
|
|
259
|
+
expect(res.status).toBe(200);
|
|
260
|
+
expect(await res.json()).toEqual({ version: "1.2.3" });
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("Handler bekommt deps.app — Hono-Instance für internal-fetch", async () => {
|
|
264
|
+
const res = await routeApp.request("/probe-deps");
|
|
265
|
+
expect(res.status).toBe(200);
|
|
266
|
+
const body = (await res.json()) as { hasApp: boolean };
|
|
267
|
+
expect(body.hasApp).toBe(true);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("Handler kann via deps.app intern /api/query aufrufen (anonymous + defaultTenantId)", async () => {
|
|
271
|
+
// Realistischer Use-Case (publicstatus feed.xml): die r.httpRoute
|
|
272
|
+
// baut eine View aus internen /api/query-Daten. Anonymous-Access mit
|
|
273
|
+
// defaultTenantId macht den inner-Call ohne Bearer-Token möglich;
|
|
274
|
+
// pinst dass deps.app.fetch identisch zu einem echten HTTP-Call läuft.
|
|
275
|
+
const inner = defineFeature("inner", (r) => {
|
|
276
|
+
r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
|
|
277
|
+
// Bewusst "anonymous" — openToAll schließt anonymous-User explizit
|
|
278
|
+
// aus (siehe access.ts), damit das Aktivieren von anonymousAccess
|
|
279
|
+
// nicht versehentlich jeden openToAll-Handler public macht.
|
|
280
|
+
r.queryHandler("item:list", z.object({}), async () => [{ id: 42, name: "hello" }], {
|
|
281
|
+
access: { roles: ["anonymous"] },
|
|
282
|
+
});
|
|
283
|
+
r.httpRoute({
|
|
284
|
+
method: "GET",
|
|
285
|
+
path: "/feed",
|
|
286
|
+
anonymous: true,
|
|
287
|
+
handler: async (c, deps) => {
|
|
288
|
+
const queryRes = await deps.app.fetch(
|
|
289
|
+
new Request(`${new URL(c.req.url).origin}/api/query`, {
|
|
290
|
+
method: "POST",
|
|
291
|
+
headers: { "Content-Type": "application/json" },
|
|
292
|
+
body: JSON.stringify({ type: "inner:query:item:list", payload: {} }),
|
|
293
|
+
}),
|
|
294
|
+
);
|
|
295
|
+
const body = (await queryRes.json()) as { data?: unknown };
|
|
296
|
+
return c.json({ status: queryRes.status, items: body.data });
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
const innerRegistry = createRegistry([inner]);
|
|
301
|
+
const { app: innerApp } = buildServer({
|
|
302
|
+
registry: innerRegistry,
|
|
303
|
+
context: {},
|
|
304
|
+
jwtSecret: JWT_SECRET,
|
|
305
|
+
anonymousAccess: {
|
|
306
|
+
defaultTenantId: "00000000-0000-4000-8000-000000000000" as TenantId,
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const res = await innerApp.request("/feed");
|
|
311
|
+
expect(res.status).toBe(200);
|
|
312
|
+
const body = (await res.json()) as { status: number; items: unknown };
|
|
313
|
+
expect(body.status).toBe(200);
|
|
314
|
+
expect(body.items).toEqual([{ id: 42, name: "hello" }]);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("Boot-Validator: Route auf /api/* ist verboten", () => {
|
|
318
|
+
expect(() =>
|
|
319
|
+
defineFeature("bad", (r) => {
|
|
320
|
+
r.httpRoute({
|
|
321
|
+
method: "GET",
|
|
322
|
+
path: "/api/forbidden",
|
|
323
|
+
handler: (c) => c.text("nope"),
|
|
324
|
+
});
|
|
325
|
+
}),
|
|
326
|
+
).toThrow(/\/api\/\* namespace.*reserved/);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("Boot-Validator: doppelte method+path-Combo wird abgelehnt", () => {
|
|
330
|
+
expect(() =>
|
|
331
|
+
defineFeature("dup", (r) => {
|
|
332
|
+
r.httpRoute({ method: "GET", path: "/x", handler: (c) => c.text("a") });
|
|
333
|
+
r.httpRoute({ method: "GET", path: "/x", handler: (c) => c.text("b") });
|
|
334
|
+
}),
|
|
335
|
+
).toThrow(/already registered/);
|
|
336
|
+
});
|
|
337
|
+
});
|