@actagent/googlechat 2026.6.2
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/actagent.plugin.json +17 -0
- package/api.ts +4 -0
- package/channel-config-api.ts +2 -0
- package/channel-plugin-api.ts +2 -0
- package/config-api.ts +3 -0
- package/contract-api.ts +6 -0
- package/directory-contract-api.ts +7 -0
- package/doctor-contract-api.ts +2 -0
- package/index.ts +21 -0
- package/npm-shrinkwrap.json +314 -0
- package/package.json +88 -0
- package/runtime-api.ts +61 -0
- package/secret-contract-api.ts +6 -0
- package/setup-entry.ts +14 -0
- package/setup-plugin-api.ts +3 -0
- package/src/accounts.ts +185 -0
- package/src/actions.test.ts +312 -0
- package/src/actions.ts +228 -0
- package/src/api.ts +346 -0
- package/src/approval-auth.test.ts +25 -0
- package/src/approval-auth.ts +38 -0
- package/src/approval-card-actions.test.ts +113 -0
- package/src/approval-card-actions.ts +307 -0
- package/src/approval-card-click.test.ts +279 -0
- package/src/approval-card-click.ts +94 -0
- package/src/approval-handler.runtime.test.ts +388 -0
- package/src/approval-handler.runtime.ts +413 -0
- package/src/approval-native.test.ts +399 -0
- package/src/approval-native.ts +246 -0
- package/src/auth.ts +219 -0
- package/src/channel-base.ts +123 -0
- package/src/channel-config.test.ts +174 -0
- package/src/channel.adapters.ts +363 -0
- package/src/channel.deps.runtime.ts +30 -0
- package/src/channel.runtime.ts +18 -0
- package/src/channel.setup.ts +7 -0
- package/src/channel.test.ts +845 -0
- package/src/channel.ts +214 -0
- package/src/config-schema.test.ts +32 -0
- package/src/config-schema.ts +4 -0
- package/src/doctor-contract.test.ts +76 -0
- package/src/doctor-contract.ts +181 -0
- package/src/doctor.ts +58 -0
- package/src/gateway.ts +84 -0
- package/src/google-auth.runtime.test.ts +571 -0
- package/src/google-auth.runtime.ts +570 -0
- package/src/group-policy.ts +18 -0
- package/src/monitor-access.test.ts +492 -0
- package/src/monitor-access.ts +466 -0
- package/src/monitor-durable.test.ts +40 -0
- package/src/monitor-durable.ts +24 -0
- package/src/monitor-reply-delivery.ts +162 -0
- package/src/monitor-routing.ts +66 -0
- package/src/monitor-types.ts +34 -0
- package/src/monitor-webhook.test.ts +670 -0
- package/src/monitor-webhook.ts +361 -0
- package/src/monitor.reply-delivery.test.ts +145 -0
- package/src/monitor.test.ts +389 -0
- package/src/monitor.ts +530 -0
- package/src/monitor.webhook-routing.test.ts +258 -0
- package/src/runtime.ts +10 -0
- package/src/secret-contract.test.ts +61 -0
- package/src/secret-contract.ts +162 -0
- package/src/setup-core.ts +41 -0
- package/src/setup-surface.ts +244 -0
- package/src/setup.test.ts +620 -0
- package/src/targets.test.ts +562 -0
- package/src/targets.ts +67 -0
- package/src/types.config.ts +4 -0
- package/src/types.ts +139 -0
- package/test-api.ts +3 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
// Googlechat tests cover monitor webhook plugin behavior.
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
import type { FixedWindowRateLimiter } from "actagent/plugin-sdk/webhook-ingress";
|
|
4
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { WebhookTarget } from "./monitor-types.js";
|
|
6
|
+
import type { GoogleChatEvent } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const readJsonWebhookBodyOrReject = vi.hoisted(() => vi.fn());
|
|
9
|
+
const resolveWebhookTargetWithAuthOrReject = vi.hoisted(() => vi.fn());
|
|
10
|
+
const withResolvedWebhookRequestPipeline = vi.hoisted(() => vi.fn());
|
|
11
|
+
const verifyGoogleChatRequest = vi.hoisted(() => vi.fn());
|
|
12
|
+
|
|
13
|
+
vi.mock("actagent/plugin-sdk/webhook-request-guards", () => ({
|
|
14
|
+
readJsonWebhookBodyOrReject,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("actagent/plugin-sdk/webhook-targets", () => ({
|
|
18
|
+
resolveWebhookTargetWithAuthOrReject,
|
|
19
|
+
withResolvedWebhookRequestPipeline,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("./auth.js", () => ({
|
|
23
|
+
verifyGoogleChatRequest,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
type ProcessEventFn = (event: GoogleChatEvent, target: WebhookTarget) => Promise<void>;
|
|
27
|
+
let createGoogleChatWebhookRequestHandler: typeof import("./monitor-webhook.js").createGoogleChatWebhookRequestHandler;
|
|
28
|
+
let warnAppPrincipalMisconfiguration: typeof import("./monitor-webhook.js").warnAppPrincipalMisconfiguration;
|
|
29
|
+
|
|
30
|
+
function createRequest(options?: {
|
|
31
|
+
authorization?: string;
|
|
32
|
+
headers?: Record<string, string>;
|
|
33
|
+
remoteAddress?: string;
|
|
34
|
+
url?: string;
|
|
35
|
+
}): IncomingMessage {
|
|
36
|
+
return {
|
|
37
|
+
method: "POST",
|
|
38
|
+
url: options?.url ?? "/googlechat",
|
|
39
|
+
headers: {
|
|
40
|
+
authorization: options?.authorization ?? "",
|
|
41
|
+
"content-type": "application/json",
|
|
42
|
+
...options?.headers,
|
|
43
|
+
},
|
|
44
|
+
socket: { remoteAddress: options?.remoteAddress ?? "203.0.113.10" },
|
|
45
|
+
} as IncomingMessage;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createResponse() {
|
|
49
|
+
const res = {
|
|
50
|
+
statusCode: 0,
|
|
51
|
+
headers: {} as Record<string, string>,
|
|
52
|
+
body: "",
|
|
53
|
+
setHeader: (name: string, value: string) => {
|
|
54
|
+
res.headers[name] = value;
|
|
55
|
+
},
|
|
56
|
+
end: (payload?: string) => {
|
|
57
|
+
res.body = payload ?? "";
|
|
58
|
+
return res;
|
|
59
|
+
},
|
|
60
|
+
} as ServerResponse & { headers: Record<string, string>; body: string };
|
|
61
|
+
return res;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function installSimplePipeline(targets: unknown[]) {
|
|
65
|
+
withResolvedWebhookRequestPipeline.mockImplementation(
|
|
66
|
+
async ({
|
|
67
|
+
handle,
|
|
68
|
+
req,
|
|
69
|
+
res,
|
|
70
|
+
}: {
|
|
71
|
+
handle: (input: {
|
|
72
|
+
targets: unknown[];
|
|
73
|
+
req: IncomingMessage;
|
|
74
|
+
res: ServerResponse;
|
|
75
|
+
}) => Promise<unknown>;
|
|
76
|
+
req: IncomingMessage;
|
|
77
|
+
res: ServerResponse;
|
|
78
|
+
}) =>
|
|
79
|
+
await handle({
|
|
80
|
+
targets,
|
|
81
|
+
req,
|
|
82
|
+
res,
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function runWebhookHandler(options?: {
|
|
88
|
+
processEvent?: ProcessEventFn;
|
|
89
|
+
authorization?: string;
|
|
90
|
+
webhookRateLimiter?: FixedWindowRateLimiter;
|
|
91
|
+
}) {
|
|
92
|
+
const processEvent: ProcessEventFn =
|
|
93
|
+
options?.processEvent ?? (vi.fn(async () => {}) as ProcessEventFn);
|
|
94
|
+
const handler = createGoogleChatWebhookRequestHandler({
|
|
95
|
+
webhookTargets: new Map(),
|
|
96
|
+
webhookRateLimiter: options?.webhookRateLimiter ?? {
|
|
97
|
+
isRateLimited: vi.fn(() => false),
|
|
98
|
+
size: vi.fn(() => 0),
|
|
99
|
+
clear: vi.fn(),
|
|
100
|
+
},
|
|
101
|
+
webhookInFlightLimiter: {} as never,
|
|
102
|
+
processEvent,
|
|
103
|
+
});
|
|
104
|
+
const req = createRequest({ authorization: options?.authorization });
|
|
105
|
+
const res = createResponse();
|
|
106
|
+
await expect(handler(req, res)).resolves.toBe(true);
|
|
107
|
+
return { processEvent, res };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe("googlechat monitor webhook", () => {
|
|
111
|
+
beforeAll(async () => {
|
|
112
|
+
({ createGoogleChatWebhookRequestHandler, warnAppPrincipalMisconfiguration } =
|
|
113
|
+
await import("./monitor-webhook.js"));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
vi.clearAllMocks();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
afterAll(() => {
|
|
121
|
+
vi.doUnmock("actagent/plugin-sdk/webhook-request-guards");
|
|
122
|
+
vi.doUnmock("actagent/plugin-sdk/webhook-targets");
|
|
123
|
+
vi.doUnmock("./auth.js");
|
|
124
|
+
vi.resetModules();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("passes a fixed-window request limiter to the shared webhook pipeline", async () => {
|
|
128
|
+
const rateLimiter: FixedWindowRateLimiter = {
|
|
129
|
+
isRateLimited: vi.fn(() => false),
|
|
130
|
+
size: vi.fn(() => 0),
|
|
131
|
+
clear: vi.fn(),
|
|
132
|
+
};
|
|
133
|
+
const webhookTargets = new Map<string, WebhookTarget[]>([
|
|
134
|
+
[
|
|
135
|
+
"/googlechat",
|
|
136
|
+
[
|
|
137
|
+
{
|
|
138
|
+
account: {
|
|
139
|
+
accountId: "default",
|
|
140
|
+
config: { appPrincipal: "chat-app" },
|
|
141
|
+
},
|
|
142
|
+
config: {
|
|
143
|
+
gateway: {
|
|
144
|
+
trustedProxies: ["10.0.0.0/24"],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
runtime: {},
|
|
148
|
+
core: {} as never,
|
|
149
|
+
path: "/googlechat",
|
|
150
|
+
mediaMaxMb: 20,
|
|
151
|
+
} as unknown as WebhookTarget,
|
|
152
|
+
],
|
|
153
|
+
],
|
|
154
|
+
]);
|
|
155
|
+
const webhookInFlightLimiter = {} as never;
|
|
156
|
+
const processEvent = vi.fn(async () => {});
|
|
157
|
+
const handler = createGoogleChatWebhookRequestHandler({
|
|
158
|
+
webhookTargets,
|
|
159
|
+
webhookRateLimiter: rateLimiter,
|
|
160
|
+
webhookInFlightLimiter,
|
|
161
|
+
processEvent,
|
|
162
|
+
});
|
|
163
|
+
const req = createRequest({
|
|
164
|
+
url: "/googlechat?ignored=1",
|
|
165
|
+
headers: {
|
|
166
|
+
"x-forwarded-for": "198.51.100.7, 10.0.0.1",
|
|
167
|
+
},
|
|
168
|
+
remoteAddress: "10.0.0.1",
|
|
169
|
+
});
|
|
170
|
+
const res = createResponse();
|
|
171
|
+
withResolvedWebhookRequestPipeline.mockResolvedValue(true);
|
|
172
|
+
|
|
173
|
+
await expect(handler(req, res)).resolves.toBe(true);
|
|
174
|
+
|
|
175
|
+
expect(withResolvedWebhookRequestPipeline).toHaveBeenCalledWith({
|
|
176
|
+
req,
|
|
177
|
+
res,
|
|
178
|
+
targetsByPath: webhookTargets,
|
|
179
|
+
allowMethods: ["POST"],
|
|
180
|
+
requireJsonContentType: true,
|
|
181
|
+
rateLimiter,
|
|
182
|
+
rateLimitKey: "/googlechat:198.51.100.7",
|
|
183
|
+
inFlightLimiter: webhookInFlightLimiter,
|
|
184
|
+
handle: expect.any(Function),
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("uses the unknown rate-limit bucket when a trusted proxy omits client headers", async () => {
|
|
189
|
+
const rateLimiter: FixedWindowRateLimiter = {
|
|
190
|
+
isRateLimited: vi.fn(() => false),
|
|
191
|
+
size: vi.fn(() => 0),
|
|
192
|
+
clear: vi.fn(),
|
|
193
|
+
};
|
|
194
|
+
const webhookTargets = new Map<string, WebhookTarget[]>([
|
|
195
|
+
[
|
|
196
|
+
"/googlechat",
|
|
197
|
+
[
|
|
198
|
+
{
|
|
199
|
+
account: {
|
|
200
|
+
accountId: "default",
|
|
201
|
+
config: { appPrincipal: "chat-app" },
|
|
202
|
+
},
|
|
203
|
+
config: {
|
|
204
|
+
gateway: {
|
|
205
|
+
trustedProxies: ["10.0.0.0/24"],
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
runtime: {},
|
|
209
|
+
core: {} as never,
|
|
210
|
+
path: "/googlechat",
|
|
211
|
+
mediaMaxMb: 20,
|
|
212
|
+
} as unknown as WebhookTarget,
|
|
213
|
+
],
|
|
214
|
+
],
|
|
215
|
+
]);
|
|
216
|
+
const webhookInFlightLimiter = {} as never;
|
|
217
|
+
const processEvent = vi.fn(async () => {});
|
|
218
|
+
const handler = createGoogleChatWebhookRequestHandler({
|
|
219
|
+
webhookTargets,
|
|
220
|
+
webhookRateLimiter: rateLimiter,
|
|
221
|
+
webhookInFlightLimiter,
|
|
222
|
+
processEvent,
|
|
223
|
+
});
|
|
224
|
+
const req = createRequest({ remoteAddress: "10.0.0.1" });
|
|
225
|
+
const res = createResponse();
|
|
226
|
+
withResolvedWebhookRequestPipeline.mockResolvedValue(true);
|
|
227
|
+
|
|
228
|
+
await expect(handler(req, res)).resolves.toBe(true);
|
|
229
|
+
|
|
230
|
+
expect(withResolvedWebhookRequestPipeline).toHaveBeenCalledWith({
|
|
231
|
+
req,
|
|
232
|
+
res,
|
|
233
|
+
targetsByPath: webhookTargets,
|
|
234
|
+
allowMethods: ["POST"],
|
|
235
|
+
requireJsonContentType: true,
|
|
236
|
+
rateLimiter,
|
|
237
|
+
rateLimitKey: "/googlechat:unknown",
|
|
238
|
+
inFlightLimiter: webhookInFlightLimiter,
|
|
239
|
+
handle: expect.any(Function),
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("accepts add-on payloads that carry systemIdToken in the body", async () => {
|
|
244
|
+
const target = {
|
|
245
|
+
account: {
|
|
246
|
+
accountId: "default",
|
|
247
|
+
config: { appPrincipal: "chat-app" },
|
|
248
|
+
},
|
|
249
|
+
runtime: { error: vi.fn() },
|
|
250
|
+
statusSink: vi.fn(),
|
|
251
|
+
audienceType: "app-url",
|
|
252
|
+
audience: "https://example.com/googlechat",
|
|
253
|
+
};
|
|
254
|
+
installSimplePipeline([target]);
|
|
255
|
+
readJsonWebhookBodyOrReject.mockResolvedValue({
|
|
256
|
+
ok: true,
|
|
257
|
+
value: {
|
|
258
|
+
commonEventObject: { hostApp: "CHAT" },
|
|
259
|
+
authorizationEventObject: { systemIdToken: "addon-token" },
|
|
260
|
+
chat: {
|
|
261
|
+
eventTime: "2026-03-22T00:00:00.000Z",
|
|
262
|
+
user: { name: "users/123" },
|
|
263
|
+
messagePayload: {
|
|
264
|
+
space: { name: "spaces/AAA" },
|
|
265
|
+
message: { name: "spaces/AAA/messages/1", text: "hello" },
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
resolveWebhookTargetWithAuthOrReject.mockImplementation(async ({ isMatch, targets }) => {
|
|
271
|
+
for (const targetLocal of targets) {
|
|
272
|
+
if (await isMatch(targetLocal)) {
|
|
273
|
+
return targetLocal;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
});
|
|
278
|
+
verifyGoogleChatRequest.mockResolvedValue({ ok: true });
|
|
279
|
+
const { processEvent, res } = await runWebhookHandler();
|
|
280
|
+
|
|
281
|
+
expect(verifyGoogleChatRequest).toHaveBeenCalledWith({
|
|
282
|
+
bearer: "addon-token",
|
|
283
|
+
audienceType: "app-url",
|
|
284
|
+
audience: "https://example.com/googlechat",
|
|
285
|
+
expectedAddOnPrincipal: "chat-app",
|
|
286
|
+
});
|
|
287
|
+
expect(processEvent).toHaveBeenCalledWith(
|
|
288
|
+
{
|
|
289
|
+
type: "MESSAGE",
|
|
290
|
+
space: { name: "spaces/AAA" },
|
|
291
|
+
message: { name: "spaces/AAA/messages/1", text: "hello" },
|
|
292
|
+
user: { name: "users/123" },
|
|
293
|
+
eventTime: "2026-03-22T00:00:00.000Z",
|
|
294
|
+
},
|
|
295
|
+
target,
|
|
296
|
+
);
|
|
297
|
+
expect(res.statusCode).toBe(200);
|
|
298
|
+
expect(res.headers["Content-Type"]).toBe("application/json");
|
|
299
|
+
expect(res.body).toBe("{}");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("normalizes add-on card-click payloads for approval actions", async () => {
|
|
303
|
+
const target = {
|
|
304
|
+
account: {
|
|
305
|
+
accountId: "default",
|
|
306
|
+
config: { appPrincipal: "chat-app" },
|
|
307
|
+
},
|
|
308
|
+
runtime: { error: vi.fn() },
|
|
309
|
+
statusSink: vi.fn(),
|
|
310
|
+
audienceType: "app-url",
|
|
311
|
+
audience: "https://example.com/googlechat",
|
|
312
|
+
};
|
|
313
|
+
installSimplePipeline([target]);
|
|
314
|
+
readJsonWebhookBodyOrReject.mockResolvedValue({
|
|
315
|
+
ok: true,
|
|
316
|
+
value: {
|
|
317
|
+
commonEventObject: {
|
|
318
|
+
hostApp: "CHAT",
|
|
319
|
+
parameters: {
|
|
320
|
+
actagent_action: "approval",
|
|
321
|
+
token: "token-1",
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
authorizationEventObject: { systemIdToken: "addon-token" },
|
|
325
|
+
chat: {
|
|
326
|
+
eventTime: "2026-03-22T00:00:00.000Z",
|
|
327
|
+
user: { name: "users/123" },
|
|
328
|
+
buttonClickedPayload: {
|
|
329
|
+
space: { name: "spaces/AAA" },
|
|
330
|
+
message: { name: "spaces/AAA/messages/1" },
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
resolveWebhookTargetWithAuthOrReject.mockImplementation(async ({ isMatch, targets }) => {
|
|
336
|
+
for (const targetLocal of targets) {
|
|
337
|
+
if (await isMatch(targetLocal)) {
|
|
338
|
+
return targetLocal;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
});
|
|
343
|
+
verifyGoogleChatRequest.mockResolvedValue({ ok: true });
|
|
344
|
+
const { processEvent, res } = await runWebhookHandler();
|
|
345
|
+
|
|
346
|
+
expect(verifyGoogleChatRequest).toHaveBeenCalledWith({
|
|
347
|
+
bearer: "addon-token",
|
|
348
|
+
audienceType: "app-url",
|
|
349
|
+
audience: "https://example.com/googlechat",
|
|
350
|
+
expectedAddOnPrincipal: "chat-app",
|
|
351
|
+
});
|
|
352
|
+
expect(processEvent).toHaveBeenCalledWith(
|
|
353
|
+
{
|
|
354
|
+
type: "CARD_CLICKED",
|
|
355
|
+
space: { name: "spaces/AAA" },
|
|
356
|
+
message: { name: "spaces/AAA/messages/1" },
|
|
357
|
+
user: { name: "users/123" },
|
|
358
|
+
eventTime: "2026-03-22T00:00:00.000Z",
|
|
359
|
+
action: {
|
|
360
|
+
parameters: [
|
|
361
|
+
{ key: "actagent_action", value: "approval" },
|
|
362
|
+
{ key: "token", value: "token-1" },
|
|
363
|
+
],
|
|
364
|
+
},
|
|
365
|
+
commonEventObject: {
|
|
366
|
+
parameters: {
|
|
367
|
+
actagent_action: "approval",
|
|
368
|
+
token: "token-1",
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
target,
|
|
373
|
+
);
|
|
374
|
+
expect(res.statusCode).toBe(200);
|
|
375
|
+
expect(res.headers["Content-Type"]).toBe("application/json");
|
|
376
|
+
expect(res.body).toBe("{}");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("logs WARN with reason when verification fails (missing token)", async () => {
|
|
380
|
+
const logFn = vi.fn();
|
|
381
|
+
installSimplePipeline([
|
|
382
|
+
{
|
|
383
|
+
account: {
|
|
384
|
+
accountId: "acct-1",
|
|
385
|
+
config: { appPrincipal: "chat-app" },
|
|
386
|
+
},
|
|
387
|
+
runtime: { log: logFn, error: vi.fn() },
|
|
388
|
+
audienceType: "app-url",
|
|
389
|
+
audience: "https://example.com/googlechat",
|
|
390
|
+
},
|
|
391
|
+
]);
|
|
392
|
+
readJsonWebhookBodyOrReject.mockResolvedValue({
|
|
393
|
+
ok: true,
|
|
394
|
+
value: {
|
|
395
|
+
commonEventObject: { hostApp: "CHAT" },
|
|
396
|
+
authorizationEventObject: { systemIdToken: "bad-token" },
|
|
397
|
+
chat: {
|
|
398
|
+
messagePayload: {
|
|
399
|
+
space: { name: "spaces/AAA" },
|
|
400
|
+
message: { name: "spaces/AAA/messages/1", text: "hi" },
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
resolveWebhookTargetWithAuthOrReject.mockImplementation(async ({ isMatch, targets, res }) => {
|
|
406
|
+
for (const target of targets) {
|
|
407
|
+
if (await isMatch(target)) {
|
|
408
|
+
return target;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
res.statusCode = 401;
|
|
412
|
+
res.end("unauthorized");
|
|
413
|
+
return null;
|
|
414
|
+
});
|
|
415
|
+
verifyGoogleChatRequest.mockResolvedValue({ ok: false, reason: "missing token" });
|
|
416
|
+
const { processEvent, res } = await runWebhookHandler();
|
|
417
|
+
|
|
418
|
+
expect(logFn).toHaveBeenCalledWith("[acct-1] Google Chat webhook auth rejected: missing token");
|
|
419
|
+
expect(processEvent).not.toHaveBeenCalled();
|
|
420
|
+
expect(res.statusCode).toBe(401);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("logs WARN with reason when verification fails (unexpected principal)", async () => {
|
|
424
|
+
const logFn = vi.fn();
|
|
425
|
+
installSimplePipeline([
|
|
426
|
+
{
|
|
427
|
+
account: {
|
|
428
|
+
accountId: "acct-2",
|
|
429
|
+
config: { appPrincipal: "chat-app" },
|
|
430
|
+
},
|
|
431
|
+
runtime: { log: logFn, error: vi.fn() },
|
|
432
|
+
audienceType: "app-url",
|
|
433
|
+
audience: "https://example.com/googlechat",
|
|
434
|
+
},
|
|
435
|
+
]);
|
|
436
|
+
readJsonWebhookBodyOrReject.mockResolvedValue({
|
|
437
|
+
ok: true,
|
|
438
|
+
value: {
|
|
439
|
+
commonEventObject: { hostApp: "CHAT" },
|
|
440
|
+
authorizationEventObject: { systemIdToken: "bad-token" },
|
|
441
|
+
chat: {
|
|
442
|
+
messagePayload: {
|
|
443
|
+
space: { name: "spaces/AAA" },
|
|
444
|
+
message: { name: "spaces/AAA/messages/1", text: "hi" },
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
resolveWebhookTargetWithAuthOrReject.mockImplementation(async ({ isMatch, targets, res }) => {
|
|
450
|
+
for (const target of targets) {
|
|
451
|
+
if (await isMatch(target)) {
|
|
452
|
+
return target;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
res.statusCode = 401;
|
|
456
|
+
res.end("unauthorized");
|
|
457
|
+
return null;
|
|
458
|
+
});
|
|
459
|
+
verifyGoogleChatRequest.mockResolvedValue({
|
|
460
|
+
ok: false,
|
|
461
|
+
reason: "unexpected add-on principal: 999999999999999999999",
|
|
462
|
+
});
|
|
463
|
+
const { processEvent, res } = await runWebhookHandler();
|
|
464
|
+
|
|
465
|
+
expect(logFn).toHaveBeenCalledWith(
|
|
466
|
+
"[acct-2] Google Chat webhook auth rejected: unexpected add-on principal: 999999999999999999999",
|
|
467
|
+
);
|
|
468
|
+
expect(processEvent).not.toHaveBeenCalled();
|
|
469
|
+
expect(res.statusCode).toBe(401);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("does not log WARN when verification succeeds", async () => {
|
|
473
|
+
const logFn = vi.fn();
|
|
474
|
+
installSimplePipeline([
|
|
475
|
+
{
|
|
476
|
+
account: {
|
|
477
|
+
accountId: "acct-ok",
|
|
478
|
+
config: { appPrincipal: "chat-app" },
|
|
479
|
+
},
|
|
480
|
+
runtime: { log: logFn, error: vi.fn() },
|
|
481
|
+
statusSink: vi.fn(),
|
|
482
|
+
audienceType: "app-url",
|
|
483
|
+
audience: "https://example.com/googlechat",
|
|
484
|
+
},
|
|
485
|
+
]);
|
|
486
|
+
readJsonWebhookBodyOrReject.mockResolvedValue({
|
|
487
|
+
ok: true,
|
|
488
|
+
value: {
|
|
489
|
+
commonEventObject: { hostApp: "CHAT" },
|
|
490
|
+
authorizationEventObject: { systemIdToken: "good-token" },
|
|
491
|
+
chat: {
|
|
492
|
+
eventTime: "2026-03-22T00:00:00.000Z",
|
|
493
|
+
user: { name: "users/123" },
|
|
494
|
+
messagePayload: {
|
|
495
|
+
space: { name: "spaces/AAA" },
|
|
496
|
+
message: { name: "spaces/AAA/messages/1", text: "hi" },
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
resolveWebhookTargetWithAuthOrReject.mockImplementation(async ({ isMatch, targets }) => {
|
|
502
|
+
for (const target of targets) {
|
|
503
|
+
if (await isMatch(target)) {
|
|
504
|
+
return target;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return null;
|
|
508
|
+
});
|
|
509
|
+
verifyGoogleChatRequest.mockResolvedValue({ ok: true });
|
|
510
|
+
const { res } = await runWebhookHandler();
|
|
511
|
+
|
|
512
|
+
expect(logFn).not.toHaveBeenCalled();
|
|
513
|
+
expect(res.statusCode).toBe(200);
|
|
514
|
+
expect(res.headers["Content-Type"]).toBe("application/json");
|
|
515
|
+
expect(res.body).toBe("{}");
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("does not log failed candidate targets when another target verifies", async () => {
|
|
519
|
+
const logA = vi.fn();
|
|
520
|
+
const logB = vi.fn();
|
|
521
|
+
const targetA = {
|
|
522
|
+
account: {
|
|
523
|
+
accountId: "acct-a",
|
|
524
|
+
config: { appPrincipal: "chat-app-a" },
|
|
525
|
+
},
|
|
526
|
+
runtime: { log: logA, error: vi.fn() },
|
|
527
|
+
audienceType: "app-url",
|
|
528
|
+
audience: "https://example.com/googlechat",
|
|
529
|
+
};
|
|
530
|
+
const targetB = {
|
|
531
|
+
account: {
|
|
532
|
+
accountId: "acct-b",
|
|
533
|
+
config: { appPrincipal: "chat-app-b" },
|
|
534
|
+
},
|
|
535
|
+
runtime: { log: logB, error: vi.fn() },
|
|
536
|
+
statusSink: vi.fn(),
|
|
537
|
+
audienceType: "app-url",
|
|
538
|
+
audience: "https://example.com/googlechat",
|
|
539
|
+
};
|
|
540
|
+
installSimplePipeline([targetA, targetB]);
|
|
541
|
+
readJsonWebhookBodyOrReject.mockResolvedValue({
|
|
542
|
+
ok: true,
|
|
543
|
+
value: {
|
|
544
|
+
commonEventObject: { hostApp: "CHAT" },
|
|
545
|
+
authorizationEventObject: { systemIdToken: "shared-path-token" },
|
|
546
|
+
chat: {
|
|
547
|
+
eventTime: "2026-03-22T00:00:00.000Z",
|
|
548
|
+
user: { name: "users/123" },
|
|
549
|
+
messagePayload: {
|
|
550
|
+
space: { name: "spaces/BBB" },
|
|
551
|
+
message: { name: "spaces/BBB/messages/1", text: "hi" },
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
resolveWebhookTargetWithAuthOrReject.mockImplementation(async ({ isMatch, targets }) => {
|
|
557
|
+
for (const target of targets) {
|
|
558
|
+
if (await isMatch(target)) {
|
|
559
|
+
return target;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return null;
|
|
563
|
+
});
|
|
564
|
+
verifyGoogleChatRequest
|
|
565
|
+
.mockResolvedValueOnce({ ok: false, reason: "unexpected add-on principal: 111" })
|
|
566
|
+
.mockResolvedValueOnce({ ok: true });
|
|
567
|
+
const { processEvent, res } = await runWebhookHandler();
|
|
568
|
+
|
|
569
|
+
expect(logA).not.toHaveBeenCalled();
|
|
570
|
+
expect(logB).not.toHaveBeenCalled();
|
|
571
|
+
expect(processEvent).toHaveBeenCalledWith(
|
|
572
|
+
{
|
|
573
|
+
type: "MESSAGE",
|
|
574
|
+
space: { name: "spaces/BBB" },
|
|
575
|
+
message: { name: "spaces/BBB/messages/1", text: "hi" },
|
|
576
|
+
user: { name: "users/123" },
|
|
577
|
+
eventTime: "2026-03-22T00:00:00.000Z",
|
|
578
|
+
},
|
|
579
|
+
targetB,
|
|
580
|
+
);
|
|
581
|
+
expect(res.statusCode).toBe(200);
|
|
582
|
+
expect(res.headers["Content-Type"]).toBe("application/json");
|
|
583
|
+
expect(res.body).toBe("{}");
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("rejects missing add-on bearer tokens before dispatch", async () => {
|
|
587
|
+
const logFn = vi.fn();
|
|
588
|
+
installSimplePipeline([
|
|
589
|
+
{
|
|
590
|
+
account: {
|
|
591
|
+
accountId: "default",
|
|
592
|
+
config: { appPrincipal: "chat-app" },
|
|
593
|
+
},
|
|
594
|
+
runtime: { log: logFn, error: vi.fn() },
|
|
595
|
+
},
|
|
596
|
+
]);
|
|
597
|
+
readJsonWebhookBodyOrReject.mockResolvedValue({
|
|
598
|
+
ok: true,
|
|
599
|
+
value: {
|
|
600
|
+
commonEventObject: { hostApp: "CHAT" },
|
|
601
|
+
chat: {
|
|
602
|
+
messagePayload: {
|
|
603
|
+
space: { name: "spaces/AAA" },
|
|
604
|
+
message: { name: "spaces/AAA/messages/1", text: "hello" },
|
|
605
|
+
},
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
const { processEvent, res } = await runWebhookHandler();
|
|
610
|
+
|
|
611
|
+
expect(processEvent).not.toHaveBeenCalled();
|
|
612
|
+
expect(logFn).toHaveBeenCalledWith(
|
|
613
|
+
"[default] Google Chat webhook auth rejected: missing token",
|
|
614
|
+
);
|
|
615
|
+
expect(res.statusCode).toBe(401);
|
|
616
|
+
expect(res.body).toBe("unauthorized");
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
describe("warnAppPrincipalMisconfiguration", () => {
|
|
621
|
+
it("warns when appPrincipal is missing for app-url audience", () => {
|
|
622
|
+
const log = vi.fn();
|
|
623
|
+
warnAppPrincipalMisconfiguration({
|
|
624
|
+
accountId: "acct-missing",
|
|
625
|
+
audienceType: "app-url",
|
|
626
|
+
appPrincipal: undefined,
|
|
627
|
+
log,
|
|
628
|
+
});
|
|
629
|
+
expect(log).toHaveBeenCalledOnce();
|
|
630
|
+
expect(log).toHaveBeenCalledWith(
|
|
631
|
+
'[acct-missing] appPrincipal is missing for audienceType "app-url"; add-on token verification will fail. Set appPrincipal to the numeric OAuth 2.0 client ID (uniqueId, 21 digits), not an email.',
|
|
632
|
+
);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it("warns when appPrincipal contains @ for app-url audience", () => {
|
|
636
|
+
const log = vi.fn();
|
|
637
|
+
warnAppPrincipalMisconfiguration({
|
|
638
|
+
accountId: "acct-email",
|
|
639
|
+
audienceType: "app-url",
|
|
640
|
+
appPrincipal: "bot@example.iam.gserviceaccount.com",
|
|
641
|
+
log,
|
|
642
|
+
});
|
|
643
|
+
expect(log).toHaveBeenCalledOnce();
|
|
644
|
+
expect(log).toHaveBeenCalledWith(
|
|
645
|
+
'[acct-email] appPrincipal "bot@example.iam.gserviceaccount.com" looks like an email address. Set appPrincipal to the numeric OAuth 2.0 client ID (uniqueId, 21 digits), not an email.',
|
|
646
|
+
);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it("does not warn for valid numeric appPrincipal with app-url audience", () => {
|
|
650
|
+
const log = vi.fn();
|
|
651
|
+
warnAppPrincipalMisconfiguration({
|
|
652
|
+
accountId: "acct-ok",
|
|
653
|
+
audienceType: "app-url",
|
|
654
|
+
appPrincipal: "123456789012345678901",
|
|
655
|
+
log,
|
|
656
|
+
});
|
|
657
|
+
expect(log).not.toHaveBeenCalled();
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it("does not warn for project-number audience even with missing appPrincipal", () => {
|
|
661
|
+
const log = vi.fn();
|
|
662
|
+
warnAppPrincipalMisconfiguration({
|
|
663
|
+
accountId: "acct-pn",
|
|
664
|
+
audienceType: "project-number",
|
|
665
|
+
appPrincipal: undefined,
|
|
666
|
+
log,
|
|
667
|
+
});
|
|
668
|
+
expect(log).not.toHaveBeenCalled();
|
|
669
|
+
});
|
|
670
|
+
});
|