@kodelyth/googlechat 2026.5.42 → 2026.6.1

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