@notionhq/apps 0.0.35 → 0.0.37
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 +11 -0
- package/dist/cli/codegen.d.ts.map +1 -1
- package/dist/cli/codegen.js +10 -2
- package/dist/custom-block.d.ts +1 -1
- package/dist/events.generated.d.ts +19 -3
- package/dist/events.generated.d.ts.map +1 -1
- package/dist/events.generated.js +25 -1
- package/dist/schema-builder.d.ts +68 -0
- package/dist/schema-builder.d.ts.map +1 -0
- package/dist/schema-builder.js +90 -0
- package/dist/workflow-schema.d.ts +2 -0
- package/dist/workflow-schema.d.ts.map +1 -0
- package/dist/workflow-schema.js +38 -0
- package/dist/workflow-schema.test.d.ts +2 -0
- package/dist/workflow-schema.test.d.ts.map +1 -0
- package/dist/workflow.d.ts +103 -4
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +50 -4
- package/package.json +7 -2
- package/skills/workflow/SKILL.md +33 -0
- package/src/cli/build.test.ts +56 -0
- package/src/cli/codegen.test.ts +14 -0
- package/src/cli/codegen.ts +10 -2
- package/src/cli/discover.test.ts +7 -0
- package/src/events.generated.ts +47 -0
- package/src/schema-builder.ts +149 -0
- package/src/workflow-schema.test.ts +160 -0
- package/src/workflow-schema.ts +42 -0
- package/src/workflow-types.test.ts +65 -0
- package/src/workflow.test.ts +306 -1
- package/src/workflow.ts +196 -9
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, it } from "vitest";
|
|
2
|
+
import { getSchema, j, type Infer } from "./schema-builder.js";
|
|
3
|
+
import { events } from "./events.generated.js";
|
|
4
|
+
import { compileWorkflowInputSchema } from "./workflow-schema.js";
|
|
5
|
+
import type { WorkflowEventForTrigger } from "./workflow.js";
|
|
6
|
+
|
|
7
|
+
const releaseSchema = j.object({
|
|
8
|
+
releases: j.array(j.object({ version: j.string(), dryRun: j.boolean().nullable() })),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("manual input schema builders", () => {
|
|
12
|
+
it("infers nested nullable fields without optional keys or readonly output", () => {
|
|
13
|
+
const trigger = events.manual({ inputSchema: releaseSchema });
|
|
14
|
+
type Input = WorkflowEventForTrigger<typeof trigger>["input"];
|
|
15
|
+
expectTypeOf<Input>().toEqualTypeOf<{
|
|
16
|
+
releases: { version: string; dryRun: boolean | null }[];
|
|
17
|
+
}>();
|
|
18
|
+
expectTypeOf<Input>().toEqualTypeOf<Infer<typeof releaseSchema>>();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("serializes a builder as standard JSON Schema with all keys required", () => {
|
|
22
|
+
const trigger = events.manual({ inputSchema: releaseSchema });
|
|
23
|
+
expect(JSON.parse(JSON.stringify(trigger))).toEqual({
|
|
24
|
+
type: "workflow.manual",
|
|
25
|
+
inputJsonSchema: {
|
|
26
|
+
type: "object",
|
|
27
|
+
required: ["releases"],
|
|
28
|
+
additionalProperties: false,
|
|
29
|
+
properties: {
|
|
30
|
+
releases: {
|
|
31
|
+
type: "array",
|
|
32
|
+
items: {
|
|
33
|
+
type: "object",
|
|
34
|
+
required: ["version", "dryRun"],
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
properties: {
|
|
37
|
+
version: { type: "string" },
|
|
38
|
+
dryRun: { anyOf: [{ type: "boolean" }, { type: "null" }] },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("accepts explicit null and rejects missing, invalid, and extra fields without coercion", () => {
|
|
48
|
+
const { validate } = compileWorkflowInputSchema(releaseSchema);
|
|
49
|
+
for (const dryRun of [true, false, null]) {
|
|
50
|
+
expect(() => validate({ releases: [{ version: "1", dryRun }] })).not.toThrow();
|
|
51
|
+
}
|
|
52
|
+
for (const input of [
|
|
53
|
+
{},
|
|
54
|
+
{ releases: [{}] },
|
|
55
|
+
{ releases: [{ version: "1" }] },
|
|
56
|
+
{ releases: [{ version: "1", dryRun: "false" }] },
|
|
57
|
+
{ releases: null },
|
|
58
|
+
{ releases: [], extra: true },
|
|
59
|
+
]) {
|
|
60
|
+
const original = structuredClone(input);
|
|
61
|
+
expect(() => validate(input)).toThrow("Invalid manual workflow input");
|
|
62
|
+
expect(input).toEqual(original);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("supports primitive, enum, nullable object, and nullable array inference and validation", () => {
|
|
67
|
+
const inputSchema = j.object({
|
|
68
|
+
count: j.integer(),
|
|
69
|
+
ratio: j.number(),
|
|
70
|
+
stage: j.enum("draft", "live"),
|
|
71
|
+
config: j.object({ label: j.string() }).nullable(),
|
|
72
|
+
tags: j.array(j.string(), { minItems: 1 }).nullable(),
|
|
73
|
+
});
|
|
74
|
+
const trigger = events.manual({ inputSchema });
|
|
75
|
+
type Input = WorkflowEventForTrigger<typeof trigger>["input"];
|
|
76
|
+
expectTypeOf<Input>().toEqualTypeOf<{
|
|
77
|
+
count: number;
|
|
78
|
+
ratio: number;
|
|
79
|
+
stage: "draft" | "live";
|
|
80
|
+
config: { label: string } | null;
|
|
81
|
+
tags: string[] | null;
|
|
82
|
+
}>();
|
|
83
|
+
const { validate } = compileWorkflowInputSchema(inputSchema);
|
|
84
|
+
const input = { count: 1, ratio: 0.5, stage: "draft", config: null, tags: null };
|
|
85
|
+
expect(() => validate(input)).not.toThrow();
|
|
86
|
+
for (const change of [{ count: 0.5 }, { stage: "unknown" }, { config: {} }, { tags: [] }]) {
|
|
87
|
+
expect(() => validate({ ...input, ...change })).toThrow();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("keeps metadata outside nullable unions and builders immutable", () => {
|
|
92
|
+
const name = j.string().label("Name").describe("A name");
|
|
93
|
+
const nullable = name.nullable();
|
|
94
|
+
expect(getSchema(nullable)).toEqual({
|
|
95
|
+
title: "Name",
|
|
96
|
+
description: "A name",
|
|
97
|
+
anyOf: [{ type: "string" }, { type: "null" }],
|
|
98
|
+
});
|
|
99
|
+
const copy = getSchema(name);
|
|
100
|
+
copy.description = "Changed";
|
|
101
|
+
expect(getSchema(name)).toEqual({ type: "string", title: "Name", description: "A name" });
|
|
102
|
+
expect(getSchema(j.object({ z: name, a: name }).describe("Root"))).toMatchObject({
|
|
103
|
+
required: ["z", "a"],
|
|
104
|
+
description: "Root",
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("rejects plain JSON schemas and non-object roots from untyped callers", () => {
|
|
109
|
+
for (const inputSchema of [
|
|
110
|
+
JSON.parse('{"type":"object","properties":{}}'),
|
|
111
|
+
JSON.parse("null"),
|
|
112
|
+
JSON.parse('"string"'),
|
|
113
|
+
]) {
|
|
114
|
+
expect(() => events.manual({ inputSchema })).toThrow();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("formatted manual inputs", () => {
|
|
120
|
+
it.each([
|
|
121
|
+
{
|
|
122
|
+
builder: j.datetime,
|
|
123
|
+
format: "date-time",
|
|
124
|
+
valid: "2026-09-24T10:30:00Z",
|
|
125
|
+
invalid: "2026-09-24",
|
|
126
|
+
},
|
|
127
|
+
{ builder: j.date, format: "date", valid: "2024-02-29", invalid: "2025-02-29" },
|
|
128
|
+
{ builder: j.time, format: "time", valid: "10:30:00Z", invalid: "25:00:00Z" },
|
|
129
|
+
{ builder: j.duration, format: "duration", valid: "P1DT2H", invalid: "tomorrow" },
|
|
130
|
+
{ builder: j.email, format: "email", valid: "user@example.com", invalid: "not-an-email" },
|
|
131
|
+
{ builder: j.hostname, format: "hostname", valid: "example.com", invalid: "bad host" },
|
|
132
|
+
{ builder: j.ipv4, format: "ipv4", valid: "192.0.2.1", invalid: "256.0.0.1" },
|
|
133
|
+
{ builder: j.ipv6, format: "ipv6", valid: "2001:db8::1", invalid: "2001:db8::xyz" },
|
|
134
|
+
{
|
|
135
|
+
builder: j.uuid,
|
|
136
|
+
format: "uuid",
|
|
137
|
+
valid: "74877cbb-93f9-41fb-9143-c1a11bf88f6b",
|
|
138
|
+
invalid: "not-a-uuid",
|
|
139
|
+
},
|
|
140
|
+
])(
|
|
141
|
+
"serializes and validates $format, including nested nullable inputs",
|
|
142
|
+
({ builder, format, valid, invalid }) => {
|
|
143
|
+
const field = builder().label("Input").describe("Help").nullable();
|
|
144
|
+
expectTypeOf<Infer<typeof field>>().toEqualTypeOf<string | null>();
|
|
145
|
+
expect(getSchema(field)).toEqual({
|
|
146
|
+
title: "Input",
|
|
147
|
+
description: "Help",
|
|
148
|
+
anyOf: [{ type: "string", format }, { type: "null" }],
|
|
149
|
+
});
|
|
150
|
+
const schema = j.object({ values: j.array(j.object({ value: field })) });
|
|
151
|
+
const { validate } = compileWorkflowInputSchema(schema);
|
|
152
|
+
for (const value of [valid, null])
|
|
153
|
+
expect(() => validate({ values: [{ value }] })).not.toThrow();
|
|
154
|
+
expect(() => validate({ values: [{ value: invalid }] })).toThrow(
|
|
155
|
+
"Invalid manual workflow input",
|
|
156
|
+
);
|
|
157
|
+
expect(() => validate({ values: [{}] })).toThrow();
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Ajv } from "ajv";
|
|
2
|
+
import addFormats from "ajv-formats";
|
|
3
|
+
import { getSchema, type SchemaBuilder } from "./schema-builder.js";
|
|
4
|
+
|
|
5
|
+
/** @internal */
|
|
6
|
+
export function compileWorkflowInputSchema(
|
|
7
|
+
schema: SchemaBuilder<Record<string, unknown>, "object">,
|
|
8
|
+
): {
|
|
9
|
+
jsonSchema: Record<string, unknown>;
|
|
10
|
+
validate: (input: unknown) => void;
|
|
11
|
+
} {
|
|
12
|
+
const jsonSchema = getSchema(schema);
|
|
13
|
+
if (!("type" in jsonSchema) || jsonSchema.type !== "object") {
|
|
14
|
+
throw new Error("workflow manual inputSchema: must be a j.object() schema");
|
|
15
|
+
}
|
|
16
|
+
const ajv = new Ajv({ allErrors: true, strict: true });
|
|
17
|
+
addFormats.default(ajv, {
|
|
18
|
+
formats: [
|
|
19
|
+
"date-time",
|
|
20
|
+
"date",
|
|
21
|
+
"time",
|
|
22
|
+
"duration",
|
|
23
|
+
"email",
|
|
24
|
+
"hostname",
|
|
25
|
+
"ipv4",
|
|
26
|
+
"ipv6",
|
|
27
|
+
"uuid",
|
|
28
|
+
],
|
|
29
|
+
keywords: false,
|
|
30
|
+
});
|
|
31
|
+
const validate = ajv.compile(jsonSchema);
|
|
32
|
+
return {
|
|
33
|
+
jsonSchema,
|
|
34
|
+
validate(input) {
|
|
35
|
+
if (!validate(input)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Invalid manual workflow input: ${ajv.errorsText(validate.errors)}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -4,6 +4,70 @@ import { expect, it } from "vitest";
|
|
|
4
4
|
|
|
5
5
|
it("checks provider keys on callback arguments and returned triggers", () => {
|
|
6
6
|
const cases = [
|
|
7
|
+
{
|
|
8
|
+
name: "manual",
|
|
9
|
+
valid: true,
|
|
10
|
+
connections: undefined,
|
|
11
|
+
trigger:
|
|
12
|
+
"events.manual({ inputSchema: j.object({version: j.string(), dryRun: j.boolean()}) })",
|
|
13
|
+
handler:
|
|
14
|
+
"(event) => { const version: string = event.input.version; const dryRun: boolean = event.input.dryRun; void version; void dryRun; }",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: "manual_input_mismatch",
|
|
18
|
+
valid: false,
|
|
19
|
+
connections: undefined,
|
|
20
|
+
trigger: "events.manual({ inputSchema: j.object({version: j.string()}) })",
|
|
21
|
+
handler: "(event) => { const version: number = event.input.version; void version; }",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "manual_requires_object_schema",
|
|
25
|
+
valid: false,
|
|
26
|
+
connections: undefined,
|
|
27
|
+
trigger: "events.manual({ inputSchema: j.string() })",
|
|
28
|
+
handler: "() => {}",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "manual_nested",
|
|
32
|
+
valid: true,
|
|
33
|
+
connections: undefined,
|
|
34
|
+
trigger:
|
|
35
|
+
"events.manual({ inputSchema: j.object({releases: j.array(j.object({version: j.string(), dryRun: j.boolean().nullable()}))}) })",
|
|
36
|
+
handler:
|
|
37
|
+
'(event) => { const releases: { version: string; dryRun: boolean | null }[] = event.input.releases; event.input.releases = [{ version: "1.0", dryRun: null }]; void releases; }',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "manual_nullable_not_boolean",
|
|
41
|
+
valid: false,
|
|
42
|
+
connections: undefined,
|
|
43
|
+
trigger: "events.manual({ inputSchema: j.object({dryRun: j.boolean().nullable()}) })",
|
|
44
|
+
handler: "(event) => { const dryRun: boolean = event.input.dryRun; void dryRun; }",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "manual_unknown_property",
|
|
48
|
+
valid: false,
|
|
49
|
+
connections: undefined,
|
|
50
|
+
trigger: "globalTriggers.manual({ inputSchema: j.object({}) })",
|
|
51
|
+
handler: "(event) => { void event.input.unknown; }",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "manual_rejects_plain_schema",
|
|
55
|
+
valid: false,
|
|
56
|
+
connections: undefined,
|
|
57
|
+
trigger:
|
|
58
|
+
'events.manual({ inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] } })',
|
|
59
|
+
handler: "() => {}",
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
{
|
|
63
|
+
name: "manual_open_object",
|
|
64
|
+
valid: false,
|
|
65
|
+
connections: undefined,
|
|
66
|
+
trigger:
|
|
67
|
+
'events.manual({ inputSchema: { type: "object", properties: {}, additionalProperties: true } })',
|
|
68
|
+
handler: "() => {}",
|
|
69
|
+
},
|
|
70
|
+
|
|
7
71
|
{
|
|
8
72
|
name: "valid",
|
|
9
73
|
valid: true,
|
|
@@ -166,6 +230,7 @@ it("checks provider keys on callback arguments and returned triggers", () => {
|
|
|
166
230
|
resolve("src", `__workflow_types_${testCase.name}.ts`),
|
|
167
231
|
`import { connections, workflow } from "./workflow.js";
|
|
168
232
|
import { events as globalTriggers } from "./events.generated.js";
|
|
233
|
+
import { j } from "./schema-builder.js";
|
|
169
234
|
const support = connections.slack();
|
|
170
235
|
const oauthConfig = { authorizationEndpoint: "https://example.com/authorize", tokenEndpoint: "https://example.com/token", clientId: "client", clientSecretEnv: "CLIENT_SECRET", scope: "read" };
|
|
171
236
|
workflow({
|
package/src/workflow.test.ts
CHANGED
|
@@ -5,9 +5,10 @@ import { join } from "node:path";
|
|
|
5
5
|
|
|
6
6
|
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi, type Mock } from "vitest";
|
|
7
7
|
|
|
8
|
+
import { j } from "./schema-builder.js";
|
|
8
9
|
import { ExecutionError } from "./error.js";
|
|
9
10
|
import type { RunMetadata } from "./runtime-metadata.js";
|
|
10
|
-
import { events, type NotionPageCreatedEvent } from "./events.generated.js";
|
|
11
|
+
import { events, type NotionPageCreatedEvent, type WebhookEvent } from "./events.generated.js";
|
|
11
12
|
import { customAgent } from "./notion-as-code/custom-agent.js";
|
|
12
13
|
import { database } from "./notion-as-code/database.js";
|
|
13
14
|
import { page } from "./notion-as-code/page.js";
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
input,
|
|
18
19
|
workflow,
|
|
19
20
|
type StepContext,
|
|
21
|
+
type WebhookVerifyHandler,
|
|
20
22
|
type WorkflowContext,
|
|
21
23
|
type WorkflowInputDate,
|
|
22
24
|
type WorkflowWaitDuration,
|
|
@@ -56,6 +58,72 @@ afterEach(async () => {
|
|
|
56
58
|
|
|
57
59
|
const event = { type: "notion.page.created" } as NotionPageCreatedEvent;
|
|
58
60
|
|
|
61
|
+
describe("manual workflow triggers", () => {
|
|
62
|
+
it("runs a manual trigger without arguments and rejects undeclared input", async () => {
|
|
63
|
+
const handler = vi.fn();
|
|
64
|
+
const definition = workflow({
|
|
65
|
+
name: "No inputs",
|
|
66
|
+
description: "Runs without form fields",
|
|
67
|
+
triggers: [events.manual()],
|
|
68
|
+
handler: async (event) => {
|
|
69
|
+
handler(event.input);
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
await definition.handler({ type: "workflow.manual", input: {} });
|
|
73
|
+
expect(handler).toHaveBeenCalledWith({});
|
|
74
|
+
await expect(
|
|
75
|
+
definition.handler(JSON.parse('{"type":"workflow.manual","input":{"extra":true}}')),
|
|
76
|
+
).rejects.toBeInstanceOf(ExecutionError);
|
|
77
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("infers and validates the manual event input", async () => {
|
|
81
|
+
const handler = vi.fn();
|
|
82
|
+
const definition = workflow({
|
|
83
|
+
name: "Publish release",
|
|
84
|
+
description: "Publishes a release from manually entered values.",
|
|
85
|
+
triggers: [
|
|
86
|
+
events.manual({
|
|
87
|
+
inputSchema: j.object({ version: j.string(), dryRun: j.boolean() }),
|
|
88
|
+
}),
|
|
89
|
+
],
|
|
90
|
+
handler: async (manualEvent) => {
|
|
91
|
+
handler(manualEvent.input);
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await definition.handler({
|
|
96
|
+
type: "workflow.manual",
|
|
97
|
+
input: { version: "1.2.3", dryRun: true },
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(handler).toHaveBeenCalledWith({
|
|
101
|
+
version: "1.2.3",
|
|
102
|
+
dryRun: true,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("rejects invalid manual input before invoking the handler", async () => {
|
|
107
|
+
const handler = vi.fn();
|
|
108
|
+
const definition = workflow({
|
|
109
|
+
name: "Publish release",
|
|
110
|
+
description: "Publishes a release from manually entered values.",
|
|
111
|
+
triggers: [
|
|
112
|
+
events.manual({
|
|
113
|
+
inputSchema: j.object({ version: j.string() }),
|
|
114
|
+
}),
|
|
115
|
+
],
|
|
116
|
+
handler,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await expect(
|
|
120
|
+
definition.handler(JSON.parse('{"type":"workflow.manual","input":{"version":42}}')),
|
|
121
|
+
).rejects.toBeInstanceOf(ExecutionError);
|
|
122
|
+
|
|
123
|
+
expect(handler).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
59
127
|
async function createWorkflowContext(): Promise<WorkflowContext<Record<never, never>>> {
|
|
60
128
|
let context: WorkflowContext<Record<never, never>> | undefined;
|
|
61
129
|
const definition = workflow({
|
|
@@ -1621,3 +1689,240 @@ describe("connection-bound triggers", () => {
|
|
|
1621
1689
|
).not.toThrow();
|
|
1622
1690
|
});
|
|
1623
1691
|
});
|
|
1692
|
+
|
|
1693
|
+
describe("workflow verify", () => {
|
|
1694
|
+
const verifyRequest = {
|
|
1695
|
+
method: "POST" as const,
|
|
1696
|
+
url: "https://123456.webhook.notionusercontent.com/webhooks/app/s/w/u/incoming",
|
|
1697
|
+
query: {},
|
|
1698
|
+
headers: { "x-signature": "sig" },
|
|
1699
|
+
rawBody: '{"challenge":"abc"}',
|
|
1700
|
+
};
|
|
1701
|
+
|
|
1702
|
+
it("marks the webhook trigger with hasVerify when a verify handler is set", () => {
|
|
1703
|
+
const incoming = workflow({
|
|
1704
|
+
name: "Incoming",
|
|
1705
|
+
description: "Processes an incoming webhook",
|
|
1706
|
+
triggers: [events.webhook()],
|
|
1707
|
+
verify: () => ({ status: 200 }),
|
|
1708
|
+
handler: () => {},
|
|
1709
|
+
});
|
|
1710
|
+
|
|
1711
|
+
expect(incoming.config).toEqual({
|
|
1712
|
+
name: "Incoming",
|
|
1713
|
+
description: "Processes an incoming webhook",
|
|
1714
|
+
triggers: [{ type: "webhooks.webhook", hasVerify: true }],
|
|
1715
|
+
});
|
|
1716
|
+
});
|
|
1717
|
+
|
|
1718
|
+
it("flags only the webhook trigger among a workflow's triggers", () => {
|
|
1719
|
+
const incoming = workflow({
|
|
1720
|
+
name: "Incoming",
|
|
1721
|
+
description: "Processes an incoming webhook",
|
|
1722
|
+
triggers: [events.notionPageCreated(), events.webhook()],
|
|
1723
|
+
verify: () => ({ status: 200 }),
|
|
1724
|
+
handler: () => {},
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
expect(incoming.config.triggers).toEqual([
|
|
1728
|
+
{ type: "notion.page.created" },
|
|
1729
|
+
{ type: "webhooks.webhook", hasVerify: true },
|
|
1730
|
+
]);
|
|
1731
|
+
});
|
|
1732
|
+
|
|
1733
|
+
it("omits hasVerify from the trigger when no verify handler is set", () => {
|
|
1734
|
+
const incoming = workflow({
|
|
1735
|
+
name: "Incoming",
|
|
1736
|
+
description: "Processes an incoming webhook",
|
|
1737
|
+
triggers: [events.webhook()],
|
|
1738
|
+
handler: () => {},
|
|
1739
|
+
});
|
|
1740
|
+
|
|
1741
|
+
expect(incoming.config.triggers).toEqual([{ type: "webhooks.webhook" }]);
|
|
1742
|
+
});
|
|
1743
|
+
|
|
1744
|
+
it("rejects a verify handler on a workflow without a webhook trigger", () => {
|
|
1745
|
+
expect(() =>
|
|
1746
|
+
workflow({
|
|
1747
|
+
name: "Log Pages",
|
|
1748
|
+
description: "Logs created pages",
|
|
1749
|
+
triggers: [events.notionPageCreated()],
|
|
1750
|
+
verify: () => ({ status: 200 }),
|
|
1751
|
+
handler: () => {},
|
|
1752
|
+
}),
|
|
1753
|
+
).toThrow(/has no webhook trigger/);
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
it("dispatches a verify invocation to the verify handler, not the workflow handler", async () => {
|
|
1757
|
+
const handler = vi.fn();
|
|
1758
|
+
const verify: WebhookVerifyHandler = (request) => ({
|
|
1759
|
+
status: 200,
|
|
1760
|
+
body: (JSON.parse(request.rawBody) as { challenge: string }).challenge,
|
|
1761
|
+
contentType: "text/plain",
|
|
1762
|
+
});
|
|
1763
|
+
const incoming = workflow({
|
|
1764
|
+
name: "Incoming",
|
|
1765
|
+
description: "Processes an incoming webhook",
|
|
1766
|
+
triggers: [events.webhook()],
|
|
1767
|
+
verify,
|
|
1768
|
+
handler,
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
const response = await incoming.handler(
|
|
1772
|
+
{ request: verifyRequest },
|
|
1773
|
+
{ concreteOutput: true },
|
|
1774
|
+
);
|
|
1775
|
+
|
|
1776
|
+
expect(response).toEqual({ status: 200, body: "abc", contentType: "text/plain" });
|
|
1777
|
+
expect(handler).not.toHaveBeenCalled();
|
|
1778
|
+
});
|
|
1779
|
+
|
|
1780
|
+
it("returns an explicit delivery decision from the verify handler", async () => {
|
|
1781
|
+
const incoming = workflow({
|
|
1782
|
+
name: "Incoming",
|
|
1783
|
+
description: "Processes an incoming webhook",
|
|
1784
|
+
triggers: [events.webhook()],
|
|
1785
|
+
verify: () => ({ status: 200, deliver: false }),
|
|
1786
|
+
handler: () => {},
|
|
1787
|
+
});
|
|
1788
|
+
|
|
1789
|
+
const response = await incoming.handler(
|
|
1790
|
+
{ request: verifyRequest },
|
|
1791
|
+
{ concreteOutput: true },
|
|
1792
|
+
);
|
|
1793
|
+
|
|
1794
|
+
expect(response).toEqual({ status: 200, deliver: false });
|
|
1795
|
+
});
|
|
1796
|
+
|
|
1797
|
+
it("passes the capability context to the verify handler", async () => {
|
|
1798
|
+
let receivedContext: unknown;
|
|
1799
|
+
const incoming = workflow({
|
|
1800
|
+
name: "Incoming",
|
|
1801
|
+
description: "Processes an incoming webhook",
|
|
1802
|
+
triggers: [events.webhook()],
|
|
1803
|
+
verify: (_request, context) => {
|
|
1804
|
+
receivedContext = context;
|
|
1805
|
+
return { status: 200 };
|
|
1806
|
+
},
|
|
1807
|
+
handler: () => {},
|
|
1808
|
+
});
|
|
1809
|
+
|
|
1810
|
+
await incoming.handler({ request: verifyRequest }, { concreteOutput: true });
|
|
1811
|
+
|
|
1812
|
+
expect(receivedContext).toHaveProperty("notion");
|
|
1813
|
+
expect(receivedContext).not.toHaveProperty("step");
|
|
1814
|
+
});
|
|
1815
|
+
|
|
1816
|
+
it("verifies without workflow run metadata", async () => {
|
|
1817
|
+
delete process.env.NOTION_RUN_METADATA_PATH;
|
|
1818
|
+
const incoming = workflow({
|
|
1819
|
+
name: "Incoming",
|
|
1820
|
+
description: "Processes an incoming webhook",
|
|
1821
|
+
triggers: [events.webhook()],
|
|
1822
|
+
verify: () => ({ status: 200 }),
|
|
1823
|
+
handler: () => {},
|
|
1824
|
+
});
|
|
1825
|
+
|
|
1826
|
+
await expect(
|
|
1827
|
+
incoming.handler({ request: verifyRequest }, { concreteOutput: true }),
|
|
1828
|
+
).resolves.toEqual({ status: 200 });
|
|
1829
|
+
});
|
|
1830
|
+
|
|
1831
|
+
it("writes the verify response as a success envelope without concreteOutput", async () => {
|
|
1832
|
+
const incoming = workflow({
|
|
1833
|
+
name: "Incoming",
|
|
1834
|
+
description: "Processes an incoming webhook",
|
|
1835
|
+
triggers: [events.webhook()],
|
|
1836
|
+
verify: () => ({ status: 401 }),
|
|
1837
|
+
handler: () => {},
|
|
1838
|
+
});
|
|
1839
|
+
|
|
1840
|
+
await incoming.handler({ request: verifyRequest });
|
|
1841
|
+
|
|
1842
|
+
expect(stdoutSpy).toHaveBeenCalledWith(
|
|
1843
|
+
expect.stringContaining('"_tag":"success","value":{"status":401}'),
|
|
1844
|
+
);
|
|
1845
|
+
});
|
|
1846
|
+
|
|
1847
|
+
it("runs in the app bundle, so verify handlers may capture module scope", async () => {
|
|
1848
|
+
const signingSecret = "shh";
|
|
1849
|
+
const incoming = workflow({
|
|
1850
|
+
name: "Incoming",
|
|
1851
|
+
description: "Processes an incoming webhook",
|
|
1852
|
+
triggers: [events.webhook()],
|
|
1853
|
+
verify: (request) => ({
|
|
1854
|
+
status: request.headers["x-signature"] === signingSecret ? 200 : 401,
|
|
1855
|
+
}),
|
|
1856
|
+
handler: () => {},
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
const response = await incoming.handler(
|
|
1860
|
+
{ request: { ...verifyRequest, headers: { "x-signature": "shh" } } },
|
|
1861
|
+
{ concreteOutput: true },
|
|
1862
|
+
);
|
|
1863
|
+
|
|
1864
|
+
expect(response).toEqual({ status: 200 });
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
it("uses a file-backed verify request instead of the inline invocation", async () => {
|
|
1868
|
+
if (!metadataDirectory) {
|
|
1869
|
+
throw new Error("Metadata directory was not created");
|
|
1870
|
+
}
|
|
1871
|
+
const payloadPath = join(metadataDirectory, "verify-input.json");
|
|
1872
|
+
await writeFile(
|
|
1873
|
+
payloadPath,
|
|
1874
|
+
JSON.stringify({ request: { ...verifyRequest, rawBody: '{"challenge":"file"}' } }),
|
|
1875
|
+
);
|
|
1876
|
+
process.env.NOTION_INPUT_PAYLOAD_PATH = payloadPath;
|
|
1877
|
+
|
|
1878
|
+
const incoming = workflow({
|
|
1879
|
+
name: "Incoming",
|
|
1880
|
+
description: "Processes an incoming webhook",
|
|
1881
|
+
triggers: [events.webhook()],
|
|
1882
|
+
verify: (request) => ({
|
|
1883
|
+
status: 200,
|
|
1884
|
+
body: (JSON.parse(request.rawBody) as { challenge: string }).challenge,
|
|
1885
|
+
}),
|
|
1886
|
+
handler: () => {},
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
const response = await incoming.handler(
|
|
1890
|
+
{ request: verifyRequest },
|
|
1891
|
+
{ concreteOutput: true },
|
|
1892
|
+
);
|
|
1893
|
+
|
|
1894
|
+
expect(response).toEqual({ status: 200, body: "file" });
|
|
1895
|
+
});
|
|
1896
|
+
|
|
1897
|
+
it("still dispatches trigger events to the workflow handler when verify is configured", async () => {
|
|
1898
|
+
const handler = vi.fn();
|
|
1899
|
+
const webhookEvent = { type: "webhooks.webhook" } as WebhookEvent;
|
|
1900
|
+
const incoming = workflow({
|
|
1901
|
+
name: "Incoming",
|
|
1902
|
+
description: "Processes an incoming webhook",
|
|
1903
|
+
triggers: [events.webhook()],
|
|
1904
|
+
verify: () => ({ status: 200 }),
|
|
1905
|
+
handler,
|
|
1906
|
+
});
|
|
1907
|
+
|
|
1908
|
+
const result = await incoming.handler(webhookEvent, { concreteOutput: true });
|
|
1909
|
+
|
|
1910
|
+
expect(result).toEqual({ status: "success" });
|
|
1911
|
+
expect(handler).toHaveBeenCalledWith(webhookEvent, expect.anything());
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
it("rejects a verify invocation when no verify handler is declared", async () => {
|
|
1915
|
+
const handler = vi.fn();
|
|
1916
|
+
const incoming = workflow({
|
|
1917
|
+
name: "Incoming",
|
|
1918
|
+
description: "Processes an incoming webhook",
|
|
1919
|
+
triggers: [events.webhook()],
|
|
1920
|
+
handler,
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
await expect(
|
|
1924
|
+
incoming.handler({ request: verifyRequest }, { concreteOutput: true }),
|
|
1925
|
+
).rejects.toThrow(/does not declare a verify handler/);
|
|
1926
|
+
expect(handler).not.toHaveBeenCalled();
|
|
1927
|
+
});
|
|
1928
|
+
});
|