




















@@ -23,6 +23,7 @@ vi.mock("./auth.js", () => ({
23232424type ProcessEventFn = (event: GoogleChatEvent, target: WebhookTarget) => Promise<void>;
2525let createGoogleChatWebhookRequestHandler: typeof import("./monitor-webhook.js").createGoogleChatWebhookRequestHandler;
26+let warnAppPrincipalMisconfiguration: typeof import("./monitor-webhook.js").warnAppPrincipalMisconfiguration;
26272728function createRequest(authorization?: string): IncomingMessage {
2829return {
@@ -93,7 +94,8 @@ async function runWebhookHandler(options?: {
93949495describe("googlechat monitor webhook", () => {
9596beforeAll(async () => {
96-({ createGoogleChatWebhookRequestHandler } = await import("./monitor-webhook.js"));
97+({ createGoogleChatWebhookRequestHandler, warnAppPrincipalMisconfiguration } =
98+await import("./monitor-webhook.js"));
9799});
9810099101beforeEach(() => {
@@ -156,14 +158,217 @@ describe("googlechat monitor webhook", () => {
156158expect(res.headers["Content-Type"]).toBe("application/json");
157159});
158160161+it("logs WARN with reason when verification fails (missing token)", async () => {
162+const logFn = vi.fn();
163+installSimplePipeline([
164+{
165+account: {
166+accountId: "acct-1",
167+config: { appPrincipal: "chat-app" },
168+},
169+runtime: { log: logFn, error: vi.fn() },
170+audienceType: "app-url",
171+audience: "https://example.com/googlechat",
172+},
173+]);
174+readJsonWebhookBodyOrReject.mockResolvedValue({
175+ok: true,
176+value: {
177+commonEventObject: { hostApp: "CHAT" },
178+authorizationEventObject: { systemIdToken: "bad-token" },
179+chat: {
180+messagePayload: {
181+space: { name: "spaces/AAA" },
182+message: { name: "spaces/AAA/messages/1", text: "hi" },
183+},
184+},
185+},
186+});
187+resolveWebhookTargetWithAuthOrReject.mockImplementation(async ({ isMatch, targets, res }) => {
188+for (const target of targets) {
189+if (await isMatch(target)) {
190+return target;
191+}
192+}
193+res.statusCode = 401;
194+res.end("unauthorized");
195+return null;
196+});
197+verifyGoogleChatRequest.mockResolvedValue({ ok: false, reason: "missing token" });
198+const { processEvent, res } = await runWebhookHandler();
199+200+expect(logFn).toHaveBeenCalledWith(expect.stringContaining("acct-1"));
201+expect(logFn).toHaveBeenCalledWith(expect.stringContaining("missing token"));
202+expect(processEvent).not.toHaveBeenCalled();
203+expect(res.statusCode).toBe(401);
204+});
205+206+it("logs WARN with reason when verification fails (unexpected principal)", async () => {
207+const logFn = vi.fn();
208+installSimplePipeline([
209+{
210+account: {
211+accountId: "acct-2",
212+config: { appPrincipal: "chat-app" },
213+},
214+runtime: { log: logFn, error: vi.fn() },
215+audienceType: "app-url",
216+audience: "https://example.com/googlechat",
217+},
218+]);
219+readJsonWebhookBodyOrReject.mockResolvedValue({
220+ok: true,
221+value: {
222+commonEventObject: { hostApp: "CHAT" },
223+authorizationEventObject: { systemIdToken: "bad-token" },
224+chat: {
225+messagePayload: {
226+space: { name: "spaces/AAA" },
227+message: { name: "spaces/AAA/messages/1", text: "hi" },
228+},
229+},
230+},
231+});
232+resolveWebhookTargetWithAuthOrReject.mockImplementation(async ({ isMatch, targets, res }) => {
233+for (const target of targets) {
234+if (await isMatch(target)) {
235+return target;
236+}
237+}
238+res.statusCode = 401;
239+res.end("unauthorized");
240+return null;
241+});
242+verifyGoogleChatRequest.mockResolvedValue({
243+ok: false,
244+reason: "unexpected add-on principal: 999999999999999999999",
245+});
246+const { processEvent, res } = await runWebhookHandler();
247+248+expect(logFn).toHaveBeenCalledWith(expect.stringContaining("acct-2"));
249+expect(logFn).toHaveBeenCalledWith(
250+expect.stringContaining("unexpected add-on principal: 999999999999999999999"),
251+);
252+expect(processEvent).not.toHaveBeenCalled();
253+expect(res.statusCode).toBe(401);
254+});
255+256+it("does not log WARN when verification succeeds", async () => {
257+const logFn = vi.fn();
258+installSimplePipeline([
259+{
260+account: {
261+accountId: "acct-ok",
262+config: { appPrincipal: "chat-app" },
263+},
264+runtime: { log: logFn, error: vi.fn() },
265+statusSink: vi.fn(),
266+audienceType: "app-url",
267+audience: "https://example.com/googlechat",
268+},
269+]);
270+readJsonWebhookBodyOrReject.mockResolvedValue({
271+ok: true,
272+value: {
273+commonEventObject: { hostApp: "CHAT" },
274+authorizationEventObject: { systemIdToken: "good-token" },
275+chat: {
276+eventTime: "2026-03-22T00:00:00.000Z",
277+user: { name: "users/123" },
278+messagePayload: {
279+space: { name: "spaces/AAA" },
280+message: { name: "spaces/AAA/messages/1", text: "hi" },
281+},
282+},
283+},
284+});
285+resolveWebhookTargetWithAuthOrReject.mockImplementation(async ({ isMatch, targets }) => {
286+for (const target of targets) {
287+if (await isMatch(target)) {
288+return target;
289+}
290+}
291+return null;
292+});
293+verifyGoogleChatRequest.mockResolvedValue({ ok: true });
294+const { res } = await runWebhookHandler();
295+296+expect(logFn).not.toHaveBeenCalled();
297+expect(res.statusCode).toBe(200);
298+});
299+300+it("does not log failed candidate targets when another target verifies", async () => {
301+const logA = vi.fn();
302+const logB = vi.fn();
303+installSimplePipeline([
304+{
305+account: {
306+accountId: "acct-a",
307+config: { appPrincipal: "chat-app-a" },
308+},
309+runtime: { log: logA, error: vi.fn() },
310+audienceType: "app-url",
311+audience: "https://example.com/googlechat",
312+},
313+{
314+account: {
315+accountId: "acct-b",
316+config: { appPrincipal: "chat-app-b" },
317+},
318+runtime: { log: logB, error: vi.fn() },
319+statusSink: vi.fn(),
320+audienceType: "app-url",
321+audience: "https://example.com/googlechat",
322+},
323+]);
324+readJsonWebhookBodyOrReject.mockResolvedValue({
325+ok: true,
326+value: {
327+commonEventObject: { hostApp: "CHAT" },
328+authorizationEventObject: { systemIdToken: "shared-path-token" },
329+chat: {
330+eventTime: "2026-03-22T00:00:00.000Z",
331+user: { name: "users/123" },
332+messagePayload: {
333+space: { name: "spaces/BBB" },
334+message: { name: "spaces/BBB/messages/1", text: "hi" },
335+},
336+},
337+},
338+});
339+resolveWebhookTargetWithAuthOrReject.mockImplementation(async ({ isMatch, targets }) => {
340+for (const target of targets) {
341+if (await isMatch(target)) {
342+return target;
343+}
344+}
345+return null;
346+});
347+verifyGoogleChatRequest
348+.mockResolvedValueOnce({ ok: false, reason: "unexpected add-on principal: 111" })
349+.mockResolvedValueOnce({ ok: true });
350+const { processEvent, res } = await runWebhookHandler();
351+352+expect(logA).not.toHaveBeenCalled();
353+expect(logB).not.toHaveBeenCalled();
354+expect(processEvent).toHaveBeenCalledWith(
355+expect.anything(),
356+expect.objectContaining({
357+account: expect.objectContaining({ accountId: "acct-b" }),
358+}),
359+);
360+expect(res.statusCode).toBe(200);
361+});
362+159363it("rejects missing add-on bearer tokens before dispatch", async () => {
364+const logFn = vi.fn();
160365installSimplePipeline([
161366{
162367account: {
163368accountId: "default",
164369config: { appPrincipal: "chat-app" },
165370},
166-runtime: { error: vi.fn() },
371+runtime: { log: logFn, error: vi.fn() },
167372},
168373]);
169374readJsonWebhookBodyOrReject.mockResolvedValue({
@@ -181,7 +386,61 @@ describe("googlechat monitor webhook", () => {
181386const { processEvent, res } = await runWebhookHandler();
182387183388expect(processEvent).not.toHaveBeenCalled();
389+expect(logFn).toHaveBeenCalledWith(expect.stringContaining("default"));
390+expect(logFn).toHaveBeenCalledWith(expect.stringContaining("missing token"));
184391expect(res.statusCode).toBe(401);
185392expect(res.body).toBe("unauthorized");
186393});
187394});
395+396+describe("warnAppPrincipalMisconfiguration", () => {
397+it("warns when appPrincipal is missing for app-url audience", () => {
398+const log = vi.fn();
399+warnAppPrincipalMisconfiguration({
400+accountId: "acct-missing",
401+audienceType: "app-url",
402+appPrincipal: undefined,
403+ log,
404+});
405+expect(log).toHaveBeenCalledOnce();
406+expect(log).toHaveBeenCalledWith(expect.stringContaining("acct-missing"));
407+expect(log).toHaveBeenCalledWith(expect.stringContaining("appPrincipal is missing"));
408+expect(log).toHaveBeenCalledWith(expect.stringContaining("numeric OAuth 2.0 client ID"));
409+});
410+411+it("warns when appPrincipal contains @ for app-url audience", () => {
412+const log = vi.fn();
413+warnAppPrincipalMisconfiguration({
414+accountId: "acct-email",
415+audienceType: "app-url",
416+appPrincipal: "bot@example.iam.gserviceaccount.com",
417+ log,
418+});
419+expect(log).toHaveBeenCalledOnce();
420+expect(log).toHaveBeenCalledWith(expect.stringContaining("acct-email"));
421+expect(log).toHaveBeenCalledWith(expect.stringContaining("looks like an email"));
422+expect(log).toHaveBeenCalledWith(expect.stringContaining("numeric OAuth 2.0 client ID"));
423+});
424+425+it("does not warn for valid numeric appPrincipal with app-url audience", () => {
426+const log = vi.fn();
427+warnAppPrincipalMisconfiguration({
428+accountId: "acct-ok",
429+audienceType: "app-url",
430+appPrincipal: "123456789012345678901",
431+ log,
432+});
433+expect(log).not.toHaveBeenCalled();
434+});
435+436+it("does not warn for project-number audience even with missing appPrincipal", () => {
437+const log = vi.fn();
438+warnAppPrincipalMisconfiguration({
439+accountId: "acct-pn",
440+audienceType: "project-number",
441+appPrincipal: undefined,
442+ log,
443+});
444+expect(log).not.toHaveBeenCalled();
445+});
446+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。