@powerhousedao/switchboard 6.2.3-dev.5 → 6.2.3-dev.51
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/attachments/byte-grants.test.d.ts +2 -0
- package/.tsbuild/test/attachments/byte-grants.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts +2 -0
- package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/reserve-metadata-parity.test.d.ts +2 -0
- package/.tsbuild/test/attachments/reserve-metadata-parity.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/url-signer.test.d.ts +2 -0
- package/.tsbuild/test/attachments/url-signer.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-create-signature-policy.test.d.ts +2 -0
- package/.tsbuild/test/boot-create-signature-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-mcp-enabled.test.d.ts +2 -0
- package/.tsbuild/test/boot-mcp-enabled.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-privacy.test.d.ts +2 -0
- package/.tsbuild/test/boot-privacy.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts +2 -0
- package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-without-renown.test.d.ts +2 -0
- package/.tsbuild/test/boot-without-renown.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-without-require-signatures.test.d.ts +2 -0
- package/.tsbuild/test/boot-without-require-signatures.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-worker-trust-policy.test.d.ts +2 -0
- package/.tsbuild/test/boot-worker-trust-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-workflow-singleton-conflict.test.d.ts +2 -0
- package/.tsbuild/test/boot-workflow-singleton-conflict.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-workflow-singleton-retry.test.d.ts +2 -0
- package/.tsbuild/test/boot-workflow-singleton-retry.test.d.ts.map +1 -0
- package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
- package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
- package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
- package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/durable-node-fs-pg16.test.d.ts +2 -0
- package/.tsbuild/test/durable-node-fs-pg16.test.d.ts.map +1 -0
- package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
- package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
- package/.tsbuild/test/mcp-flag.test.d.ts +2 -0
- package/.tsbuild/test/mcp-flag.test.d.ts.map +1 -0
- package/.tsbuild/test/observability-signals.test.d.ts +2 -0
- package/.tsbuild/test/observability-signals.test.d.ts.map +1 -0
- package/.tsbuild/test/pglite-preflight.test.d.ts +2 -0
- package/.tsbuild/test/pglite-preflight.test.d.ts.map +1 -0
- package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
- package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/snapshot-writer.d.ts +18 -0
- package/.tsbuild/test/snapshot-writer.d.ts.map +1 -0
- package/.tsbuild/test/unsupported-stored-documents.test.d.ts +2 -0
- package/.tsbuild/test/unsupported-stored-documents.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/harness.d.ts +56 -0
- package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
- 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/resolvers.test.d.ts +2 -0
- package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-signed.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/test/workflow/webhook-unsigned.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-reactor-access.test.d.ts +2 -0
- package/.tsbuild/test/workflow-reactor-access.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts +2 -0
- package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
- package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
- package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/.tsbuild/tsdown.config.d.ts.map +1 -1
- package/.tsbuild/vitest.config.d.ts.map +1 -1
- package/Auth.md +94 -0
- package/CHANGELOG.md +541 -0
- package/README.md +35 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +48 -49
- package/dist/index.mjs.map +1 -1
- package/dist/install-packages.d.mts +2 -1
- package/dist/install-packages.mjs +2 -2
- package/dist/install-packages.mjs.map +1 -1
- package/dist/migrate.d.mts +2 -1
- package/dist/migrate.mjs +4 -3
- package/dist/migrate.mjs.map +1 -1
- package/dist/server-J9fpd42v.mjs +3549 -0
- package/dist/server-J9fpd42v.mjs.map +1 -0
- package/dist/server.d.mts +155 -30
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +4 -5
- package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
- package/dist/utils-uaBlzyjS.mjs.map +1 -0
- package/dist/utils.d.mts +4 -5
- package/dist/utils.d.mts.map +1 -1
- package/dist/utils.mjs +3 -3
- package/package.json +22 -18
- package/test/attachment-reference-read-model.test.ts +6 -1
- package/test/attachments/auth.test.ts +85 -0
- package/test/attachments/byte-grants.test.ts +290 -0
- package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
- package/test/attachments/download-target.test.ts +201 -32
- package/test/attachments/index.test.ts +34 -3
- package/test/attachments/reserve-metadata-parity.test.ts +96 -0
- package/test/attachments/routes-integration.test.ts +60 -4
- package/test/attachments/routes.test.ts +166 -23
- package/test/attachments/service-config.test.ts +13 -3
- package/test/attachments/url-signer.test.ts +105 -0
- package/test/boot-create-signature-policy.test.ts +108 -0
- package/test/boot-mcp-enabled.test.ts +121 -0
- package/test/boot-privacy.test.ts +243 -0
- package/test/boot-unsupported-stored-documents.test.ts +213 -0
- package/test/boot-without-renown.test.ts +81 -0
- package/test/boot-without-require-signatures.test.ts +74 -0
- package/test/boot-worker-trust-policy.test.ts +90 -0
- package/test/boot-workflow-singleton-conflict.test.ts +107 -0
- package/test/boot-workflow-singleton-retry.test.ts +133 -0
- package/test/builder-defaults.test.ts +80 -0
- package/test/create-signature-policy.test.ts +69 -0
- package/test/durable-node-fs-pg16.test.ts +80 -0
- package/test/fatal-shutdown.test.ts +165 -0
- package/test/mcp-flag.test.ts +56 -0
- package/test/observability-signals.test.ts +131 -0
- package/test/pglite-dialect.test.ts +217 -2
- package/test/pglite-preflight.test.ts +313 -0
- package/test/pglite-version.test.ts +9 -1
- package/test/renown-trust-policy.test.ts +315 -0
- package/test/snapshot-writer.ts +94 -0
- package/test/unsupported-stored-documents.test.ts +72 -0
- package/test/worker-pool.test.ts +27 -1
- package/test/workflow/harness.ts +289 -0
- package/test/workflow/oauth.test.ts +214 -0
- package/test/workflow/resolvers.test.ts +357 -0
- package/test/workflow/webhook-dedupe.test.ts +295 -0
- package/test/workflow/webhook-signed.test.ts +509 -0
- package/test/workflow/webhook-trigger-test.test.ts +82 -0
- package/test/workflow/webhook-unsigned.test.ts +345 -0
- package/test/workflow-reactor-access.test.ts +384 -0
- package/test/workflow-reactor-enforcement.test.ts +808 -0
- package/test/workflow-read-gate.test.ts +213 -0
- package/test/workflow-runtime.test.ts +1067 -0
- package/tsconfig.json +9 -3
- package/tsdown.config.ts +3 -1
- package/dist/server-CiKkVE6g.mjs +0 -1555
- package/dist/server-CiKkVE6g.mjs.map +0 -1
- package/dist/utils-Baw7rThP.mjs.map +0 -1
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// What the workflow subgraph does with the caller behind a request: every
|
|
2
|
+
// workflow-scoped field hands it to the runtime, and secret writes are admins'.
|
|
3
|
+
import type {
|
|
4
|
+
Context,
|
|
5
|
+
IAuthorizationService,
|
|
6
|
+
} from "@powerhousedao/reactor-api";
|
|
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";
|
|
13
|
+
import { describe, expect, it, vi } from "vitest";
|
|
14
|
+
import { getResolvers } from "../../src/workflow/resolvers.js";
|
|
15
|
+
|
|
16
|
+
const CTX = {
|
|
17
|
+
headers: {},
|
|
18
|
+
db: {},
|
|
19
|
+
user: { address: "0xadmin" },
|
|
20
|
+
} as unknown as Context;
|
|
21
|
+
|
|
22
|
+
type Resolver = (
|
|
23
|
+
parent: unknown,
|
|
24
|
+
args: unknown,
|
|
25
|
+
ctx: Context,
|
|
26
|
+
info?: unknown,
|
|
27
|
+
) => Promise<unknown>;
|
|
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
|
+
|
|
39
|
+
function fakeRuntime() {
|
|
40
|
+
return {
|
|
41
|
+
webhookEndpoint: vi.fn(() => Promise.resolve(null)),
|
|
42
|
+
triggerStates: vi.fn(() => Promise.resolve([])),
|
|
43
|
+
runs: vi.fn(() => Promise.resolve([])),
|
|
44
|
+
runsPage: vi.fn(emptyPage),
|
|
45
|
+
run: vi.fn(() => Promise.resolve(null)),
|
|
46
|
+
fire: vi.fn(() => Promise.resolve({})),
|
|
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
|
+
searchPieces: vi.fn(() =>
|
|
52
|
+
Promise.resolve({ status: "ready", pieces: [], indexedPieces: 0 }),
|
|
53
|
+
),
|
|
54
|
+
reactorAccessDenial: vi.fn((): string | undefined => undefined),
|
|
55
|
+
testTrigger: vi.fn(() => Promise.resolve(null)),
|
|
56
|
+
cancelTriggerTestFor: vi.fn(() => Promise.resolve(true)),
|
|
57
|
+
secrets: vi.fn(() =>
|
|
58
|
+
Promise.resolve({
|
|
59
|
+
create: vi.fn(() => Promise.resolve({ ref: "secret://v1:00" })),
|
|
60
|
+
rotate: vi.fn(() => Promise.resolve({ ref: "secret://v1:00" })),
|
|
61
|
+
delete: vi.fn(() => Promise.resolve()),
|
|
62
|
+
}),
|
|
63
|
+
),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function build(isAdmin: boolean) {
|
|
68
|
+
const runtime = fakeRuntime();
|
|
69
|
+
const authorizationService = {
|
|
70
|
+
isSupremeAdmin: vi.fn(() => isAdmin),
|
|
71
|
+
} as unknown as IAuthorizationService;
|
|
72
|
+
const resolvers = getResolvers(
|
|
73
|
+
runtime as unknown as WorkflowRuntimeService,
|
|
74
|
+
authorizationService,
|
|
75
|
+
undefined,
|
|
76
|
+
undefined,
|
|
77
|
+
(documentId) =>
|
|
78
|
+
documentId === "wf-hidden"
|
|
79
|
+
? Promise.reject(new Error("You may not read this workflow"))
|
|
80
|
+
: Promise.resolve(),
|
|
81
|
+
) as Record<string, Record<string, Resolver>>;
|
|
82
|
+
return {
|
|
83
|
+
runtime,
|
|
84
|
+
queries: resolvers.WorkflowRuntimeQueries,
|
|
85
|
+
mutations: resolvers.WorkflowRuntimeMutations,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("the workflow resolvers and the caller", () => {
|
|
90
|
+
it("hands the caller to every workflow-scoped field", async () => {
|
|
91
|
+
const { runtime, queries, mutations } = build(true);
|
|
92
|
+
|
|
93
|
+
await queries.webhookEndpoint({}, { workflowId: "wf-1" }, CTX);
|
|
94
|
+
await queries.triggerStates({}, {}, CTX);
|
|
95
|
+
await queries.runs({}, { driveId: "drive-1" }, CTX);
|
|
96
|
+
await queries.run({}, { id: "run-1" }, CTX);
|
|
97
|
+
await mutations.fire({}, { workflowId: "wf-1", payload: { a: 1 } }, CTX);
|
|
98
|
+
await mutations.rerun({}, { runId: "run-1" }, CTX);
|
|
99
|
+
await mutations.testStep({}, { workflowId: "wf-1", stepId: "s1" }, CTX);
|
|
100
|
+
await queries.stepOutputTree({}, { workflowId: "wf-1", stepId: "s1" }, CTX);
|
|
101
|
+
await queries.blockResolutions({}, { workflowId: "wf-1" }, CTX);
|
|
102
|
+
await mutations.testTrigger(
|
|
103
|
+
{},
|
|
104
|
+
{ workflowId: "wf-1", payload: { a: 1 }, timeoutSeconds: 30 },
|
|
105
|
+
CTX,
|
|
106
|
+
);
|
|
107
|
+
await mutations.cancelTriggerTest({}, { workflowId: "wf-1" }, CTX);
|
|
108
|
+
|
|
109
|
+
expect(runtime.webhookEndpoint).toHaveBeenCalledWith("wf-1", CTX, {});
|
|
110
|
+
expect(runtime.triggerStates).toHaveBeenCalledWith(CTX);
|
|
111
|
+
expect(runtime.runs).toHaveBeenCalledWith(
|
|
112
|
+
{ driveId: "drive-1", withStepData: true },
|
|
113
|
+
CTX,
|
|
114
|
+
);
|
|
115
|
+
expect(runtime.run).toHaveBeenCalledWith("run-1", CTX);
|
|
116
|
+
expect(runtime.fire).toHaveBeenCalledWith(
|
|
117
|
+
"wf-1",
|
|
118
|
+
{ a: 1 },
|
|
119
|
+
"manual",
|
|
120
|
+
undefined,
|
|
121
|
+
CTX,
|
|
122
|
+
);
|
|
123
|
+
expect(runtime.rerun).toHaveBeenCalledWith("run-1", CTX);
|
|
124
|
+
expect(runtime.testStep).toHaveBeenCalledWith("wf-1", "s1", CTX, {});
|
|
125
|
+
expect(runtime.stepOutputTree).toHaveBeenCalledWith("wf-1", "s1", CTX);
|
|
126
|
+
expect(runtime.blockResolutions).toHaveBeenCalledWith("wf-1", CTX);
|
|
127
|
+
expect(runtime.testTrigger).toHaveBeenCalledWith("wf-1", CTX, {
|
|
128
|
+
payload: { a: 1 },
|
|
129
|
+
timeoutMs: 30_000,
|
|
130
|
+
});
|
|
131
|
+
expect(runtime.cancelTriggerTestFor).toHaveBeenCalledWith("wf-1", CTX);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("passes the caller's drive, and tags a still-syncing workflow", async () => {
|
|
135
|
+
const { runtime, queries, mutations } = build(true);
|
|
136
|
+
const drive = { driveId: "drive-1" };
|
|
137
|
+
|
|
138
|
+
await queries.webhookEndpoint({}, { workflowId: "wf-1", ...drive }, CTX);
|
|
139
|
+
await mutations.testTrigger({}, { workflowId: "wf-1", ...drive }, CTX);
|
|
140
|
+
runtime.testStep.mockRejectedValueOnce(new WorkflowSyncingError());
|
|
141
|
+
const syncing = mutations.testStep(
|
|
142
|
+
{},
|
|
143
|
+
{ workflowId: "wf-1", stepId: "s1", ...drive },
|
|
144
|
+
CTX,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
expect(runtime.webhookEndpoint).toHaveBeenCalledWith("wf-1", CTX, drive);
|
|
148
|
+
expect(runtime.testTrigger).toHaveBeenCalledWith("wf-1", CTX, drive);
|
|
149
|
+
await expect(syncing).rejects.toMatchObject({
|
|
150
|
+
message: WORKFLOW_SYNCING_MESSAGE,
|
|
151
|
+
extensions: { code: "WORKFLOW_SYNCING", retryable: true },
|
|
152
|
+
});
|
|
153
|
+
expect(runtime.testStep).toHaveBeenCalledWith("wf-1", "s1", CTX, drive);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("refuses secret writes to a caller who does not administer the reactor", async () => {
|
|
157
|
+
const { runtime, mutations } = build(false);
|
|
158
|
+
|
|
159
|
+
await expect(
|
|
160
|
+
mutations.createSecret({}, { value: "s3cret" }, CTX),
|
|
161
|
+
).rejects.toThrow("Admin access required");
|
|
162
|
+
await expect(
|
|
163
|
+
mutations.rotateSecret({}, { ref: "secret://v1:00", value: "s" }, CTX),
|
|
164
|
+
).rejects.toThrow("Admin access required");
|
|
165
|
+
await expect(
|
|
166
|
+
mutations.deleteSecret({}, { ref: "secret://v1:00" }, CTX),
|
|
167
|
+
).rejects.toThrow("Admin access required");
|
|
168
|
+
// Refused before the store is ever opened.
|
|
169
|
+
expect(runtime.secrets).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("lets an administrator write secrets", async () => {
|
|
173
|
+
const { runtime, mutations } = build(true);
|
|
174
|
+
|
|
175
|
+
await mutations.createSecret({}, { value: "s3cret", label: "slack" }, CTX);
|
|
176
|
+
|
|
177
|
+
expect(runtime.secrets).toHaveBeenCalledTimes(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("serves why a workflow got no reactor access, naming the publisher", async () => {
|
|
181
|
+
const { runtime, queries } = build(false);
|
|
182
|
+
runtime.reactorAccessDenial.mockReturnValueOnce(
|
|
183
|
+
'The publisher 0xabc cannot read reactor connection "conn-1"',
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
await expect(
|
|
187
|
+
queries.reactorAccessDenial({}, { workflowId: "wf-1" }, CTX),
|
|
188
|
+
).resolves.toBe(
|
|
189
|
+
'The publisher 0xabc cannot read reactor connection "conn-1"',
|
|
190
|
+
);
|
|
191
|
+
await expect(
|
|
192
|
+
queries.reactorAccessDenial({}, { workflowId: "wf-1" }, CTX),
|
|
193
|
+
).resolves.toBeNull();
|
|
194
|
+
await expect(
|
|
195
|
+
queries.reactorAccessDenial({}, { workflowId: "wf-hidden" }, CTX),
|
|
196
|
+
).rejects.toThrow("You may not read this workflow");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("serves the piece version a run's steps ran", async () => {
|
|
200
|
+
const { runtime, queries } = build(true);
|
|
201
|
+
runtime.run.mockResolvedValueOnce({
|
|
202
|
+
row: { id: "run-1", warnings: 1, trigger_payload: null },
|
|
203
|
+
steps: [
|
|
204
|
+
{
|
|
205
|
+
step_id: "s1",
|
|
206
|
+
input: null,
|
|
207
|
+
output: null,
|
|
208
|
+
piece_version: "2.1.0",
|
|
209
|
+
piece_source: "registry",
|
|
210
|
+
version_match: "fallback",
|
|
211
|
+
version_note:
|
|
212
|
+
"Pinned 3.0.0 is not available; runs 2.1.0 from registry",
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
} as never);
|
|
216
|
+
|
|
217
|
+
const run = (await queries.run({}, { id: "run-1" }, CTX)) as {
|
|
218
|
+
warnings: number;
|
|
219
|
+
steps: Record<string, unknown>[];
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
expect(run.warnings).toBe(1);
|
|
223
|
+
expect(run.steps[0]).toMatchObject({
|
|
224
|
+
pieceVersion: "2.1.0",
|
|
225
|
+
pieceSource: "registry",
|
|
226
|
+
versionMatch: "fallback",
|
|
227
|
+
versionNote: "Pinned 3.0.0 is not available; runs 2.1.0 from registry",
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("the runs listing and step data", () => {
|
|
233
|
+
it("reads step blobs only when the query selects them", async () => {
|
|
234
|
+
const { runtime, queries } = build(false);
|
|
235
|
+
const withData = (query: string) =>
|
|
236
|
+
queries.runs({}, {}, CTX, runsInfo(query)).then(
|
|
237
|
+
() =>
|
|
238
|
+
(runtime.runs.mock.calls.at(-1) as unknown[])[0] as {
|
|
239
|
+
withStepData: boolean;
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
expect(
|
|
244
|
+
(await withData("{ workflowRuntime { runs { id status } } }"))
|
|
245
|
+
.withStepData,
|
|
246
|
+
).toBe(false);
|
|
247
|
+
expect(
|
|
248
|
+
(
|
|
249
|
+
await withData(
|
|
250
|
+
"{ workflowRuntime { runs { id steps { stepId status } } } }",
|
|
251
|
+
)
|
|
252
|
+
).withStepData,
|
|
253
|
+
).toBe(false);
|
|
254
|
+
expect(
|
|
255
|
+
(
|
|
256
|
+
await withData(
|
|
257
|
+
"{ workflowRuntime { runs { id steps { stepId output } } } }",
|
|
258
|
+
)
|
|
259
|
+
).withStepData,
|
|
260
|
+
).toBe(true);
|
|
261
|
+
// A fragment can select anything, so it reads as asking for the blobs.
|
|
262
|
+
expect(
|
|
263
|
+
(
|
|
264
|
+
await withData(
|
|
265
|
+
"{ workflowRuntime { runs { id steps { ...S } } } } fragment S on WorkflowStepRunRecord { input }",
|
|
266
|
+
)
|
|
267
|
+
).withStepData,
|
|
268
|
+
).toBe(true);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("the runs page", () => {
|
|
273
|
+
it("passes paging through and serves the page", async () => {
|
|
274
|
+
const { runtime, queries } = build(false);
|
|
275
|
+
runtime.runsPage.mockResolvedValueOnce({
|
|
276
|
+
records: [],
|
|
277
|
+
hasNextPage: true,
|
|
278
|
+
cursor: "next",
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const page = await queries.runsPage(
|
|
282
|
+
{},
|
|
283
|
+
{
|
|
284
|
+
driveId: "drive-1",
|
|
285
|
+
excludeTriggerKinds: ["test"],
|
|
286
|
+
paging: { limit: 10, cursor: "prev" },
|
|
287
|
+
},
|
|
288
|
+
CTX,
|
|
289
|
+
runsInfo(
|
|
290
|
+
"{ workflowRuntime { runsPage { items { id steps { status } } cursor } } }",
|
|
291
|
+
),
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
expect(runtime.runsPage).toHaveBeenCalledWith(
|
|
295
|
+
{
|
|
296
|
+
driveId: "drive-1",
|
|
297
|
+
excludeTriggerKinds: ["test"],
|
|
298
|
+
limit: 10,
|
|
299
|
+
cursor: "prev",
|
|
300
|
+
withStepData: false,
|
|
301
|
+
},
|
|
302
|
+
CTX,
|
|
303
|
+
);
|
|
304
|
+
expect(page).toEqual({
|
|
305
|
+
items: [],
|
|
306
|
+
hasNextPage: true,
|
|
307
|
+
hasPreviousPage: true,
|
|
308
|
+
cursor: "next",
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("reports a cursor it did not issue as bad input", async () => {
|
|
313
|
+
const { runtime, queries } = build(false);
|
|
314
|
+
const invalid = new Error("Invalid runs cursor");
|
|
315
|
+
invalid.name = "InvalidRunCursorError";
|
|
316
|
+
runtime.runsPage.mockRejectedValueOnce(invalid);
|
|
317
|
+
|
|
318
|
+
const error = (await queries
|
|
319
|
+
.runsPage({}, { paging: { cursor: "junk" } }, CTX)
|
|
320
|
+
.catch((thrown: unknown) => thrown)) as {
|
|
321
|
+
extensions?: { code?: string };
|
|
322
|
+
};
|
|
323
|
+
expect(error.extensions?.code).toBe("BAD_USER_INPUT");
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe("piece search", () => {
|
|
328
|
+
it("passes a valid filter through", async () => {
|
|
329
|
+
const { runtime, queries } = build(false);
|
|
330
|
+
await queries.searchPieces(
|
|
331
|
+
{},
|
|
332
|
+
{ query: "send", kind: "trigger", sources: ["registry", "local"] },
|
|
333
|
+
CTX,
|
|
334
|
+
);
|
|
335
|
+
expect(runtime.searchPieces).toHaveBeenCalledWith("send", {
|
|
336
|
+
kind: "trigger",
|
|
337
|
+
sources: ["registry", "local"],
|
|
338
|
+
categories: undefined,
|
|
339
|
+
limit: undefined,
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("refuses a kind or source it does not know, rather than widen", () => {
|
|
344
|
+
const { runtime, queries } = build(false);
|
|
345
|
+
expect(() =>
|
|
346
|
+
queries.searchPieces({}, { query: "send", kind: "triggers" }, CTX),
|
|
347
|
+
).toThrow(/kind is "action" or "trigger"/);
|
|
348
|
+
expect(() =>
|
|
349
|
+
queries.searchPieces(
|
|
350
|
+
{},
|
|
351
|
+
{ query: "send", kind: "action", sources: ["Registry"] },
|
|
352
|
+
CTX,
|
|
353
|
+
),
|
|
354
|
+
).toThrow(/got Registry/);
|
|
355
|
+
expect(runtime.searchPieces).not.toHaveBeenCalled();
|
|
356
|
+
});
|
|
357
|
+
});
|