@cosmicdrift/kumiko-framework 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/package.json +124 -39
- package/src/__tests__/full-stack.integration.ts +2 -2
- package/src/api/auth-routes.ts +5 -5
- package/src/api/jwt.ts +2 -2
- package/src/api/route-registrars.ts +1 -1
- package/src/api/routes.ts +3 -3
- package/src/api/server.ts +6 -7
- package/src/compliance/profiles.ts +8 -8
- package/src/db/assert-exists-in.ts +2 -2
- package/src/db/cursor.ts +3 -3
- package/src/db/event-store-executor.ts +19 -13
- package/src/db/located-timestamp.ts +1 -1
- package/src/db/money.ts +12 -2
- package/src/db/pg-error.ts +1 -1
- package/src/db/row-helpers.ts +1 -1
- package/src/db/table-builder.ts +3 -5
- package/src/db/tenant-db.ts +9 -9
- package/src/engine/__tests__/_pipeline-test-utils.ts +23 -0
- package/src/engine/__tests__/build-target.test.ts +135 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +551 -0
- package/src/engine/__tests__/entity-handlers.test.ts +3 -3
- package/src/engine/__tests__/event-helpers.test.ts +4 -4
- package/src/engine/__tests__/pipeline-engine.test.ts +215 -0
- package/src/engine/__tests__/pipeline-handler.integration.ts +894 -0
- package/src/engine/__tests__/pipeline-observability.integration.ts +142 -0
- package/src/engine/__tests__/pipeline-performance.integration.ts +152 -0
- package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +288 -0
- package/src/engine/__tests__/raw-table.test.ts +2 -2
- package/src/engine/__tests__/steps-aggregate-append-event.test.ts +115 -0
- package/src/engine/__tests__/steps-aggregate-create.test.ts +92 -0
- package/src/engine/__tests__/steps-aggregate-update.test.ts +127 -0
- package/src/engine/__tests__/steps-call-feature.test.ts +123 -0
- package/src/engine/__tests__/steps-mail-send.test.ts +136 -0
- package/src/engine/__tests__/steps-read.test.ts +142 -0
- package/src/engine/__tests__/steps-resolver-utils.test.ts +50 -0
- package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +69 -0
- package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +117 -0
- package/src/engine/__tests__/steps-webhook-send.test.ts +135 -0
- package/src/engine/__tests__/steps-workflow.test.ts +198 -0
- package/src/engine/__tests__/validate-projection-allowlist.test.ts +491 -0
- package/src/engine/__tests__/visual-tree-patterns.test.ts +251 -0
- package/src/engine/boot-validator/api-ext.ts +77 -0
- package/src/engine/boot-validator/config-deps.ts +163 -0
- package/src/engine/boot-validator/entity-handler.ts +466 -0
- package/src/engine/boot-validator/index.ts +159 -0
- package/src/engine/boot-validator/ownership.ts +198 -0
- package/src/engine/boot-validator/pii-retention.ts +155 -0
- package/src/engine/boot-validator/screens-nav.ts +624 -0
- package/src/engine/boot-validator.ts +1 -1804
- package/src/engine/build-app-schema.ts +1 -1
- package/src/engine/build-target.ts +99 -0
- package/src/engine/codemod/index.ts +15 -0
- package/src/engine/codemod/pipeline-codemod.ts +641 -0
- package/src/engine/config-helpers.ts +9 -19
- package/src/engine/constants.ts +1 -1
- package/src/engine/define-feature.ts +88 -9
- package/src/engine/define-handler.ts +89 -3
- package/src/engine/define-roles.ts +2 -2
- package/src/engine/define-step.ts +28 -0
- package/src/engine/define-workflow.ts +110 -0
- package/src/engine/entity-handlers.ts +10 -9
- package/src/engine/event-helpers.ts +4 -4
- package/src/engine/factories.ts +12 -12
- package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +184 -0
- package/src/engine/feature-ast/extractors/index.ts +74 -0
- package/src/engine/feature-ast/extractors/round1.ts +110 -0
- package/src/engine/feature-ast/extractors/round2.ts +253 -0
- package/src/engine/feature-ast/extractors/round3.ts +471 -0
- package/src/engine/feature-ast/extractors/round4.ts +1365 -0
- package/src/engine/feature-ast/extractors/round5.ts +72 -0
- package/src/engine/feature-ast/extractors/round6.ts +66 -0
- package/src/engine/feature-ast/extractors/shared.ts +177 -0
- package/src/engine/feature-ast/parse.ts +7 -0
- package/src/engine/feature-ast/patch.ts +9 -1
- package/src/engine/feature-ast/patcher.ts +10 -3
- package/src/engine/feature-ast/patterns.ts +49 -1
- package/src/engine/feature-ast/render.ts +17 -1
- package/src/engine/index.ts +45 -2
- package/src/engine/pattern-library/__tests__/library.test.ts +6 -0
- package/src/engine/pattern-library/library.ts +42 -2
- package/src/engine/pipeline.ts +88 -0
- package/src/engine/projection-helpers.ts +1 -1
- package/src/engine/read-claim.ts +1 -1
- package/src/engine/registry.ts +30 -2
- package/src/engine/resolve-config-or-param.ts +4 -0
- package/src/engine/run-pipeline.ts +162 -0
- package/src/engine/schema-builder.ts +2 -4
- package/src/engine/state-machine.ts +1 -1
- package/src/engine/steps/_drizzle-boundary.ts +19 -0
- package/src/engine/steps/_duration-utils.ts +33 -0
- package/src/engine/steps/_no-return-guard.ts +21 -0
- package/src/engine/steps/_resolver-utils.ts +42 -0
- package/src/engine/steps/_step-dispatch-constants.ts +38 -0
- package/src/engine/steps/aggregate-append-event.ts +56 -0
- package/src/engine/steps/aggregate-create.ts +56 -0
- package/src/engine/steps/aggregate-update.ts +68 -0
- package/src/engine/steps/branch.ts +84 -0
- package/src/engine/steps/call-feature.ts +49 -0
- package/src/engine/steps/compute.ts +41 -0
- package/src/engine/steps/for-each.ts +111 -0
- package/src/engine/steps/mail-send.ts +44 -0
- package/src/engine/steps/read-find-many.ts +51 -0
- package/src/engine/steps/read-find-one.ts +58 -0
- package/src/engine/steps/retry.ts +87 -0
- package/src/engine/steps/return.ts +34 -0
- package/src/engine/steps/unsafe-projection-delete.ts +46 -0
- package/src/engine/steps/unsafe-projection-upsert.ts +69 -0
- package/src/engine/steps/wait-for-event.ts +71 -0
- package/src/engine/steps/wait.ts +69 -0
- package/src/engine/steps/webhook-send.ts +71 -0
- package/src/engine/system-user.ts +1 -1
- package/src/engine/types/feature.ts +92 -1
- package/src/engine/types/handlers.ts +18 -10
- package/src/engine/types/identifiers.ts +1 -0
- package/src/engine/types/index.ts +12 -1
- package/src/engine/types/step.ts +334 -0
- package/src/engine/types/target-ref.ts +21 -0
- package/src/engine/types/tree-node.ts +130 -0
- package/src/engine/types/workspace.ts +7 -0
- package/src/engine/validate-projection-allowlist.ts +161 -0
- package/src/event-store/snapshot.ts +1 -1
- package/src/event-store/upcaster-dead-letter.ts +1 -1
- package/src/event-store/upcaster.ts +1 -1
- package/src/files/file-routes.ts +1 -1
- package/src/files/types.ts +2 -2
- package/src/jobs/job-runner.ts +10 -10
- package/src/lifecycle/lifecycle.ts +0 -3
- package/src/logging/index.ts +1 -0
- package/src/logging/pino-logger.ts +11 -7
- package/src/logging/utils.ts +24 -0
- package/src/observability/prometheus-meter.ts +7 -5
- package/src/pipeline/__tests__/archive-stream.integration.ts +1 -1
- package/src/pipeline/__tests__/causation-chain.integration.ts +1 -1
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +3 -3
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +4 -4
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +1 -1
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +3 -3
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +2 -2
- package/src/pipeline/__tests__/query-projection.integration.ts +5 -5
- package/src/pipeline/append-event-core.ts +22 -6
- package/src/pipeline/dispatcher-utils.ts +188 -0
- package/src/pipeline/dispatcher.ts +63 -283
- package/src/pipeline/distributed-lock.ts +1 -1
- package/src/pipeline/entity-cache.ts +2 -2
- package/src/pipeline/event-consumer-state.ts +0 -13
- package/src/pipeline/event-dispatcher.ts +4 -4
- package/src/pipeline/index.ts +0 -2
- package/src/pipeline/lifecycle-pipeline.ts +6 -12
- package/src/pipeline/msp-rebuild.ts +5 -5
- package/src/pipeline/multi-stream-apply-context.ts +6 -7
- package/src/pipeline/projection-rebuild.ts +2 -2
- package/src/pipeline/projection-state.ts +0 -12
- package/src/rate-limit/__tests__/resolver.integration.ts +8 -4
- package/src/rate-limit/resolver.ts +1 -1
- package/src/search/in-memory-adapter.ts +1 -1
- package/src/search/meilisearch-adapter.ts +3 -3
- package/src/search/types.ts +1 -1
- package/src/secrets/leak-guard.ts +2 -2
- package/src/stack/request-helper.ts +9 -5
- package/src/stack/test-stack.ts +1 -1
- package/src/testing/handler-context.ts +4 -4
- package/src/testing/http-cookies.ts +1 -1
- package/src/time/tz-context.ts +1 -2
- package/src/ui-types/index.ts +4 -0
- package/src/engine/feature-ast/extractors.ts +0 -2602
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { getStep } from "../define-step";
|
|
3
|
+
import {
|
|
4
|
+
SUSPEND_SENTINEL,
|
|
5
|
+
WORKFLOW_AGGREGATE_TYPE,
|
|
6
|
+
WORKFLOW_WAITING_FOR_EVENT_TYPE,
|
|
7
|
+
WORKFLOW_WAITING_TYPE,
|
|
8
|
+
} from "../steps/_step-dispatch-constants";
|
|
9
|
+
import { buildRetryStep, calculateBackoff } from "../steps/retry";
|
|
10
|
+
import { buildWaitStep } from "../steps/wait";
|
|
11
|
+
import { buildWaitForEventStep } from "../steps/wait-for-event";
|
|
12
|
+
import type { PipelineCtx } from "../types/step";
|
|
13
|
+
|
|
14
|
+
const mockUnsafeAppendEvent = vi.fn();
|
|
15
|
+
|
|
16
|
+
const workflowCtx = {
|
|
17
|
+
unsafeAppendEvent: mockUnsafeAppendEvent,
|
|
18
|
+
event: { type: "user.signed-up", payload: { email: "test@example.com" } },
|
|
19
|
+
steps: {},
|
|
20
|
+
scope: {},
|
|
21
|
+
workflow: {
|
|
22
|
+
runId: "wr_abc123",
|
|
23
|
+
workflowName: "test-workflow",
|
|
24
|
+
stepIndex: 0,
|
|
25
|
+
},
|
|
26
|
+
} as unknown as PipelineCtx;
|
|
27
|
+
|
|
28
|
+
const nonWorkflowCtx = {
|
|
29
|
+
unsafeAppendEvent: mockUnsafeAppendEvent,
|
|
30
|
+
event: { type: "test", payload: { url: "https://hooks.example/test" } },
|
|
31
|
+
steps: {},
|
|
32
|
+
scope: {},
|
|
33
|
+
} as unknown as PipelineCtx;
|
|
34
|
+
|
|
35
|
+
describe("buildWaitStep", () => {
|
|
36
|
+
it("returns a StepInstance with kind workflow.wait", () => {
|
|
37
|
+
const step = buildWaitStep({ for: "PT1H" });
|
|
38
|
+
expect(step.kind).toBe("workflow.wait");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("accepts an ISO-8601 duration string", () => {
|
|
42
|
+
const step = buildWaitStep({ for: "P1D" });
|
|
43
|
+
expect((step.args as { for: string }).for).toBe("P1D");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("workflow.wait run", () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
vi.clearAllMocks();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("throws when used outside defineWorkflow (no ctx.workflow)", async () => {
|
|
53
|
+
const stepDef = getStep("workflow.wait");
|
|
54
|
+
expect(stepDef).toBeDefined();
|
|
55
|
+
await expect(stepDef!.run({ for: "PT1H" }, nonWorkflowCtx)).rejects.toThrow(
|
|
56
|
+
/only allowed inside defineWorkflow/,
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("writes a workflow.step.waiting event and returns SUSPEND_SENTINEL", async () => {
|
|
61
|
+
const stepDef = getStep("workflow.wait");
|
|
62
|
+
expect(stepDef).toBeDefined();
|
|
63
|
+
|
|
64
|
+
const result = await stepDef!.run({ for: "PT1H" }, workflowCtx);
|
|
65
|
+
|
|
66
|
+
expect(result).toBe(SUSPEND_SENTINEL);
|
|
67
|
+
expect(mockUnsafeAppendEvent).toHaveBeenCalledOnce();
|
|
68
|
+
const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
|
|
69
|
+
expect(eventArg.aggregateType).toBe(WORKFLOW_AGGREGATE_TYPE);
|
|
70
|
+
expect(eventArg.type).toBe(WORKFLOW_WAITING_TYPE);
|
|
71
|
+
expect(eventArg.aggregateId).toBe("wr_abc123");
|
|
72
|
+
expect(eventArg.payload.stepIndex).toBe(0);
|
|
73
|
+
expect(typeof eventArg.payload.wakeAt).toBe("string");
|
|
74
|
+
expect(eventArg.payload.workflowName).toBe("test-workflow");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("accepts an absolute ISO timestamp as the `for` value", async () => {
|
|
78
|
+
const stepDef = getStep("workflow.wait");
|
|
79
|
+
expect(stepDef).toBeDefined();
|
|
80
|
+
|
|
81
|
+
const future = new Date(Date.now() + 86400000).toISOString();
|
|
82
|
+
const result = await stepDef!.run({ for: future }, workflowCtx);
|
|
83
|
+
|
|
84
|
+
expect(result).toBe(SUSPEND_SENTINEL);
|
|
85
|
+
const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
|
|
86
|
+
expect(eventArg.payload.wakeAt).toBe(future);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("buildWaitForEventStep", () => {
|
|
91
|
+
it("returns a StepInstance with kind workflow.waitForEvent", () => {
|
|
92
|
+
const step = buildWaitForEventStep({
|
|
93
|
+
event: "user.confirmed-email",
|
|
94
|
+
timeout: "P7D",
|
|
95
|
+
});
|
|
96
|
+
expect(step.kind).toBe("workflow.waitForEvent");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("accepts an optional match resolver", () => {
|
|
100
|
+
const step = buildWaitForEventStep({
|
|
101
|
+
event: "user.confirmed-email",
|
|
102
|
+
match: (payload: unknown) => (payload as { email: string }).email === "test@test.com",
|
|
103
|
+
timeout: "P7D",
|
|
104
|
+
});
|
|
105
|
+
expect(step.kind).toBe("workflow.waitForEvent");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("workflow.waitForEvent run", () => {
|
|
110
|
+
beforeEach(() => {
|
|
111
|
+
vi.clearAllMocks();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("throws when used outside defineWorkflow", async () => {
|
|
115
|
+
const stepDef = getStep("workflow.waitForEvent");
|
|
116
|
+
expect(stepDef).toBeDefined();
|
|
117
|
+
await expect(
|
|
118
|
+
stepDef!.run({ event: "user.confirmed-email", timeout: "P7D" }, nonWorkflowCtx),
|
|
119
|
+
).rejects.toThrow(/only allowed inside defineWorkflow/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("writes a workflow.step.waiting-for-event event and returns SUSPEND_SENTINEL", async () => {
|
|
123
|
+
const stepDef = getStep("workflow.waitForEvent");
|
|
124
|
+
expect(stepDef).toBeDefined();
|
|
125
|
+
|
|
126
|
+
const result = await stepDef!.run(
|
|
127
|
+
{ event: "user.confirmed-email", timeout: "P7D" },
|
|
128
|
+
workflowCtx,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
expect(result).toBe(SUSPEND_SENTINEL);
|
|
132
|
+
expect(mockUnsafeAppendEvent).toHaveBeenCalledOnce();
|
|
133
|
+
const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
|
|
134
|
+
expect(eventArg.aggregateType).toBe(WORKFLOW_AGGREGATE_TYPE);
|
|
135
|
+
expect(eventArg.type).toBe(WORKFLOW_WAITING_FOR_EVENT_TYPE);
|
|
136
|
+
expect(eventArg.aggregateId).toBe("wr_abc123");
|
|
137
|
+
expect(eventArg.payload.eventType).toBe("user.confirmed-email");
|
|
138
|
+
expect(typeof eventArg.payload.timeoutAt).toBe("string");
|
|
139
|
+
expect(eventArg.payload.workflowName).toBe("test-workflow");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("buildRetryStep", () => {
|
|
144
|
+
it("returns a StepInstance with kind workflow.retry", () => {
|
|
145
|
+
const step = buildRetryStep({
|
|
146
|
+
times: 3,
|
|
147
|
+
backoff: "exponential",
|
|
148
|
+
do: [],
|
|
149
|
+
});
|
|
150
|
+
expect(step.kind).toBe("workflow.retry");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("requires times and backoff", () => {
|
|
154
|
+
const step = buildRetryStep({
|
|
155
|
+
times: 5,
|
|
156
|
+
backoff: "linear",
|
|
157
|
+
do: [],
|
|
158
|
+
});
|
|
159
|
+
expect((step.args as { times: number }).times).toBe(5);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("workflow.retry run", () => {
|
|
164
|
+
beforeEach(() => {
|
|
165
|
+
vi.clearAllMocks();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("throws when used outside defineWorkflow", async () => {
|
|
169
|
+
const stepDef = getStep("workflow.retry");
|
|
170
|
+
expect(stepDef).toBeDefined();
|
|
171
|
+
await expect(
|
|
172
|
+
stepDef!.run({ times: 3, backoff: "exponential", do: [] }, nonWorkflowCtx),
|
|
173
|
+
).rejects.toThrow(/only allowed inside defineWorkflow/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("executes the do sub-pipeline and returns undefined on success", async () => {
|
|
177
|
+
const stepDef = getStep("workflow.retry");
|
|
178
|
+
expect(stepDef).toBeDefined();
|
|
179
|
+
|
|
180
|
+
const result = await stepDef!.run({ times: 3, backoff: "exponential", do: [] }, workflowCtx);
|
|
181
|
+
|
|
182
|
+
expect(result).toBeUndefined();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("calculateBackoff", () => {
|
|
187
|
+
it("returns baseMs * attempt for linear strategy", () => {
|
|
188
|
+
expect(calculateBackoff(1, "linear")).toBe(10_000);
|
|
189
|
+
expect(calculateBackoff(3, "linear")).toBe(30_000);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("returns baseMs * 2^(attempt-1) for exponential strategy", () => {
|
|
193
|
+
expect(calculateBackoff(1, "exponential")).toBe(10_000);
|
|
194
|
+
expect(calculateBackoff(2, "exponential")).toBe(20_000);
|
|
195
|
+
expect(calculateBackoff(3, "exponential")).toBe(40_000);
|
|
196
|
+
expect(calculateBackoff(4, "exponential")).toBe(80_000);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
// Boot-validator tests for r.step.unsafeProjection-* allowlist enforcement.
|
|
2
|
+
// Recursive walk through sub-pipelines (branch.onTrue/onFalse, forEach.do)
|
|
3
|
+
// is included here, plus the self-registration via defineStep({ subPaths })
|
|
4
|
+
// gate (Followup #15).
|
|
5
|
+
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { eq } from "drizzle-orm";
|
|
8
|
+
import { pgTable, text, uuid } from "drizzle-orm/pg-core";
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { defineFeature } from "../define-feature";
|
|
12
|
+
import { defineWriteHandler } from "../define-handler";
|
|
13
|
+
import { defineStep } from "../define-step";
|
|
14
|
+
import { createEntity, createTextField } from "../factories";
|
|
15
|
+
import { pipeline } from "../pipeline";
|
|
16
|
+
import { validateProjectionAllowlist } from "../validate-projection-allowlist";
|
|
17
|
+
|
|
18
|
+
describe("validateProjectionAllowlist", () => {
|
|
19
|
+
const demoLogTable = pgTable("validate_demo_log", {
|
|
20
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
21
|
+
message: text("message").notNull(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("rejects unsafeProjectionUpsert on an undeclared table", () => {
|
|
25
|
+
const featureWithMissingDeclaration = defineFeature("vproj-missing", (r) => {
|
|
26
|
+
// Note: NO r.requires.projection("validate_demo_log") here.
|
|
27
|
+
r.writeHandler(
|
|
28
|
+
defineWriteHandler({
|
|
29
|
+
name: "log",
|
|
30
|
+
schema: z.object({ msg: z.string() }),
|
|
31
|
+
access: { roles: ["User"] },
|
|
32
|
+
perform: pipeline<{ msg: string }, { ok: true }>(({ event, r }) => [
|
|
33
|
+
r.step.unsafeProjectionUpsert({
|
|
34
|
+
table: demoLogTable,
|
|
35
|
+
on: ["id"],
|
|
36
|
+
row: () => ({ message: event.payload.msg }),
|
|
37
|
+
}),
|
|
38
|
+
r.step.return({ isSuccess: true as const, data: { ok: true } }),
|
|
39
|
+
]),
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(() => validateProjectionAllowlist([featureWithMissingDeclaration])).toThrow(
|
|
45
|
+
/did not declare it via r\.requires\.projection\("validate_demo_log"\)/,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("rejects unsafeProjectionUpsert on an aggregate-table (registered via r.entity)", () => {
|
|
50
|
+
// Feature A registers `widget` as an aggregate (with table "widgets").
|
|
51
|
+
const ownerFeature = defineFeature("vproj-owner", (r) => {
|
|
52
|
+
r.entity(
|
|
53
|
+
"widget",
|
|
54
|
+
createEntity({
|
|
55
|
+
table: "widgets",
|
|
56
|
+
fields: { label: createTextField({ required: true }) },
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Feature B tries to upsert directly into the widgets table — bypassing
|
|
62
|
+
// the aggregate-pipeline. Even with r.requires.projection it must fail.
|
|
63
|
+
const trespasserFeature = defineFeature("vproj-trespasser", (r) => {
|
|
64
|
+
r.requires.projection("widgets");
|
|
65
|
+
const widgetsTable = pgTable("widgets", {
|
|
66
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
67
|
+
label: text("label").notNull(),
|
|
68
|
+
});
|
|
69
|
+
r.writeHandler(
|
|
70
|
+
defineWriteHandler({
|
|
71
|
+
name: "sneaky",
|
|
72
|
+
schema: z.object({}),
|
|
73
|
+
access: { roles: ["User"] },
|
|
74
|
+
perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
|
|
75
|
+
r.step.unsafeProjectionUpsert({
|
|
76
|
+
table: widgetsTable,
|
|
77
|
+
on: ["id"],
|
|
78
|
+
row: () => ({ label: "trespass" }),
|
|
79
|
+
}),
|
|
80
|
+
r.step.return({ isSuccess: true as const, data: { ok: true } }),
|
|
81
|
+
]),
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(() => validateProjectionAllowlist([ownerFeature, trespasserFeature])).toThrow(
|
|
87
|
+
/aggregate-projection of feature "vproj-owner".*r\.step\.aggregate\.\*/s,
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("rejects unsafeProjectionDelete on an aggregate-table (parallel to upsert case)", () => {
|
|
92
|
+
// Both unsafe-projection-* steps share UNSAFE_PROJECTION_KINDS in
|
|
93
|
+
// the validator. Verify the aggregate-table guard fires for delete
|
|
94
|
+
// too — without this test, a future kind-set narrowing could break
|
|
95
|
+
// delete's protection silently.
|
|
96
|
+
const ownerFeature = defineFeature("vproj-delete-owner", (r) => {
|
|
97
|
+
r.entity(
|
|
98
|
+
"widget",
|
|
99
|
+
createEntity({
|
|
100
|
+
table: "widgets-delete",
|
|
101
|
+
fields: { label: createTextField({ required: true }) },
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const trespasserFeature = defineFeature("vproj-delete-trespasser", (r) => {
|
|
107
|
+
r.requires.projection("widgets-delete");
|
|
108
|
+
const widgetsTable = pgTable("widgets-delete", {
|
|
109
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
110
|
+
label: text("label").notNull(),
|
|
111
|
+
});
|
|
112
|
+
r.writeHandler(
|
|
113
|
+
defineWriteHandler({
|
|
114
|
+
name: "sneaky-delete",
|
|
115
|
+
schema: z.object({}),
|
|
116
|
+
access: { roles: ["User"] },
|
|
117
|
+
perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
|
|
118
|
+
r.step.unsafeProjectionDelete({
|
|
119
|
+
table: widgetsTable,
|
|
120
|
+
where: () => eq(widgetsTable.id, "anything"),
|
|
121
|
+
}),
|
|
122
|
+
r.step.return({ isSuccess: true as const, data: { ok: true } }),
|
|
123
|
+
]),
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(() => validateProjectionAllowlist([ownerFeature, trespasserFeature])).toThrow(
|
|
129
|
+
/aggregate-projection of feature "vproj-delete-owner".*r\.step\.aggregate\.\*/s,
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("rejects unsafeProjectionDelete on an undeclared table (same gate as upsert)", () => {
|
|
134
|
+
const featureWithoutDecl = defineFeature("vproj-delete-missing", (r) => {
|
|
135
|
+
r.writeHandler(
|
|
136
|
+
defineWriteHandler({
|
|
137
|
+
name: "purge",
|
|
138
|
+
schema: z.object({}),
|
|
139
|
+
access: { roles: ["User"] },
|
|
140
|
+
perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
|
|
141
|
+
r.step.unsafeProjectionDelete({
|
|
142
|
+
table: demoLogTable,
|
|
143
|
+
where: () => eq(demoLogTable.id, "anything"),
|
|
144
|
+
}),
|
|
145
|
+
r.step.return({ isSuccess: true as const, data: { ok: true } }),
|
|
146
|
+
]),
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(() => validateProjectionAllowlist([featureWithoutDecl])).toThrow(
|
|
152
|
+
/did not declare it via r\.requires\.projection\("validate_demo_log"\)/,
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("walks into branch.onTrue to find unsafeProjection-* (Q17 recursive)", () => {
|
|
157
|
+
// Without recursive walk, the allowlist gate would be bypassed by
|
|
158
|
+
// wrapping the forbidden step in branch.onTrue — exactly the kind of
|
|
159
|
+
// bypass that the unsafe-prefix is meant to make visible.
|
|
160
|
+
const featureWithBranchedUnsafe = defineFeature("vproj-branched", (r) => {
|
|
161
|
+
r.writeHandler(
|
|
162
|
+
defineWriteHandler({
|
|
163
|
+
name: "branchedWrite",
|
|
164
|
+
schema: z.object({}),
|
|
165
|
+
access: { roles: ["User"] },
|
|
166
|
+
perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
|
|
167
|
+
r.step.branch({
|
|
168
|
+
if: () => true,
|
|
169
|
+
onTrue: [
|
|
170
|
+
r.step.unsafeProjectionUpsert({
|
|
171
|
+
table: demoLogTable,
|
|
172
|
+
on: ["id"],
|
|
173
|
+
row: () => ({ message: "wrapped in branch" }),
|
|
174
|
+
}),
|
|
175
|
+
],
|
|
176
|
+
}),
|
|
177
|
+
r.step.return({ isSuccess: true as const, data: { ok: true } }),
|
|
178
|
+
]),
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(() => validateProjectionAllowlist([featureWithBranchedUnsafe])).toThrow(
|
|
184
|
+
/did not declare it via r\.requires\.projection\("validate_demo_log"\)/,
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("walks recursively through nested sub-pipelines (forEach.do containing branch.onTrue containing unsafeProjection)", () => {
|
|
189
|
+
// Generator-depth coverage: if walkAllSteps' yield* gets removed
|
|
190
|
+
// in a future refactor, top-level + one-level tests stay green
|
|
191
|
+
// but nested patterns (very common: forEach with conditional
|
|
192
|
+
// upsert-or-delete) silently bypass the allowlist.
|
|
193
|
+
const featureWithNestedUnsafe = defineFeature("vproj-nested", (r) => {
|
|
194
|
+
r.writeHandler(
|
|
195
|
+
defineWriteHandler({
|
|
196
|
+
name: "nestedWrite",
|
|
197
|
+
schema: z.object({}),
|
|
198
|
+
access: { roles: ["User"] },
|
|
199
|
+
perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
|
|
200
|
+
r.step.forEach({
|
|
201
|
+
over: () => [],
|
|
202
|
+
as: "item",
|
|
203
|
+
do: [
|
|
204
|
+
r.step.branch({
|
|
205
|
+
if: () => true,
|
|
206
|
+
onTrue: [
|
|
207
|
+
r.step.unsafeProjectionUpsert({
|
|
208
|
+
table: demoLogTable,
|
|
209
|
+
on: ["id"],
|
|
210
|
+
row: () => ({ message: "deeply nested" }),
|
|
211
|
+
}),
|
|
212
|
+
],
|
|
213
|
+
}),
|
|
214
|
+
],
|
|
215
|
+
}),
|
|
216
|
+
r.step.return({ isSuccess: true as const, data: { ok: true } }),
|
|
217
|
+
]),
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(() => validateProjectionAllowlist([featureWithNestedUnsafe])).toThrow(
|
|
223
|
+
/did not declare it via r\.requires\.projection\("validate_demo_log"\)/,
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("walks into forEach.do to find unsafeProjection-* (Q17 recursive)", () => {
|
|
228
|
+
const featureWithLoopedUnsafe = defineFeature("vproj-looped", (r) => {
|
|
229
|
+
r.writeHandler(
|
|
230
|
+
defineWriteHandler({
|
|
231
|
+
name: "loopedWrite",
|
|
232
|
+
schema: z.object({}),
|
|
233
|
+
access: { roles: ["User"] },
|
|
234
|
+
perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
|
|
235
|
+
r.step.forEach({
|
|
236
|
+
over: () => [],
|
|
237
|
+
as: "x",
|
|
238
|
+
do: [
|
|
239
|
+
r.step.unsafeProjectionUpsert({
|
|
240
|
+
table: demoLogTable,
|
|
241
|
+
on: ["id"],
|
|
242
|
+
row: () => ({ message: "looped" }),
|
|
243
|
+
}),
|
|
244
|
+
],
|
|
245
|
+
}),
|
|
246
|
+
r.step.return({ isSuccess: true as const, data: { ok: true } }),
|
|
247
|
+
]),
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
expect(() => validateProjectionAllowlist([featureWithLoopedUnsafe])).toThrow(
|
|
253
|
+
/did not declare it via r\.requires\.projection\("validate_demo_log"\)/,
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("does NOT recurse into unregistered step-kind sub-arrays — defineStep is the registration gate (#15)", () => {
|
|
258
|
+
// Pins the Followup #15 contract: walkAllSteps recurses ONLY into
|
|
259
|
+
// sub-arrays whose step-kind is registered with `subPaths` via
|
|
260
|
+
// defineStep. Hand-crafted instances with unregistered kinds are
|
|
261
|
+
// walked as a single node — their nested arrays are invisible.
|
|
262
|
+
// This is the SHAPE of the gate, demonstrated explicitly so the
|
|
263
|
+
// contract is testable, not accidental.
|
|
264
|
+
const featureWithUnknownSubBuilder = defineFeature("vproj-future-builder", (r) => {
|
|
265
|
+
r.requires.projection("validate_demo_log");
|
|
266
|
+
r.writeHandler(
|
|
267
|
+
defineWriteHandler({
|
|
268
|
+
name: "futureBuilder",
|
|
269
|
+
schema: z.object({}),
|
|
270
|
+
access: { roles: ["User"] },
|
|
271
|
+
perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
|
|
272
|
+
// Hand-crafted StepInstance simulating a future builder
|
|
273
|
+
// whose kind isn't registered via defineStep yet.
|
|
274
|
+
{
|
|
275
|
+
kind: "future-sub-builder",
|
|
276
|
+
args: {
|
|
277
|
+
children: [
|
|
278
|
+
r.step.unsafeProjectionUpsert({
|
|
279
|
+
table: demoLogTable,
|
|
280
|
+
on: ["id"],
|
|
281
|
+
row: () => ({ message: "should escape allowlist" }),
|
|
282
|
+
}),
|
|
283
|
+
],
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
r.step.return({ isSuccess: true as const, data: { ok: true } }),
|
|
287
|
+
]),
|
|
288
|
+
}),
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
expect(() => validateProjectionAllowlist([featureWithUnknownSubBuilder])).not.toThrow();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("DOES recurse into sub-arrays of step-kinds registered with subPaths via defineStep (#15)", () => {
|
|
296
|
+
// Inverse of the previous test: when a NEW sub-step-builder
|
|
297
|
+
// self-registers via defineStep({ subPaths: [...] }), the boot-
|
|
298
|
+
// validator picks up the recursion automatically — no central
|
|
299
|
+
// map to update. This is the value of self-registration.
|
|
300
|
+
const futureKind = `test:future-builder:${randomUUID()}`;
|
|
301
|
+
defineStep({
|
|
302
|
+
kind: futureKind,
|
|
303
|
+
defaultFailureStrategy: "throw",
|
|
304
|
+
subPaths: ["children"],
|
|
305
|
+
run: () => undefined,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const undeclaredTable = pgTable(`undeclared_${randomUUID().replace(/-/g, "")}`, {
|
|
309
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
310
|
+
message: text("message").notNull(),
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const feature = defineFeature(`vproj-future-builder-${randomUUID()}`, (r) => {
|
|
314
|
+
// Note: NO r.requires.projection for the undeclared table.
|
|
315
|
+
r.writeHandler(
|
|
316
|
+
defineWriteHandler({
|
|
317
|
+
name: "futureBuilder",
|
|
318
|
+
schema: z.object({}),
|
|
319
|
+
access: { roles: ["User"] },
|
|
320
|
+
perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
|
|
321
|
+
{
|
|
322
|
+
kind: futureKind,
|
|
323
|
+
args: {
|
|
324
|
+
children: [
|
|
325
|
+
r.step.unsafeProjectionUpsert({
|
|
326
|
+
table: undeclaredTable,
|
|
327
|
+
on: ["id"],
|
|
328
|
+
row: () => ({ message: "deeply nested" }),
|
|
329
|
+
}),
|
|
330
|
+
],
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
r.step.return({ isSuccess: true as const, data: { ok: true } }),
|
|
334
|
+
]),
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Walker descended into `children` because the registered StepDef
|
|
340
|
+
// declared it as a subPath, and the inner unsafeProjection trips
|
|
341
|
+
// the allowlist check.
|
|
342
|
+
expect(() => validateProjectionAllowlist([feature])).toThrow(
|
|
343
|
+
/did not declare it via r\.requires\.projection/,
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("rejects two features registering r.entity on the same table (#8)", () => {
|
|
348
|
+
// Without this guard, the second r.entity() silently overwrites the
|
|
349
|
+
// first in the validator's aggregate-tables map — and a later
|
|
350
|
+
// unsafeProjection error against that table would name the WRONG
|
|
351
|
+
// feature as owner. Surfacing the collision here names both parties.
|
|
352
|
+
const featureA = defineFeature("dup-aggregate-a", (r) => {
|
|
353
|
+
r.entity(
|
|
354
|
+
"thing",
|
|
355
|
+
createEntity({
|
|
356
|
+
table: "shared_table",
|
|
357
|
+
fields: { label: createTextField({ required: true }) },
|
|
358
|
+
}),
|
|
359
|
+
);
|
|
360
|
+
});
|
|
361
|
+
const featureB = defineFeature("dup-aggregate-b", (r) => {
|
|
362
|
+
r.entity(
|
|
363
|
+
"thing",
|
|
364
|
+
createEntity({
|
|
365
|
+
table: "shared_table",
|
|
366
|
+
fields: { label: createTextField({ required: true }) },
|
|
367
|
+
}),
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
expect(() => validateProjectionAllowlist([featureA, featureB])).toThrow(
|
|
372
|
+
/both feature "dup-aggregate-a" and feature "dup-aggregate-b"/,
|
|
373
|
+
);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("rejects use of a tier-2 step without r.requires.step(...) declaration (Q9)", () => {
|
|
377
|
+
const sneakyFeature = defineFeature("step-discovery-missing", (r) => {
|
|
378
|
+
// Note: NO r.requires.step("webhook.send")
|
|
379
|
+
r.writeHandler(
|
|
380
|
+
defineWriteHandler({
|
|
381
|
+
name: "sneak",
|
|
382
|
+
schema: z.object({}),
|
|
383
|
+
access: { roles: ["Admin"] },
|
|
384
|
+
perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
|
|
385
|
+
r.step.webhook.send({
|
|
386
|
+
url: "https://hooks.example/sneak",
|
|
387
|
+
mode: "deferred",
|
|
388
|
+
body: () => ({}),
|
|
389
|
+
}),
|
|
390
|
+
r.step.return({ isSuccess: true as const, data: { ok: true } }),
|
|
391
|
+
]),
|
|
392
|
+
}),
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
expect(() => validateProjectionAllowlist([sneakyFeature])).toThrow(
|
|
397
|
+
/did not declare it via r\.requires\.step\("webhook\.send"\)/,
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("rejects mail.send without r.requires.step(...) declaration", () => {
|
|
402
|
+
const sneakyMail = defineFeature("step-discovery-mail-missing", (r) => {
|
|
403
|
+
r.writeHandler(
|
|
404
|
+
defineWriteHandler({
|
|
405
|
+
name: "sneak",
|
|
406
|
+
schema: z.object({}),
|
|
407
|
+
access: { roles: ["Admin"] },
|
|
408
|
+
perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
|
|
409
|
+
r.step.mail.send({
|
|
410
|
+
to: "x@y.com",
|
|
411
|
+
subject: "hi",
|
|
412
|
+
body: "hello",
|
|
413
|
+
mode: "deferred",
|
|
414
|
+
}),
|
|
415
|
+
r.step.return({ isSuccess: true as const, data: { ok: true } }),
|
|
416
|
+
]),
|
|
417
|
+
}),
|
|
418
|
+
);
|
|
419
|
+
});
|
|
420
|
+
expect(() => validateProjectionAllowlist([sneakyMail])).toThrow(
|
|
421
|
+
/did not declare it via r\.requires\.step\("mail\.send"\)/,
|
|
422
|
+
);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("rejects callFeature without r.requires.step(...) declaration", () => {
|
|
426
|
+
const sneakyCall = defineFeature("step-discovery-call-missing", (r) => {
|
|
427
|
+
r.writeHandler(
|
|
428
|
+
defineWriteHandler({
|
|
429
|
+
name: "sneak",
|
|
430
|
+
schema: z.object({}),
|
|
431
|
+
access: { roles: ["Admin"] },
|
|
432
|
+
perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
|
|
433
|
+
r.step.callFeature("subResult", {
|
|
434
|
+
handler: "other:write:do",
|
|
435
|
+
payload: () => ({}),
|
|
436
|
+
}),
|
|
437
|
+
r.step.return({ isSuccess: true as const, data: { ok: true } }),
|
|
438
|
+
]),
|
|
439
|
+
}),
|
|
440
|
+
);
|
|
441
|
+
});
|
|
442
|
+
expect(() => validateProjectionAllowlist([sneakyCall])).toThrow(
|
|
443
|
+
/did not declare it via r\.requires\.step\("callFeature"\)/,
|
|
444
|
+
);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("accepts a tier-2 step when r.requires.step(...) is declared", () => {
|
|
448
|
+
const happyFeature = defineFeature("step-discovery-happy", (r) => {
|
|
449
|
+
r.requires.step("webhook.send");
|
|
450
|
+
r.writeHandler(
|
|
451
|
+
defineWriteHandler({
|
|
452
|
+
name: "ok",
|
|
453
|
+
schema: z.object({}),
|
|
454
|
+
access: { roles: ["Admin"] },
|
|
455
|
+
perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
|
|
456
|
+
r.step.webhook.send({
|
|
457
|
+
url: "https://hooks.example/ok",
|
|
458
|
+
mode: "deferred",
|
|
459
|
+
body: () => ({}),
|
|
460
|
+
}),
|
|
461
|
+
r.step.return({ isSuccess: true as const, data: { ok: true } }),
|
|
462
|
+
]),
|
|
463
|
+
}),
|
|
464
|
+
);
|
|
465
|
+
});
|
|
466
|
+
expect(() => validateProjectionAllowlist([happyFeature])).not.toThrow();
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("accepts unsafeProjectionUpsert when the table is declared and not an aggregate", () => {
|
|
470
|
+
const happyFeature = defineFeature("vproj-happy", (r) => {
|
|
471
|
+
r.requires.projection("validate_demo_log");
|
|
472
|
+
r.writeHandler(
|
|
473
|
+
defineWriteHandler({
|
|
474
|
+
name: "log",
|
|
475
|
+
schema: z.object({ msg: z.string() }),
|
|
476
|
+
access: { roles: ["User"] },
|
|
477
|
+
perform: pipeline<{ msg: string }, { ok: true }>(({ event, r }) => [
|
|
478
|
+
r.step.unsafeProjectionUpsert({
|
|
479
|
+
table: demoLogTable,
|
|
480
|
+
on: ["id"],
|
|
481
|
+
row: () => ({ message: event.payload.msg }),
|
|
482
|
+
}),
|
|
483
|
+
r.step.return({ isSuccess: true as const, data: { ok: true } }),
|
|
484
|
+
]),
|
|
485
|
+
}),
|
|
486
|
+
);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
expect(() => validateProjectionAllowlist([happyFeature])).not.toThrow();
|
|
490
|
+
});
|
|
491
|
+
});
|