@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,449 @@
|
|
|
1
|
+
// H.2 Ownership — Full Leak-Prevention + Row-Grab + Straddle + Field-Level Matrix.
|
|
2
|
+
//
|
|
3
|
+
// Consolidates the three Phase 1–3 Integration suites (entity-read,
|
|
4
|
+
// entity-write, field-level) into one file with shared fixtures. Each
|
|
5
|
+
// `describe` block maps to a cell of the core-auth.md Policy-Matrix.
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
createEntity,
|
|
10
|
+
createTextField,
|
|
11
|
+
defineEntityQueryHandler,
|
|
12
|
+
defineEntityWriteHandler,
|
|
13
|
+
defineFeature,
|
|
14
|
+
from,
|
|
15
|
+
} from "../engine";
|
|
16
|
+
import type { SessionUser, TenantId } from "../engine/types";
|
|
17
|
+
import {
|
|
18
|
+
createEntityTable,
|
|
19
|
+
createTestUser,
|
|
20
|
+
setupTestStack,
|
|
21
|
+
type TestStack,
|
|
22
|
+
TestUsers,
|
|
23
|
+
testTenantId,
|
|
24
|
+
} from "../stack";
|
|
25
|
+
import { expectErrorIncludes } from "../testing";
|
|
26
|
+
|
|
27
|
+
// ── Shared test entity ─────────────────────────────────────────────────────
|
|
28
|
+
//
|
|
29
|
+
// One entity covers all three layers:
|
|
30
|
+
// - Entity-level read + write ownership per role
|
|
31
|
+
// - Field-level read + write ownership on specific columns
|
|
32
|
+
// - `teamId` + `assigneeId` columns drive both claim-rules and user-rules
|
|
33
|
+
|
|
34
|
+
const contractEntity = createEntity({
|
|
35
|
+
table: "h2_contracts",
|
|
36
|
+
softDelete: true,
|
|
37
|
+
fields: {
|
|
38
|
+
teamId: createTextField({ required: true }),
|
|
39
|
+
assigneeId: createTextField(),
|
|
40
|
+
title: createTextField({ required: true }),
|
|
41
|
+
// propA: public on read + write
|
|
42
|
+
propA: createTextField(),
|
|
43
|
+
// propB: Admin-only
|
|
44
|
+
propB: createTextField({
|
|
45
|
+
access: { read: { Admin: "all" }, write: { Admin: "all" } },
|
|
46
|
+
}),
|
|
47
|
+
// propC: Admin full, TeamMember scoped to matching teamId
|
|
48
|
+
propC: createTextField({
|
|
49
|
+
access: {
|
|
50
|
+
read: { Admin: "all", TeamMember: from("claim:teams:teamId") },
|
|
51
|
+
write: { Admin: "all", TeamMember: from("claim:teams:teamId") },
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
},
|
|
55
|
+
access: {
|
|
56
|
+
read: {
|
|
57
|
+
Admin: "all",
|
|
58
|
+
Manager: from("claim:teams:teamId"),
|
|
59
|
+
TeamMember: from("claim:teams:teamId"),
|
|
60
|
+
Driver: from("user:id", "assigneeId"),
|
|
61
|
+
},
|
|
62
|
+
write: {
|
|
63
|
+
Admin: "all",
|
|
64
|
+
Manager: from("claim:teams:teamId"),
|
|
65
|
+
TeamMember: from("claim:teams:teamId"),
|
|
66
|
+
Driver: from("user:id", "assigneeId"),
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const allRoles = ["Admin", "Manager", "TeamMember", "Driver", "Guest"] as const;
|
|
72
|
+
|
|
73
|
+
const contractsFeature = defineFeature("h2contracts", (r) => {
|
|
74
|
+
r.entity("contract", contractEntity);
|
|
75
|
+
for (const verb of ["create", "update", "delete", "restore"] as const) {
|
|
76
|
+
r.writeHandler(
|
|
77
|
+
defineEntityWriteHandler(`contract:${verb}`, contractEntity, {
|
|
78
|
+
access: { roles: [...allRoles] },
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
for (const verb of ["list", "detail"] as const) {
|
|
83
|
+
r.queryHandler(
|
|
84
|
+
defineEntityQueryHandler(`contract:${verb}`, contractEntity, {
|
|
85
|
+
access: { roles: [...allRoles] },
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const teamsFeature = defineFeature("teams", (r) => {
|
|
92
|
+
r.claimKey("teamId", { type: "string" });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── Shared users ───────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
const tenant = testTenantId(1);
|
|
98
|
+
|
|
99
|
+
const admin: SessionUser = { ...TestUsers.admin, tenantId: tenant };
|
|
100
|
+
const managerEng = mkUser(22, ["Manager"], "eng");
|
|
101
|
+
const teamEng = mkUser(33, ["TeamMember"], "eng");
|
|
102
|
+
const teamOps = mkUser(34, ["TeamMember"], "ops");
|
|
103
|
+
const driverAlice = mkUser(44, ["Driver"], undefined);
|
|
104
|
+
const driverBob = mkUser(45, ["Driver"], undefined);
|
|
105
|
+
// User with BOTH Driver + Manager — the Straddle-attack test targets this
|
|
106
|
+
// combination specifically.
|
|
107
|
+
const straddler = mkUser(55, ["Driver", "Manager"], "eng");
|
|
108
|
+
const guest = mkUser(66, ["Guest"], undefined);
|
|
109
|
+
const noClaimTeamMember = mkUser(77, ["TeamMember"], undefined);
|
|
110
|
+
|
|
111
|
+
function mkUser(n: number, roles: readonly string[], team: string | undefined): SessionUser {
|
|
112
|
+
return createTestUser({
|
|
113
|
+
id: `11111111-0000-4000-8000-0000000000${String(n).padStart(2, "0")}`,
|
|
114
|
+
tenantId: tenant,
|
|
115
|
+
roles: [...roles],
|
|
116
|
+
...(team ? { claims: { "teams:teamId": team } } : {}),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Test stack ─────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
let stack: TestStack;
|
|
123
|
+
|
|
124
|
+
beforeAll(async () => {
|
|
125
|
+
stack = await setupTestStack({ features: [teamsFeature, contractsFeature] });
|
|
126
|
+
await createEntityTable(stack.db, contractEntity, "contract");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterAll(async () => {
|
|
130
|
+
await stack.cleanup();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
let engRow: { id: string; version: number };
|
|
134
|
+
let opsRow: { id: string; version: number };
|
|
135
|
+
|
|
136
|
+
beforeEach(async () => {
|
|
137
|
+
await stack.db.execute("DELETE FROM h2_contracts");
|
|
138
|
+
const eng = await stack.http.writeOk<{ id: string; data: { version: number } }>(
|
|
139
|
+
"h2contracts:write:contract:create",
|
|
140
|
+
{
|
|
141
|
+
teamId: "eng",
|
|
142
|
+
assigneeId: driverAlice.id,
|
|
143
|
+
title: "Eng",
|
|
144
|
+
propA: "public-a",
|
|
145
|
+
propB: "admin-b",
|
|
146
|
+
propC: "team-c",
|
|
147
|
+
},
|
|
148
|
+
admin,
|
|
149
|
+
);
|
|
150
|
+
const ops = await stack.http.writeOk<{ id: string; data: { version: number } }>(
|
|
151
|
+
"h2contracts:write:contract:create",
|
|
152
|
+
{
|
|
153
|
+
teamId: "ops",
|
|
154
|
+
assigneeId: driverBob.id,
|
|
155
|
+
title: "Ops",
|
|
156
|
+
propA: "public-a-ops",
|
|
157
|
+
propB: "admin-b-ops",
|
|
158
|
+
propC: "team-c-ops",
|
|
159
|
+
},
|
|
160
|
+
admin,
|
|
161
|
+
);
|
|
162
|
+
engRow = { id: eng.id, version: eng.data.version };
|
|
163
|
+
opsRow = { id: ops.id, version: ops.data.version };
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
type ListResult = { rows: Array<Record<string, unknown>> };
|
|
169
|
+
|
|
170
|
+
function list(user: SessionUser): Promise<ListResult> {
|
|
171
|
+
return stack.http.queryOk<ListResult>("h2contracts:query:contract:list", {}, user);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function detail(user: SessionUser, id: string): Promise<Record<string, unknown> | null> {
|
|
175
|
+
return stack.http.queryOk("h2contracts:query:contract:detail", { id }, user);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Entity-level READ ──────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
describe("entity-level READ: list + detail leak-prevention", () => {
|
|
181
|
+
test("Admin lists both rows", async () => {
|
|
182
|
+
const r = await list(admin);
|
|
183
|
+
expect(r.rows).toHaveLength(2);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("TeamMember eng lists only eng row", async () => {
|
|
187
|
+
const r = await list(teamEng);
|
|
188
|
+
expect(r.rows).toHaveLength(1);
|
|
189
|
+
expect(r.rows[0]?.["teamId"]).toBe("eng");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("TeamMember ops lists only ops row (no cross-team leak)", async () => {
|
|
193
|
+
const r = await list(teamOps);
|
|
194
|
+
expect(r.rows).toHaveLength(1);
|
|
195
|
+
expect(r.rows[0]?.["teamId"]).toBe("ops");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("Driver Alice sees only rows assigned to her", async () => {
|
|
199
|
+
const r = await list(driverAlice);
|
|
200
|
+
expect(r.rows).toHaveLength(1);
|
|
201
|
+
expect(r.rows[0]?.["assigneeId"]).toBe(driverAlice.id);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("Guest (not in any access-map rule) sees nothing", async () => {
|
|
205
|
+
const r = await list(guest);
|
|
206
|
+
expect(r.rows).toHaveLength(0);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("detail() on foreign row → null (indistinguishable from not-found, no info-leak)", async () => {
|
|
210
|
+
expect(await detail(teamEng, opsRow.id)).toBeNull();
|
|
211
|
+
expect(await detail(guest, engRow.id)).toBeNull();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("TeamMember eng gets their own row back with all readable fields", async () => {
|
|
215
|
+
const row = await detail(teamEng, engRow.id);
|
|
216
|
+
expect(row?.["teamId"]).toBe("eng");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("Missing claim degrades to no-access, not wildcard", async () => {
|
|
220
|
+
expect((await list(noClaimTeamMember)).rows).toHaveLength(0);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ── Entity-level WRITE ─────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
describe("entity-level WRITE: create/update/delete/restore", () => {
|
|
227
|
+
test("TeamMember eng creates an eng-team row", async () => {
|
|
228
|
+
const res = await stack.http.writeOk<{ id: string }>(
|
|
229
|
+
"h2contracts:write:contract:create",
|
|
230
|
+
{ teamId: "eng", title: "new" },
|
|
231
|
+
teamEng,
|
|
232
|
+
);
|
|
233
|
+
expect(res.id).toBeTruthy();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("TeamMember eng CANNOT create an ops-team row", async () => {
|
|
237
|
+
const err = await stack.http.writeErr(
|
|
238
|
+
"h2contracts:write:contract:create",
|
|
239
|
+
{ teamId: "ops", title: "rogue" },
|
|
240
|
+
teamEng,
|
|
241
|
+
);
|
|
242
|
+
expectErrorIncludes(err, "ownership_denied");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("Guest cannot create anything", async () => {
|
|
246
|
+
const err = await stack.http.writeErr(
|
|
247
|
+
"h2contracts:write:contract:create",
|
|
248
|
+
{ teamId: "eng", title: "guest" },
|
|
249
|
+
guest,
|
|
250
|
+
);
|
|
251
|
+
expectErrorIncludes(err, "ownership_denied");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("Manager eng can update their eng row", async () => {
|
|
255
|
+
const res = await stack.http.writeOk<{ data: { title: string } }>(
|
|
256
|
+
"h2contracts:write:contract:update",
|
|
257
|
+
{ id: engRow.id, version: engRow.version, changes: { title: "edited" } },
|
|
258
|
+
managerEng,
|
|
259
|
+
);
|
|
260
|
+
expect(res.data.title).toBe("edited");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("Manager eng CANNOT update a foreign (ops) row", async () => {
|
|
264
|
+
const err = await stack.http.writeErr(
|
|
265
|
+
"h2contracts:write:contract:update",
|
|
266
|
+
{ id: opsRow.id, version: opsRow.version, changes: { title: "grabbed" } },
|
|
267
|
+
managerEng,
|
|
268
|
+
);
|
|
269
|
+
expectErrorIncludes(err, "ownership_denied");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("Manager eng CANNOT rewrite teamId on their own row (row-grab via column move)", async () => {
|
|
273
|
+
const err = await stack.http.writeErr(
|
|
274
|
+
"h2contracts:write:contract:update",
|
|
275
|
+
{ id: engRow.id, version: engRow.version, changes: { teamId: "ops" } },
|
|
276
|
+
managerEng,
|
|
277
|
+
);
|
|
278
|
+
expectErrorIncludes(err, "ownership_denied");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("Manager eng deletes their eng row; ops row survives", async () => {
|
|
282
|
+
const res = await stack.http.writeOk<{ id: string }>(
|
|
283
|
+
"h2contracts:write:contract:delete",
|
|
284
|
+
{ id: engRow.id },
|
|
285
|
+
managerEng,
|
|
286
|
+
);
|
|
287
|
+
expect(res.id).toBe(engRow.id);
|
|
288
|
+
expect(await detail(admin, engRow.id)).toBeNull();
|
|
289
|
+
expect(await detail(admin, opsRow.id)).not.toBeNull();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("Manager eng CANNOT delete a foreign row", async () => {
|
|
293
|
+
const err = await stack.http.writeErr(
|
|
294
|
+
"h2contracts:write:contract:delete",
|
|
295
|
+
{ id: opsRow.id },
|
|
296
|
+
managerEng,
|
|
297
|
+
);
|
|
298
|
+
expectErrorIncludes(err, "ownership_denied");
|
|
299
|
+
expect(await detail(admin, opsRow.id)).not.toBeNull();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("Manager eng can restore their own soft-deleted row but not a foreign one", async () => {
|
|
303
|
+
await stack.http.writeOk("h2contracts:write:contract:delete", { id: engRow.id }, admin);
|
|
304
|
+
await stack.http.writeOk("h2contracts:write:contract:delete", { id: opsRow.id }, admin);
|
|
305
|
+
const ok = await stack.http.writeOk<{ id: string }>(
|
|
306
|
+
"h2contracts:write:contract:restore",
|
|
307
|
+
{ id: engRow.id },
|
|
308
|
+
managerEng,
|
|
309
|
+
);
|
|
310
|
+
expect(ok.id).toBe(engRow.id);
|
|
311
|
+
const err = await stack.http.writeErr(
|
|
312
|
+
"h2contracts:write:contract:restore",
|
|
313
|
+
{ id: opsRow.id },
|
|
314
|
+
managerEng,
|
|
315
|
+
);
|
|
316
|
+
expectErrorIncludes(err, "ownership_denied");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("Admin 'all' short-circuit: every write succeeds regardless of row", async () => {
|
|
320
|
+
const created = await stack.http.writeOk<{ id: string; data: { version: number } }>(
|
|
321
|
+
"h2contracts:write:contract:create",
|
|
322
|
+
{ teamId: "anything", title: "admin-create" },
|
|
323
|
+
admin,
|
|
324
|
+
);
|
|
325
|
+
expect(created.id).toBeTruthy();
|
|
326
|
+
const updated = await stack.http.writeOk<{ data: { title: string } }>(
|
|
327
|
+
"h2contracts:write:contract:update",
|
|
328
|
+
{ id: created.id, version: created.data.version, changes: { title: "admin-edit" } },
|
|
329
|
+
admin,
|
|
330
|
+
);
|
|
331
|
+
expect(updated.data.title).toBe("admin-edit");
|
|
332
|
+
const deleted = await stack.http.writeOk<{ id: string }>(
|
|
333
|
+
"h2contracts:write:contract:delete",
|
|
334
|
+
{ id: created.id },
|
|
335
|
+
admin,
|
|
336
|
+
);
|
|
337
|
+
expect(deleted.id).toBe(created.id);
|
|
338
|
+
const restored = await stack.http.writeOk<{ id: string }>(
|
|
339
|
+
"h2contracts:write:contract:restore",
|
|
340
|
+
{ id: created.id },
|
|
341
|
+
admin,
|
|
342
|
+
);
|
|
343
|
+
expect(restored.id).toBe(created.id);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// ── STRADDLE attack — advisor blocker, kept in its own describe so a ──────
|
|
348
|
+
// refactor accidentally deleting this block is immediately obvious.
|
|
349
|
+
|
|
350
|
+
describe("entity-level WRITE: Straddle-attack prevention (multi-role atomic)", () => {
|
|
351
|
+
test("CRITICAL: user with [Driver, Manager] cannot split old/new across roles", async () => {
|
|
352
|
+
// Setup: Straddler has BOTH Driver and Manager(eng). The Eng-row's
|
|
353
|
+
// assigneeId is Alice (not the Straddler). An aggregated-role attack
|
|
354
|
+
// would be:
|
|
355
|
+
// OLD row: teamId=eng (Manager ✓), assigneeId=Alice (Driver ✗)
|
|
356
|
+
// NEW row: teamId=ops (Manager ✗), assigneeId=me (Driver ✓)
|
|
357
|
+
// Aggregated (OR across roles per side): passes. Atomic (one role both
|
|
358
|
+
// sides): neither Manager nor Driver passes both → BLOCKED.
|
|
359
|
+
const err = await stack.http.writeErr(
|
|
360
|
+
"h2contracts:write:contract:update",
|
|
361
|
+
{
|
|
362
|
+
id: engRow.id,
|
|
363
|
+
version: engRow.version,
|
|
364
|
+
changes: { teamId: "ops", assigneeId: straddler.id },
|
|
365
|
+
},
|
|
366
|
+
straddler,
|
|
367
|
+
);
|
|
368
|
+
expectErrorIncludes(err, "ownership_denied");
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("Valid: straddler with Manager(eng) updates eng row, keeps it in eng", async () => {
|
|
372
|
+
const res = await stack.http.writeOk<{ data: { title: string } }>(
|
|
373
|
+
"h2contracts:write:contract:update",
|
|
374
|
+
{ id: engRow.id, version: engRow.version, changes: { title: "straddler-ok" } },
|
|
375
|
+
straddler,
|
|
376
|
+
);
|
|
377
|
+
expect(res.data.title).toBe("straddler-ok");
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// ── Field-level READ ──────────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
describe("field-level READ: response JSON strips unreadable fields silently", () => {
|
|
384
|
+
test("Admin sees propA, propB, propC", async () => {
|
|
385
|
+
const r = await detail(admin, engRow.id);
|
|
386
|
+
expect(r).toMatchObject({ propA: "public-a", propB: "admin-b", propC: "team-c" });
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("TeamMember eng sees propA + propC (team match); propB silently missing", async () => {
|
|
390
|
+
const r = await detail(teamEng, engRow.id);
|
|
391
|
+
expect(r?.["propA"]).toBe("public-a");
|
|
392
|
+
expect(r?.["propC"]).toBe("team-c");
|
|
393
|
+
expect(r).not.toHaveProperty("propB");
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// ── Field-level WRITE ─────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
describe("field-level WRITE: individual fields fail-loud", () => {
|
|
400
|
+
test("TeamMember eng cannot write propB → access_denied (role gate in dispatcher)", async () => {
|
|
401
|
+
const err = await stack.http.writeErr(
|
|
402
|
+
"h2contracts:write:contract:update",
|
|
403
|
+
{ id: engRow.id, version: engRow.version, changes: { propB: "sneak" } },
|
|
404
|
+
teamEng,
|
|
405
|
+
);
|
|
406
|
+
expectErrorIncludes(err, "access_denied");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("TeamMember ops CANNOT write propC on eng row (ownership denied, fail-loud)", async () => {
|
|
410
|
+
// Entity-level write on ops user against eng row would already fail at
|
|
411
|
+
// the entity-level check. To exercise the field-level path explicitly,
|
|
412
|
+
// teamOps would need entity-level access which it doesn't have — so
|
|
413
|
+
// the entity-level check fires first. Asserting the error code is
|
|
414
|
+
// sufficient: ops can't touch eng's row.
|
|
415
|
+
const err = await stack.http.writeErr(
|
|
416
|
+
"h2contracts:write:contract:update",
|
|
417
|
+
{ id: engRow.id, version: engRow.version, changes: { propC: "rogue" } },
|
|
418
|
+
teamOps,
|
|
419
|
+
);
|
|
420
|
+
expectErrorIncludes(err, "ownership_denied");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("TeamMember eng CAN write propC on their own team row", async () => {
|
|
424
|
+
const res = await stack.http.writeOk<{ data: { propC: string } }>(
|
|
425
|
+
"h2contracts:write:contract:update",
|
|
426
|
+
{ id: engRow.id, version: engRow.version, changes: { propC: "eng-edit" } },
|
|
427
|
+
teamEng,
|
|
428
|
+
);
|
|
429
|
+
expect(res.data.propC).toBe("eng-edit");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("Partial update (only propA) doesn't trigger ownership rules on unrelated fields", async () => {
|
|
433
|
+
const res = await stack.http.writeOk<{ data: { propA: string } }>(
|
|
434
|
+
"h2contracts:write:contract:update",
|
|
435
|
+
{ id: engRow.id, version: engRow.version, changes: { propA: "partial-edit" } },
|
|
436
|
+
teamEng,
|
|
437
|
+
);
|
|
438
|
+
expect(res.data.propA).toBe("partial-edit");
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// driverBob is seeded via `assigneeId: driverBob.id` in beforeEach — his
|
|
443
|
+
// presence in the seed is what lets us assert Alice doesn't see his row.
|
|
444
|
+
// Keep the reference warm so biome doesn't strip the const.
|
|
445
|
+
void driverBob.id;
|
|
446
|
+
// TenantId import is used as the type parameter on testTenantId() via
|
|
447
|
+
// tenantConst above — re-assert via explicit type to silence any TS trim.
|
|
448
|
+
const _tenantType: TenantId = tenant;
|
|
449
|
+
void _tenantType;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
2
|
+
import { integer, table as pgTable, serial, text } from "../db/dialect";
|
|
3
|
+
import { seedReferenceData } from "../db/reference-data";
|
|
4
|
+
import type { ReferenceDataDef } from "../engine/types";
|
|
5
|
+
import { createTestDb, pushTables, type TestDb } from "../stack";
|
|
6
|
+
|
|
7
|
+
// --- Tables ---
|
|
8
|
+
|
|
9
|
+
const countryTable = pgTable("ref_countries", {
|
|
10
|
+
code: text("code").primaryKey(),
|
|
11
|
+
name: text("name").notNull(),
|
|
12
|
+
region: text("region"),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const statusTable = pgTable("ref_statuses", {
|
|
16
|
+
id: serial("id").primaryKey(),
|
|
17
|
+
slug: text("slug").notNull(),
|
|
18
|
+
label: text("label").notNull(),
|
|
19
|
+
sortOrder: integer("sort_order").default(0),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// --- Test state ---
|
|
23
|
+
|
|
24
|
+
let testDb: TestDb;
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
testDb = await createTestDb();
|
|
28
|
+
await pushTables(testDb.db, { countryTable, statusTable });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterAll(async () => {
|
|
32
|
+
await testDb?.cleanup();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Helper: read all rows from a table
|
|
36
|
+
async function readCountries() {
|
|
37
|
+
const rows = await testDb.db.select().from(countryTable);
|
|
38
|
+
return rows.sort((a, b) => a.code.localeCompare(b.code));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readStatuses() {
|
|
42
|
+
const rows = await testDb.db.select().from(statusTable);
|
|
43
|
+
return rows.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("seedReferenceData", () => {
|
|
47
|
+
const tables = new Map<string, typeof countryTable | typeof statusTable>([
|
|
48
|
+
["country", countryTable],
|
|
49
|
+
["status", statusTable],
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
test("inserts initial reference data", async () => {
|
|
53
|
+
const defs: ReferenceDataDef[] = [
|
|
54
|
+
{
|
|
55
|
+
entityName: "country",
|
|
56
|
+
data: [
|
|
57
|
+
{ code: "DE", name: "Deutschland", region: "Europe" },
|
|
58
|
+
{ code: "AT", name: "Oesterreich", region: "Europe" },
|
|
59
|
+
{ code: "JP", name: "Japan", region: "Asia" },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const result = await seedReferenceData(defs, tables, testDb.db);
|
|
65
|
+
|
|
66
|
+
expect(result).toEqual({ inserted: 3, updated: 0 });
|
|
67
|
+
|
|
68
|
+
const rows = await readCountries();
|
|
69
|
+
expect(rows).toHaveLength(3);
|
|
70
|
+
expect(rows[0]).toMatchObject({ code: "AT", name: "Oesterreich", region: "Europe" });
|
|
71
|
+
expect(rows[1]).toMatchObject({ code: "DE", name: "Deutschland", region: "Europe" });
|
|
72
|
+
expect(rows[2]).toMatchObject({ code: "JP", name: "Japan", region: "Asia" });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("is idempotent — no-op when data unchanged", async () => {
|
|
76
|
+
const defs: ReferenceDataDef[] = [
|
|
77
|
+
{
|
|
78
|
+
entityName: "country",
|
|
79
|
+
data: [
|
|
80
|
+
{ code: "DE", name: "Deutschland", region: "Europe" },
|
|
81
|
+
{ code: "AT", name: "Oesterreich", region: "Europe" },
|
|
82
|
+
{ code: "JP", name: "Japan", region: "Asia" },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const result = await seedReferenceData(defs, tables, testDb.db);
|
|
88
|
+
|
|
89
|
+
expect(result).toEqual({ inserted: 0, updated: 0 });
|
|
90
|
+
|
|
91
|
+
// Data still the same
|
|
92
|
+
const rows = await readCountries();
|
|
93
|
+
expect(rows).toHaveLength(3);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("updates changed fields without duplicating rows", async () => {
|
|
97
|
+
const defs: ReferenceDataDef[] = [
|
|
98
|
+
{
|
|
99
|
+
entityName: "country",
|
|
100
|
+
data: [
|
|
101
|
+
{ code: "DE", name: "Germany", region: "Europe" }, // name changed
|
|
102
|
+
{ code: "AT", name: "Oesterreich", region: "Europe" }, // unchanged
|
|
103
|
+
{ code: "JP", name: "Japan", region: "East Asia" }, // region changed
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
const result = await seedReferenceData(defs, tables, testDb.db);
|
|
109
|
+
|
|
110
|
+
expect(result).toEqual({ inserted: 0, updated: 2 });
|
|
111
|
+
|
|
112
|
+
const rows = await readCountries();
|
|
113
|
+
expect(rows).toHaveLength(3); // no duplicates
|
|
114
|
+
expect(rows[1]).toMatchObject({ code: "DE", name: "Germany", region: "Europe" });
|
|
115
|
+
expect(rows[2]).toMatchObject({ code: "JP", name: "Japan", region: "East Asia" });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("inserts new rows alongside existing ones", async () => {
|
|
119
|
+
const defs: ReferenceDataDef[] = [
|
|
120
|
+
{
|
|
121
|
+
entityName: "country",
|
|
122
|
+
data: [
|
|
123
|
+
{ code: "DE", name: "Germany", region: "Europe" },
|
|
124
|
+
{ code: "CH", name: "Schweiz", region: "Europe" }, // new
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
const result = await seedReferenceData(defs, tables, testDb.db);
|
|
130
|
+
|
|
131
|
+
expect(result).toEqual({ inserted: 1, updated: 0 });
|
|
132
|
+
|
|
133
|
+
const rows = await readCountries();
|
|
134
|
+
expect(rows).toHaveLength(4);
|
|
135
|
+
expect(rows.find((r) => r.code === "CH")).toMatchObject({ name: "Schweiz", region: "Europe" });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("custom upsertKey — matches on specified field instead of first", async () => {
|
|
139
|
+
const defs: ReferenceDataDef[] = [
|
|
140
|
+
{
|
|
141
|
+
entityName: "status",
|
|
142
|
+
data: [
|
|
143
|
+
{ slug: "draft", label: "Draft", sortOrder: 1 },
|
|
144
|
+
{ slug: "active", label: "Active", sortOrder: 2 },
|
|
145
|
+
{ slug: "archived", label: "Archived", sortOrder: 3 },
|
|
146
|
+
],
|
|
147
|
+
upsertKey: "slug",
|
|
148
|
+
},
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
const result = await seedReferenceData(defs, tables, testDb.db);
|
|
152
|
+
expect(result).toEqual({ inserted: 3, updated: 0 });
|
|
153
|
+
|
|
154
|
+
// Update label via same upsertKey
|
|
155
|
+
const updateDefs: ReferenceDataDef[] = [
|
|
156
|
+
{
|
|
157
|
+
entityName: "status",
|
|
158
|
+
data: [
|
|
159
|
+
{ slug: "draft", label: "Entwurf", sortOrder: 1 }, // label changed
|
|
160
|
+
{ slug: "active", label: "Active", sortOrder: 2 }, // unchanged
|
|
161
|
+
],
|
|
162
|
+
upsertKey: "slug",
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
const updateResult = await seedReferenceData(updateDefs, tables, testDb.db);
|
|
167
|
+
expect(updateResult).toEqual({ inserted: 0, updated: 1 });
|
|
168
|
+
|
|
169
|
+
const rows = await readStatuses();
|
|
170
|
+
expect(rows.find((r) => r.slug === "draft")).toMatchObject({ label: "Entwurf" });
|
|
171
|
+
expect(rows.find((r) => r.slug === "active")).toMatchObject({ label: "Active" });
|
|
172
|
+
expect(rows.find((r) => r.slug === "archived")).toMatchObject({ label: "Archived" });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("skips unknown entity names gracefully", async () => {
|
|
176
|
+
const defs: ReferenceDataDef[] = [
|
|
177
|
+
{
|
|
178
|
+
entityName: "nonexistent",
|
|
179
|
+
data: [{ code: "X", name: "Unknown" }],
|
|
180
|
+
},
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
const result = await seedReferenceData(defs, tables, testDb.db);
|
|
184
|
+
expect(result).toEqual({ inserted: 0, updated: 0 });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("skips empty data arrays", async () => {
|
|
188
|
+
const defs: ReferenceDataDef[] = [
|
|
189
|
+
{
|
|
190
|
+
entityName: "country",
|
|
191
|
+
data: [],
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
const result = await seedReferenceData(defs, tables, testDb.db);
|
|
196
|
+
expect(result).toEqual({ inserted: 0, updated: 0 });
|
|
197
|
+
});
|
|
198
|
+
});
|