@powerhousedao/switchboard 6.2.3-dev.30 → 6.2.3-dev.31
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/.tsbuild/test/workflow/harness.d.ts +12 -1
- package/.tsbuild/test/workflow/harness.d.ts.map +1 -1
- package/.tsbuild/test/workflow/oauth.test.d.ts +2 -0
- package/.tsbuild/test/workflow/oauth.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/CHANGELOG.md +27 -0
- package/dist/index.mjs +1 -1
- package/dist/{server-Cf0DB8sW.mjs → server-CKKn0aXV.mjs} +429 -45
- package/dist/server-CKKn0aXV.mjs.map +1 -0
- package/dist/server.d.mts +2 -0
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +3 -3
- package/package.json +15 -15
- package/test/workflow/harness.ts +61 -9
- package/test/workflow/oauth.test.ts +214 -0
- package/test/workflow/resolvers.test.ts +194 -3
- package/test/workflow/webhook-dedupe.test.ts +3 -3
- package/test/workflow/webhook-signed.test.ts +2 -2
- package/test/workflow/webhook-trigger-test.test.ts +82 -0
- package/test/workflow/webhook-unsigned.test.ts +19 -6
- package/test/workflow-runtime.test.ts +14 -0
- package/dist/server-Cf0DB8sW.mjs.map +0 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@powerhousedao/switchboard",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "6.2.3-dev.
|
|
4
|
+
"version": "6.2.3-dev.31",
|
|
5
5
|
"main": "dist/index.mjs",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"@openfeature/core": "1.9.1",
|
|
42
42
|
"@openfeature/env-var-provider": "0.3.1",
|
|
43
43
|
"@openfeature/server-sdk": "1.19.0",
|
|
44
|
-
"@powerhousedao/config": "6.2.3-dev.
|
|
44
|
+
"@powerhousedao/config": "6.2.3-dev.31",
|
|
45
45
|
"@opentelemetry/api": "^1.9.0",
|
|
46
46
|
"@opentelemetry/exporter-metrics-otlp-http": "^0.218.0",
|
|
47
47
|
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
|
|
@@ -55,22 +55,22 @@
|
|
|
55
55
|
"@opentelemetry/sdk-node": "^0.218.0",
|
|
56
56
|
"@opentelemetry/sdk-trace-base": "^2.7.1",
|
|
57
57
|
"@opentelemetry/semantic-conventions": "^1.41.1",
|
|
58
|
-
"@powerhousedao/opentelemetry-instrumentation-reactor": "6.2.3-dev.
|
|
59
|
-
"@powerhousedao/reactor": "6.2.3-dev.
|
|
60
|
-
"@powerhousedao/shared": "6.2.3-dev.
|
|
61
|
-
"@powerhousedao/vetra": "6.2.3-dev.
|
|
62
|
-
"@powerhousedao/reactor-api": "6.2.3-dev.
|
|
63
|
-
"@powerhousedao/reactor-attachments": "6.2.3-dev.
|
|
64
|
-
"@powerhousedao/reactor-drive": "6.2.3-dev.
|
|
65
|
-
"@powerhousedao/reactor-group": "6.2.3-dev.
|
|
66
|
-
"@powerhousedao/reactor-workflow": "6.2.3-dev.
|
|
67
|
-
"@powerhousedao/workflow": "6.2.3-dev.
|
|
68
|
-
"@powerhousedao/pglite-fs": "6.2.3-dev.
|
|
58
|
+
"@powerhousedao/opentelemetry-instrumentation-reactor": "6.2.3-dev.31",
|
|
59
|
+
"@powerhousedao/reactor": "6.2.3-dev.31",
|
|
60
|
+
"@powerhousedao/shared": "6.2.3-dev.31",
|
|
61
|
+
"@powerhousedao/vetra": "6.2.3-dev.31",
|
|
62
|
+
"@powerhousedao/reactor-api": "6.2.3-dev.31",
|
|
63
|
+
"@powerhousedao/reactor-attachments": "6.2.3-dev.31",
|
|
64
|
+
"@powerhousedao/reactor-drive": "6.2.3-dev.31",
|
|
65
|
+
"@powerhousedao/reactor-group": "6.2.3-dev.31",
|
|
66
|
+
"@powerhousedao/reactor-workflow": "6.2.3-dev.31",
|
|
67
|
+
"@powerhousedao/workflow": "6.2.3-dev.31",
|
|
68
|
+
"@powerhousedao/pglite-fs": "6.2.3-dev.31",
|
|
69
69
|
"@pyroscope/nodejs": "^0.4.5",
|
|
70
|
-
"@renown/sdk": "6.2.3-dev.
|
|
70
|
+
"@renown/sdk": "6.2.3-dev.31",
|
|
71
71
|
"@sentry/node": "^10.53.1",
|
|
72
72
|
"@sentry/opentelemetry": "^10.53.1",
|
|
73
|
-
"document-model": "6.2.3-dev.
|
|
73
|
+
"document-model": "6.2.3-dev.31",
|
|
74
74
|
"dotenv": "^16.4.7",
|
|
75
75
|
"express": "^4.22.1",
|
|
76
76
|
"graphql": "^16",
|
package/test/workflow/harness.ts
CHANGED
|
@@ -18,6 +18,10 @@ import {
|
|
|
18
18
|
import type { OperationWithContext } from "document-model";
|
|
19
19
|
import type { Kysely } from "kysely";
|
|
20
20
|
import { vi } from "vitest";
|
|
21
|
+
import {
|
|
22
|
+
CORE_PIECE_NAME,
|
|
23
|
+
CORE_PIECE_VERSION,
|
|
24
|
+
} from "@powerhousedao/reactor-workflow";
|
|
21
25
|
|
|
22
26
|
export const WORKFLOW_TYPE = "powerhouse/workflow";
|
|
23
27
|
export const PACKAGE_NAME = "@powerhousedao/workflow";
|
|
@@ -33,6 +37,19 @@ export interface FiredRun {
|
|
|
33
37
|
kind: string;
|
|
34
38
|
}
|
|
35
39
|
|
|
40
|
+
export interface TriggerFields {
|
|
41
|
+
pieceName: string;
|
|
42
|
+
pieceVersion: string;
|
|
43
|
+
triggerName: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The core piece's webhook trigger, pinned to the installed core piece.
|
|
47
|
+
export const WEBHOOK_TRIGGER: TriggerFields = {
|
|
48
|
+
pieceName: CORE_PIECE_NAME,
|
|
49
|
+
pieceVersion: CORE_PIECE_VERSION,
|
|
50
|
+
triggerName: "webhook",
|
|
51
|
+
};
|
|
52
|
+
|
|
36
53
|
export interface WebhookHost {
|
|
37
54
|
/** Origin the endpoint is served from, e.g. `http://127.0.0.1:53124`. */
|
|
38
55
|
readonly url: string;
|
|
@@ -42,10 +59,14 @@ export interface WebhookHost {
|
|
|
42
59
|
/** Publishes an ENABLED workflow with `config`; returns its endpoint. */
|
|
43
60
|
arm(
|
|
44
61
|
config: Record<string, unknown>,
|
|
45
|
-
options?: {
|
|
62
|
+
options?: { trigger?: TriggerFields; workflowId?: string },
|
|
46
63
|
): Promise<{ token: string; url: string }>;
|
|
47
64
|
/** Publishes the same workflow as DISABLED, keeping its endpoint row. */
|
|
48
65
|
disarm(workflowId?: string): Promise<void>;
|
|
66
|
+
/** Deletes the workflow document, as the reactor reports it. */
|
|
67
|
+
remove(workflowId?: string): Promise<void>;
|
|
68
|
+
/** Whether the reactor's webhook store still holds this token. */
|
|
69
|
+
hasToken(token: string): Promise<boolean>;
|
|
49
70
|
/** The policy the service hands the reactor for one endpoint. */
|
|
50
71
|
policyFor(workflowId?: string): Promise<unknown>;
|
|
51
72
|
deliver(
|
|
@@ -91,7 +112,11 @@ export function workflowOperation(
|
|
|
91
112
|
// `publicUrl` is known only after `listen`: a family registered against the
|
|
92
113
|
// wrong origin advertises URLs no caller can reach.
|
|
93
114
|
export async function startWebhookHost(
|
|
94
|
-
options: {
|
|
115
|
+
options: {
|
|
116
|
+
fire?: (run: FiredRun) => unknown;
|
|
117
|
+
// Reads and writes of the workflow documents; none by default.
|
|
118
|
+
reactorClient?: unknown;
|
|
119
|
+
} = {},
|
|
95
120
|
): Promise<WebhookHost> {
|
|
96
121
|
// The public factory, not the adapter class: the class is internal, so
|
|
97
122
|
// constructing it would test a path no consumer can take.
|
|
@@ -101,7 +126,8 @@ export async function startWebhookHost(
|
|
|
101
126
|
const { port } = server.address() as { port: number };
|
|
102
127
|
const url = `http://127.0.0.1:${port}`;
|
|
103
128
|
|
|
104
|
-
const
|
|
129
|
+
const webhookStore = new MemoryWebhookStore();
|
|
130
|
+
const webhooks = new WebhookService({ store: webhookStore });
|
|
105
131
|
const routes = new HttpRouteService({
|
|
106
132
|
httpAdapter: adapter,
|
|
107
133
|
webhooks,
|
|
@@ -120,7 +146,7 @@ export async function startWebhookHost(
|
|
|
120
146
|
relationalDb: createRelationalDb(
|
|
121
147
|
db as unknown as Kysely<unknown>,
|
|
122
148
|
) as IRelationalDb,
|
|
123
|
-
reactorClient: {
|
|
149
|
+
reactorClient: options.reactorClient ?? {
|
|
124
150
|
get: () => Promise.reject(new Error("not used")),
|
|
125
151
|
find: () => Promise.resolve({ results: [] }),
|
|
126
152
|
},
|
|
@@ -154,7 +180,7 @@ export async function startWebhookHost(
|
|
|
154
180
|
const publish = async (
|
|
155
181
|
config: Record<string, unknown>,
|
|
156
182
|
status: string,
|
|
157
|
-
|
|
183
|
+
trigger: TriggerFields,
|
|
158
184
|
workflowId: string,
|
|
159
185
|
) => {
|
|
160
186
|
await service.onOperations([
|
|
@@ -163,7 +189,7 @@ export async function startWebhookHost(
|
|
|
163
189
|
name: "Integration",
|
|
164
190
|
status,
|
|
165
191
|
version: ordinal + 1,
|
|
166
|
-
trigger: { id: "t1",
|
|
192
|
+
trigger: { id: "t1", ...trigger, config },
|
|
167
193
|
steps: [],
|
|
168
194
|
edges: [],
|
|
169
195
|
variables: [],
|
|
@@ -179,10 +205,11 @@ export async function startWebhookHost(
|
|
|
179
205
|
service,
|
|
180
206
|
async arm(config, opts = {}) {
|
|
181
207
|
const workflowId = opts.workflowId ?? DEFAULT_WORKFLOW;
|
|
208
|
+
// The runtime requires a scheme; an unsigned suite names "none".
|
|
182
209
|
await publish(
|
|
183
|
-
config,
|
|
210
|
+
{ scheme: "none", ...config },
|
|
184
211
|
"ENABLED",
|
|
185
|
-
opts.
|
|
212
|
+
opts.trigger ?? WEBHOOK_TRIGGER,
|
|
186
213
|
workflowId,
|
|
187
214
|
);
|
|
188
215
|
const endpoint = await service.webhookEndpoint(workflowId, CALLER);
|
|
@@ -194,7 +221,32 @@ export async function startWebhookHost(
|
|
|
194
221
|
};
|
|
195
222
|
},
|
|
196
223
|
async disarm(workflowId = DEFAULT_WORKFLOW) {
|
|
197
|
-
await publish({}, "DISABLED",
|
|
224
|
+
await publish({}, "DISABLED", WEBHOOK_TRIGGER, workflowId);
|
|
225
|
+
},
|
|
226
|
+
async remove(workflowId = DEFAULT_WORKFLOW) {
|
|
227
|
+
ordinal += 1;
|
|
228
|
+
await service.onOperations([
|
|
229
|
+
{
|
|
230
|
+
operation: {
|
|
231
|
+
index: ordinal,
|
|
232
|
+
timestampUtcMs: `${ordinal}`,
|
|
233
|
+
action: {
|
|
234
|
+
type: "DELETE_DOCUMENT",
|
|
235
|
+
input: { documentId: workflowId },
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
context: {
|
|
239
|
+
documentId: workflowId,
|
|
240
|
+
documentType: WORKFLOW_TYPE,
|
|
241
|
+
scope: "document",
|
|
242
|
+
branch: "main",
|
|
243
|
+
ordinal,
|
|
244
|
+
},
|
|
245
|
+
} as unknown as OperationWithContext,
|
|
246
|
+
]);
|
|
247
|
+
},
|
|
248
|
+
async hasToken(token) {
|
|
249
|
+
return (await webhookStore.find(token)) !== undefined;
|
|
198
250
|
},
|
|
199
251
|
policyFor(workflowId = DEFAULT_WORKFLOW) {
|
|
200
252
|
return service.webhookPolicy(workflowId);
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// The OAuth2 callback over a real socket through the reactor's route service,
|
|
2
|
+
// and what startOAuth accepts as a redirect and a return URL.
|
|
3
|
+
import {
|
|
4
|
+
createHttpAdapter,
|
|
5
|
+
HttpRouteService,
|
|
6
|
+
type Context,
|
|
7
|
+
type IAuthorizationService,
|
|
8
|
+
} from "@powerhousedao/reactor-api";
|
|
9
|
+
import type { WorkflowRuntimeService } from "@powerhousedao/reactor-workflow";
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import {
|
|
12
|
+
callbackUrlOf,
|
|
13
|
+
registerOAuthCallback,
|
|
14
|
+
} from "../../src/workflow/oauth-callback.js";
|
|
15
|
+
import { getResolvers } from "../../src/workflow/resolvers.js";
|
|
16
|
+
|
|
17
|
+
const PACKAGE_NAME = "@powerhousedao/workflow";
|
|
18
|
+
|
|
19
|
+
type Resolver = (
|
|
20
|
+
parent: unknown,
|
|
21
|
+
args: unknown,
|
|
22
|
+
ctx: Context,
|
|
23
|
+
) => Promise<unknown>;
|
|
24
|
+
|
|
25
|
+
describe("the OAuth2 callback route", () => {
|
|
26
|
+
let url: string;
|
|
27
|
+
let callbackUrl: string;
|
|
28
|
+
const completeOAuth = vi.fn();
|
|
29
|
+
let close: () => Promise<void>;
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
const { adapter } = await createHttpAdapter("express");
|
|
33
|
+
adapter.setupMiddleware({});
|
|
34
|
+
const server = await adapter.listen(0, undefined, "127.0.0.1");
|
|
35
|
+
const { port } = server.address() as { port: number };
|
|
36
|
+
url = `http://127.0.0.1:${port}`;
|
|
37
|
+
const routes = new HttpRouteService({
|
|
38
|
+
httpAdapter: adapter,
|
|
39
|
+
publicUrl: url,
|
|
40
|
+
});
|
|
41
|
+
const scope = routes.scopeFor(PACKAGE_NAME);
|
|
42
|
+
registerOAuthCallback(scope, { completeOAuth } as never);
|
|
43
|
+
callbackUrl = callbackUrlOf(scope);
|
|
44
|
+
close = () => new Promise((done) => server.close(() => done()));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterAll(() => close());
|
|
48
|
+
|
|
49
|
+
it("lives under the workflow package's namespace", () => {
|
|
50
|
+
expect(callbackUrl).toBe(
|
|
51
|
+
`${url}/api/@powerhousedao/workflow/oauth/callback`,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("hands the provider's answer to the runtime and says it is done", async () => {
|
|
56
|
+
completeOAuth.mockResolvedValueOnce({
|
|
57
|
+
ok: true,
|
|
58
|
+
detail: null,
|
|
59
|
+
returnUrl: null,
|
|
60
|
+
});
|
|
61
|
+
const response = await fetch(`${callbackUrl}?state=s1&code=c1`);
|
|
62
|
+
expect(response.status).toBe(200);
|
|
63
|
+
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
64
|
+
expect(await response.text()).toContain("<h1>Connected</h1>");
|
|
65
|
+
expect(completeOAuth).toHaveBeenLastCalledWith({
|
|
66
|
+
state: "s1",
|
|
67
|
+
code: "c1",
|
|
68
|
+
error: undefined,
|
|
69
|
+
errorDescription: undefined,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("shows a failure without letting it write markup", async () => {
|
|
74
|
+
completeOAuth.mockResolvedValueOnce({
|
|
75
|
+
ok: false,
|
|
76
|
+
detail: "<script>alert(1)</script>",
|
|
77
|
+
returnUrl: null,
|
|
78
|
+
});
|
|
79
|
+
const response = await fetch(`${callbackUrl}?state=s2&error=access_denied`);
|
|
80
|
+
expect(response.status).toBe(400);
|
|
81
|
+
const html = await response.text();
|
|
82
|
+
expect(html).toContain("<script>alert(1)</script>");
|
|
83
|
+
expect(html).not.toContain("window.close");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("sends a full-page sign-in back where it started", async () => {
|
|
87
|
+
completeOAuth.mockResolvedValueOnce({
|
|
88
|
+
ok: true,
|
|
89
|
+
detail: null,
|
|
90
|
+
returnUrl: "https://connect.example/d/drive?tab=x",
|
|
91
|
+
});
|
|
92
|
+
const response = await fetch(`${callbackUrl}?state=s3&code=c3`, {
|
|
93
|
+
redirect: "manual",
|
|
94
|
+
});
|
|
95
|
+
expect(response.status).toBe(303);
|
|
96
|
+
expect(response.headers.get("location")).toBe(
|
|
97
|
+
"https://connect.example/d/drive?tab=x&ph_oauth=s3",
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("refuses a request with no state", async () => {
|
|
102
|
+
completeOAuth.mockClear();
|
|
103
|
+
const response = await fetch(`${callbackUrl}?code=c4`);
|
|
104
|
+
expect(response.status).toBe(400);
|
|
105
|
+
expect(completeOAuth).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("startOAuth", () => {
|
|
110
|
+
const ORIGIN = "https://connect.example";
|
|
111
|
+
const ctx = (origin?: string) =>
|
|
112
|
+
({
|
|
113
|
+
headers: origin ? { origin } : {},
|
|
114
|
+
db: {},
|
|
115
|
+
user: { address: "0xadmin" },
|
|
116
|
+
}) as unknown as Context;
|
|
117
|
+
|
|
118
|
+
function build(callbackUrl: string | undefined, isAdmin = true) {
|
|
119
|
+
const runtime = {
|
|
120
|
+
startOAuth: vi.fn(() => Promise.resolve({})),
|
|
121
|
+
};
|
|
122
|
+
const resolvers = getResolvers(
|
|
123
|
+
runtime as unknown as WorkflowRuntimeService,
|
|
124
|
+
{ isSupremeAdmin: () => isAdmin } as unknown as IAuthorizationService,
|
|
125
|
+
callbackUrl ? { callbackUrl } : undefined,
|
|
126
|
+
) as Record<string, Record<string, Resolver>>;
|
|
127
|
+
return {
|
|
128
|
+
runtime,
|
|
129
|
+
start: resolvers.WorkflowRuntimeMutations.startOAuth,
|
|
130
|
+
redirectUri: resolvers.WorkflowRuntimeQueries.oauthRedirectUri,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const PATH = "/api/@powerhousedao/workflow/oauth/callback";
|
|
135
|
+
|
|
136
|
+
it("is an administrator's, like minting a secret", () => {
|
|
137
|
+
const { start } = build(`https://sb.example${PATH}`, false);
|
|
138
|
+
expect(() => start({}, { connectionId: "c1" }, ctx())).toThrow(
|
|
139
|
+
"Admin access required",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("uses the host's own redirect when it knows its origin", async () => {
|
|
144
|
+
const { runtime, start } = build(`https://sb.example${PATH}`);
|
|
145
|
+
await start(
|
|
146
|
+
{},
|
|
147
|
+
{ connectionId: "c1", redirectUri: `https://evil.example${PATH}` },
|
|
148
|
+
ctx(),
|
|
149
|
+
);
|
|
150
|
+
expect(runtime.startOAuth).toHaveBeenCalledWith("c1", expect.anything(), {
|
|
151
|
+
redirectUri: `https://sb.example${PATH}`,
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("takes the caller's redirect only when it names this host's path", async () => {
|
|
156
|
+
const { runtime, start, redirectUri } = build(PATH);
|
|
157
|
+
expect(redirectUri({}, {}, ctx())).toBe(PATH);
|
|
158
|
+
expect(() => start({}, { connectionId: "c1" }, ctx())).toThrow(
|
|
159
|
+
"pass redirectUri",
|
|
160
|
+
);
|
|
161
|
+
expect(() =>
|
|
162
|
+
start(
|
|
163
|
+
{},
|
|
164
|
+
{ connectionId: "c1", redirectUri: "http://localhost:4001/elsewhere" },
|
|
165
|
+
ctx(),
|
|
166
|
+
),
|
|
167
|
+
).toThrow(`redirectUri must be this host's ${PATH}`);
|
|
168
|
+
await start(
|
|
169
|
+
{},
|
|
170
|
+
{ connectionId: "c1", redirectUri: `http://localhost:4001${PATH}` },
|
|
171
|
+
ctx(),
|
|
172
|
+
);
|
|
173
|
+
expect(runtime.startOAuth).toHaveBeenLastCalledWith(
|
|
174
|
+
"c1",
|
|
175
|
+
expect.anything(),
|
|
176
|
+
{ redirectUri: `http://localhost:4001${PATH}` },
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("returns a full-page sign-in only to the page that asked", async () => {
|
|
181
|
+
const { runtime, start } = build(`https://sb.example${PATH}`);
|
|
182
|
+
expect(() =>
|
|
183
|
+
start(
|
|
184
|
+
{},
|
|
185
|
+
{ connectionId: "c1", returnUrl: "https://evil.example/" },
|
|
186
|
+
ctx(ORIGIN),
|
|
187
|
+
),
|
|
188
|
+
).toThrow("returnUrl must be on the requesting page's origin");
|
|
189
|
+
expect(() =>
|
|
190
|
+
start({}, { connectionId: "c1", returnUrl: `${ORIGIN}/d` }, ctx()),
|
|
191
|
+
).toThrow("returnUrl must be on the requesting page's origin");
|
|
192
|
+
await start(
|
|
193
|
+
{},
|
|
194
|
+
{ connectionId: "c1", returnUrl: `${ORIGIN}/d?x=1` },
|
|
195
|
+
ctx(ORIGIN),
|
|
196
|
+
);
|
|
197
|
+
expect(runtime.startOAuth).toHaveBeenLastCalledWith(
|
|
198
|
+
"c1",
|
|
199
|
+
expect.anything(),
|
|
200
|
+
{
|
|
201
|
+
redirectUri: `https://sb.example${PATH}`,
|
|
202
|
+
returnUrl: `${ORIGIN}/d?x=1`,
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("has no redirect to offer on a host without HTTP routes", () => {
|
|
208
|
+
const { redirectUri, start } = build(undefined);
|
|
209
|
+
expect(redirectUri({}, {}, ctx())).toBeNull();
|
|
210
|
+
expect(() => start({}, { connectionId: "c1" }, ctx())).toThrow(
|
|
211
|
+
"This host serves no OAuth2 callback",
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -4,7 +4,12 @@ import type {
|
|
|
4
4
|
Context,
|
|
5
5
|
IAuthorizationService,
|
|
6
6
|
} from "@powerhousedao/reactor-api";
|
|
7
|
-
import
|
|
7
|
+
import {
|
|
8
|
+
WORKFLOW_SYNCING_MESSAGE,
|
|
9
|
+
WorkflowSyncingError,
|
|
10
|
+
type WorkflowRuntimeService,
|
|
11
|
+
} from "@powerhousedao/reactor-workflow";
|
|
12
|
+
import { parse, type FieldNode, type OperationDefinitionNode } from "graphql";
|
|
8
13
|
import { describe, expect, it, vi } from "vitest";
|
|
9
14
|
import { getResolvers } from "../../src/workflow/resolvers.js";
|
|
10
15
|
|
|
@@ -18,16 +23,33 @@ type Resolver = (
|
|
|
18
23
|
parent: unknown,
|
|
19
24
|
args: unknown,
|
|
20
25
|
ctx: Context,
|
|
26
|
+
info?: unknown,
|
|
21
27
|
) => Promise<unknown>;
|
|
22
28
|
|
|
29
|
+
// The resolve info for the first runtime field of a real query document.
|
|
30
|
+
function runsInfo(query: string) {
|
|
31
|
+
const operation = parse(query).definitions[0] as OperationDefinitionNode;
|
|
32
|
+
const root = operation.selectionSet.selections[0] as FieldNode;
|
|
33
|
+
return { fieldNodes: [root.selectionSet!.selections[0] as FieldNode] };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const emptyPage = (): Promise<unknown> =>
|
|
37
|
+
Promise.resolve({ records: [], hasNextPage: false, cursor: null });
|
|
38
|
+
|
|
23
39
|
function fakeRuntime() {
|
|
24
40
|
return {
|
|
25
41
|
webhookEndpoint: vi.fn(() => Promise.resolve(null)),
|
|
26
42
|
triggerStates: vi.fn(() => Promise.resolve([])),
|
|
27
43
|
runs: vi.fn(() => Promise.resolve([])),
|
|
44
|
+
runsPage: vi.fn(emptyPage),
|
|
28
45
|
run: vi.fn(() => Promise.resolve(null)),
|
|
29
46
|
fire: vi.fn(() => Promise.resolve({})),
|
|
30
47
|
rerun: vi.fn(() => Promise.resolve({})),
|
|
48
|
+
testStep: vi.fn(() => Promise.resolve({})),
|
|
49
|
+
stepOutputTree: vi.fn(() => Promise.resolve({})),
|
|
50
|
+
blockResolutions: vi.fn(() => Promise.resolve([])),
|
|
51
|
+
testTrigger: vi.fn(() => Promise.resolve(null)),
|
|
52
|
+
cancelTriggerTestFor: vi.fn(() => Promise.resolve(true)),
|
|
31
53
|
secrets: vi.fn(() =>
|
|
32
54
|
Promise.resolve({
|
|
33
55
|
create: vi.fn(() => Promise.resolve({ ref: "secret://v1:00" })),
|
|
@@ -64,10 +86,22 @@ describe("the workflow resolvers and the caller", () => {
|
|
|
64
86
|
await queries.run({}, { id: "run-1" }, CTX);
|
|
65
87
|
await mutations.fire({}, { workflowId: "wf-1", payload: { a: 1 } }, CTX);
|
|
66
88
|
await mutations.rerun({}, { runId: "run-1" }, CTX);
|
|
89
|
+
await mutations.testStep({}, { workflowId: "wf-1", stepId: "s1" }, CTX);
|
|
90
|
+
await queries.stepOutputTree({}, { workflowId: "wf-1", stepId: "s1" }, CTX);
|
|
91
|
+
await queries.blockResolutions({}, { workflowId: "wf-1" }, CTX);
|
|
92
|
+
await mutations.testTrigger(
|
|
93
|
+
{},
|
|
94
|
+
{ workflowId: "wf-1", payload: { a: 1 }, timeoutSeconds: 30 },
|
|
95
|
+
CTX,
|
|
96
|
+
);
|
|
97
|
+
await mutations.cancelTriggerTest({}, { workflowId: "wf-1" }, CTX);
|
|
67
98
|
|
|
68
|
-
expect(runtime.webhookEndpoint).toHaveBeenCalledWith("wf-1", CTX);
|
|
99
|
+
expect(runtime.webhookEndpoint).toHaveBeenCalledWith("wf-1", CTX, {});
|
|
69
100
|
expect(runtime.triggerStates).toHaveBeenCalledWith(CTX);
|
|
70
|
-
expect(runtime.runs).toHaveBeenCalledWith(
|
|
101
|
+
expect(runtime.runs).toHaveBeenCalledWith(
|
|
102
|
+
{ driveId: "drive-1", withStepData: true },
|
|
103
|
+
CTX,
|
|
104
|
+
);
|
|
71
105
|
expect(runtime.run).toHaveBeenCalledWith("run-1", CTX);
|
|
72
106
|
expect(runtime.fire).toHaveBeenCalledWith(
|
|
73
107
|
"wf-1",
|
|
@@ -77,6 +111,36 @@ describe("the workflow resolvers and the caller", () => {
|
|
|
77
111
|
CTX,
|
|
78
112
|
);
|
|
79
113
|
expect(runtime.rerun).toHaveBeenCalledWith("run-1", CTX);
|
|
114
|
+
expect(runtime.testStep).toHaveBeenCalledWith("wf-1", "s1", CTX, {});
|
|
115
|
+
expect(runtime.stepOutputTree).toHaveBeenCalledWith("wf-1", "s1", CTX);
|
|
116
|
+
expect(runtime.blockResolutions).toHaveBeenCalledWith("wf-1", CTX);
|
|
117
|
+
expect(runtime.testTrigger).toHaveBeenCalledWith("wf-1", CTX, {
|
|
118
|
+
payload: { a: 1 },
|
|
119
|
+
timeoutMs: 30_000,
|
|
120
|
+
});
|
|
121
|
+
expect(runtime.cancelTriggerTestFor).toHaveBeenCalledWith("wf-1", CTX);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("passes the caller's drive, and tags a still-syncing workflow", async () => {
|
|
125
|
+
const { runtime, queries, mutations } = build(true);
|
|
126
|
+
const drive = { driveId: "drive-1" };
|
|
127
|
+
|
|
128
|
+
await queries.webhookEndpoint({}, { workflowId: "wf-1", ...drive }, CTX);
|
|
129
|
+
await mutations.testTrigger({}, { workflowId: "wf-1", ...drive }, CTX);
|
|
130
|
+
runtime.testStep.mockRejectedValueOnce(new WorkflowSyncingError());
|
|
131
|
+
const syncing = mutations.testStep(
|
|
132
|
+
{},
|
|
133
|
+
{ workflowId: "wf-1", stepId: "s1", ...drive },
|
|
134
|
+
CTX,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
expect(runtime.webhookEndpoint).toHaveBeenCalledWith("wf-1", CTX, drive);
|
|
138
|
+
expect(runtime.testTrigger).toHaveBeenCalledWith("wf-1", CTX, drive);
|
|
139
|
+
await expect(syncing).rejects.toMatchObject({
|
|
140
|
+
message: WORKFLOW_SYNCING_MESSAGE,
|
|
141
|
+
extensions: { code: "WORKFLOW_SYNCING", retryable: true },
|
|
142
|
+
});
|
|
143
|
+
expect(runtime.testStep).toHaveBeenCalledWith("wf-1", "s1", CTX, drive);
|
|
80
144
|
});
|
|
81
145
|
|
|
82
146
|
it("refuses secret writes to a caller who does not administer the reactor", async () => {
|
|
@@ -102,4 +166,131 @@ describe("the workflow resolvers and the caller", () => {
|
|
|
102
166
|
|
|
103
167
|
expect(runtime.secrets).toHaveBeenCalledTimes(1);
|
|
104
168
|
});
|
|
169
|
+
|
|
170
|
+
it("serves the piece version a run's steps ran", async () => {
|
|
171
|
+
const { runtime, queries } = build(true);
|
|
172
|
+
runtime.run.mockResolvedValueOnce({
|
|
173
|
+
row: { id: "run-1", warnings: 1, trigger_payload: null },
|
|
174
|
+
steps: [
|
|
175
|
+
{
|
|
176
|
+
step_id: "s1",
|
|
177
|
+
input: null,
|
|
178
|
+
output: null,
|
|
179
|
+
piece_version: "2.1.0",
|
|
180
|
+
piece_source: "registry",
|
|
181
|
+
version_match: "fallback",
|
|
182
|
+
version_note:
|
|
183
|
+
"Pinned 3.0.0 is not available; runs 2.1.0 from registry",
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
} as never);
|
|
187
|
+
|
|
188
|
+
const run = (await queries.run({}, { id: "run-1" }, CTX)) as {
|
|
189
|
+
warnings: number;
|
|
190
|
+
steps: Record<string, unknown>[];
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
expect(run.warnings).toBe(1);
|
|
194
|
+
expect(run.steps[0]).toMatchObject({
|
|
195
|
+
pieceVersion: "2.1.0",
|
|
196
|
+
pieceSource: "registry",
|
|
197
|
+
versionMatch: "fallback",
|
|
198
|
+
versionNote: "Pinned 3.0.0 is not available; runs 2.1.0 from registry",
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("the runs listing and step data", () => {
|
|
204
|
+
it("reads step blobs only when the query selects them", async () => {
|
|
205
|
+
const { runtime, queries } = build(false);
|
|
206
|
+
const withData = (query: string) =>
|
|
207
|
+
queries.runs({}, {}, CTX, runsInfo(query)).then(
|
|
208
|
+
() =>
|
|
209
|
+
(runtime.runs.mock.calls.at(-1) as unknown[])[0] as {
|
|
210
|
+
withStepData: boolean;
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
expect(
|
|
215
|
+
(await withData("{ workflowRuntime { runs { id status } } }"))
|
|
216
|
+
.withStepData,
|
|
217
|
+
).toBe(false);
|
|
218
|
+
expect(
|
|
219
|
+
(
|
|
220
|
+
await withData(
|
|
221
|
+
"{ workflowRuntime { runs { id steps { stepId status } } } }",
|
|
222
|
+
)
|
|
223
|
+
).withStepData,
|
|
224
|
+
).toBe(false);
|
|
225
|
+
expect(
|
|
226
|
+
(
|
|
227
|
+
await withData(
|
|
228
|
+
"{ workflowRuntime { runs { id steps { stepId output } } } }",
|
|
229
|
+
)
|
|
230
|
+
).withStepData,
|
|
231
|
+
).toBe(true);
|
|
232
|
+
// A fragment can select anything, so it reads as asking for the blobs.
|
|
233
|
+
expect(
|
|
234
|
+
(
|
|
235
|
+
await withData(
|
|
236
|
+
"{ workflowRuntime { runs { id steps { ...S } } } } fragment S on WorkflowStepRunRecord { input }",
|
|
237
|
+
)
|
|
238
|
+
).withStepData,
|
|
239
|
+
).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("the runs page", () => {
|
|
244
|
+
it("passes paging through and serves the page", async () => {
|
|
245
|
+
const { runtime, queries } = build(false);
|
|
246
|
+
runtime.runsPage.mockResolvedValueOnce({
|
|
247
|
+
records: [],
|
|
248
|
+
hasNextPage: true,
|
|
249
|
+
cursor: "next",
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const page = await queries.runsPage(
|
|
253
|
+
{},
|
|
254
|
+
{
|
|
255
|
+
driveId: "drive-1",
|
|
256
|
+
excludeTriggerKinds: ["test"],
|
|
257
|
+
paging: { limit: 10, cursor: "prev" },
|
|
258
|
+
},
|
|
259
|
+
CTX,
|
|
260
|
+
runsInfo(
|
|
261
|
+
"{ workflowRuntime { runsPage { items { id steps { status } } cursor } } }",
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
expect(runtime.runsPage).toHaveBeenCalledWith(
|
|
266
|
+
{
|
|
267
|
+
driveId: "drive-1",
|
|
268
|
+
excludeTriggerKinds: ["test"],
|
|
269
|
+
limit: 10,
|
|
270
|
+
cursor: "prev",
|
|
271
|
+
withStepData: false,
|
|
272
|
+
},
|
|
273
|
+
CTX,
|
|
274
|
+
);
|
|
275
|
+
expect(page).toEqual({
|
|
276
|
+
items: [],
|
|
277
|
+
hasNextPage: true,
|
|
278
|
+
hasPreviousPage: true,
|
|
279
|
+
cursor: "next",
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("reports a cursor it did not issue as bad input", async () => {
|
|
284
|
+
const { runtime, queries } = build(false);
|
|
285
|
+
const invalid = new Error("Invalid runs cursor");
|
|
286
|
+
invalid.name = "InvalidRunCursorError";
|
|
287
|
+
runtime.runsPage.mockRejectedValueOnce(invalid);
|
|
288
|
+
|
|
289
|
+
const error = (await queries
|
|
290
|
+
.runsPage({}, { paging: { cursor: "junk" } }, CTX)
|
|
291
|
+
.catch((thrown: unknown) => thrown)) as {
|
|
292
|
+
extensions?: { code?: string };
|
|
293
|
+
};
|
|
294
|
+
expect(error.extensions?.code).toBe("BAD_USER_INPUT");
|
|
295
|
+
});
|
|
105
296
|
});
|
|
@@ -24,7 +24,7 @@ interface PolicyShape {
|
|
|
24
24
|
challengeField?: unknown;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
describe("core
|
|
27
|
+
describe("the core webhook trigger redelivery", () => {
|
|
28
28
|
it("dedupes on a bare field name found in the query string", async () => {
|
|
29
29
|
host = await startWebhookHost();
|
|
30
30
|
const { token } = await host.arm({
|
|
@@ -207,7 +207,7 @@ describe("core#webhook redelivery", () => {
|
|
|
207
207
|
});
|
|
208
208
|
});
|
|
209
209
|
|
|
210
|
-
describe("core
|
|
210
|
+
describe("the core webhook trigger endpoint verification", () => {
|
|
211
211
|
it("echoes a bare challenge field from the query string without running", async () => {
|
|
212
212
|
host = await startWebhookHost();
|
|
213
213
|
const { token } = await host.arm({
|
|
@@ -262,7 +262,7 @@ describe("core#webhook endpoint verification", () => {
|
|
|
262
262
|
});
|
|
263
263
|
});
|
|
264
264
|
|
|
265
|
-
describe("core
|
|
265
|
+
describe("the core webhook trigger field policy", () => {
|
|
266
266
|
it("hands the reactor a source-tagged field for a prefixed config", async () => {
|
|
267
267
|
host = await startWebhookHost();
|
|
268
268
|
await host.arm({
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The signature-verifying half of core
|
|
1
|
+
// The signature-verifying half of the core webhook trigger, over a real socket: config,
|
|
2
2
|
// resolved policy and the reactor's verifier have to agree on the wire format.
|
|
3
3
|
import { createHmac } from "node:crypto";
|
|
4
4
|
import { afterEach, describe, expect, it } from "vitest";
|
|
@@ -42,7 +42,7 @@ async function expectNoRun(current: WebhookHost): Promise<void> {
|
|
|
42
42
|
expect(current.fired).toHaveLength(0);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
describe("core
|
|
45
|
+
describe("the core webhook trigger signature verification", () => {
|
|
46
46
|
describe("the three layouts", () => {
|
|
47
47
|
it("accepts a bare digest under hmac and starts a run", async () => {
|
|
48
48
|
host = await startWebhookHost();
|