@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.
- package/klaw.plugin.json +967 -2
- package/package.json +16 -4
- package/api.ts +0 -3
- package/channel-config-api.ts +0 -1
- package/channel-plugin-api.ts +0 -1
- package/config-api.ts +0 -2
- package/contract-api.ts +0 -5
- package/doctor-contract-api.ts +0 -1
- package/index.ts +0 -20
- package/runtime-api.ts +0 -55
- package/secret-contract-api.ts +0 -5
- package/setup-entry.ts +0 -13
- package/setup-plugin-api.ts +0 -3
- package/src/accounts.ts +0 -181
- package/src/actions.test.ts +0 -289
- package/src/actions.ts +0 -227
- package/src/api.ts +0 -316
- package/src/approval-auth.test.ts +0 -24
- package/src/approval-auth.ts +0 -32
- package/src/auth.ts +0 -218
- package/src/channel-config.test.ts +0 -39
- package/src/channel.adapters.ts +0 -340
- package/src/channel.deps.runtime.ts +0 -29
- package/src/channel.runtime.ts +0 -17
- package/src/channel.setup.ts +0 -98
- package/src/channel.test.ts +0 -784
- package/src/channel.ts +0 -277
- package/src/config-schema.test.ts +0 -31
- package/src/config-schema.ts +0 -3
- package/src/doctor-contract.test.ts +0 -75
- package/src/doctor-contract.ts +0 -182
- package/src/doctor.ts +0 -57
- package/src/gateway.ts +0 -63
- package/src/google-auth.runtime.test.ts +0 -543
- package/src/google-auth.runtime.ts +0 -568
- package/src/group-policy.ts +0 -17
- package/src/monitor-access.test.ts +0 -491
- package/src/monitor-access.ts +0 -465
- package/src/monitor-durable.test.ts +0 -39
- package/src/monitor-durable.ts +0 -23
- package/src/monitor-reply-delivery.ts +0 -156
- package/src/monitor-routing.ts +0 -65
- package/src/monitor-types.ts +0 -33
- package/src/monitor-webhook.test.ts +0 -587
- package/src/monitor-webhook.ts +0 -303
- package/src/monitor.reply-delivery.test.ts +0 -144
- package/src/monitor.test.ts +0 -159
- package/src/monitor.ts +0 -527
- package/src/monitor.webhook-routing.test.ts +0 -257
- package/src/runtime.ts +0 -9
- package/src/secret-contract.test.ts +0 -60
- package/src/secret-contract.ts +0 -161
- package/src/setup-core.ts +0 -40
- package/src/setup-surface.ts +0 -243
- package/src/setup.test.ts +0 -619
- package/src/targets.test.ts +0 -453
- package/src/targets.ts +0 -66
- package/src/types.config.ts +0 -3
- package/src/types.ts +0 -73
- package/test-api.ts +0 -2
- package/tsconfig.json +0 -16
package/src/monitor-routing.ts
DELETED
|
@@ -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
|
-
}
|
package/src/monitor-types.ts
DELETED
|
@@ -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
|
-
});
|