@kodelyth/googlechat 2026.5.39 → 2026.5.42
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/api.ts +3 -0
- package/channel-config-api.ts +1 -0
- package/channel-plugin-api.ts +1 -0
- package/config-api.ts +2 -0
- package/contract-api.ts +5 -0
- package/dist/actions-YK1wn4ed.js +160 -0
- package/dist/api-BkZX4VNX.js +633 -0
- package/dist/api.js +3 -0
- package/dist/channel-DFZdjXD6.js +584 -0
- package/dist/channel-config-api.js +6 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/channel.runtime-en3RNg9S.js +998 -0
- package/dist/contract-api.js +3 -0
- package/dist/doctor-contract-8SF6XoKj.js +151 -0
- package/dist/doctor-contract-api.js +2 -0
- package/dist/index.js +22 -0
- package/dist/runtime-api-DUH2Cg-0.js +29 -0
- package/dist/runtime-api.js +2 -0
- package/dist/secret-contract-DWX4ikgT.js +99 -0
- package/dist/secret-contract-api.js +2 -0
- package/dist/setup-entry.js +15 -0
- package/dist/setup-plugin-api.js +75 -0
- package/dist/setup-surface-B3Fa7XRx.js +321 -0
- package/dist/test-api.js +3 -0
- package/doctor-contract-api.ts +1 -0
- package/index.ts +20 -0
- package/klaw.plugin.json +2 -967
- package/package.json +4 -4
- package/runtime-api.ts +55 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +13 -0
- package/setup-plugin-api.ts +3 -0
- package/src/accounts.ts +181 -0
- package/src/actions.test.ts +289 -0
- package/src/actions.ts +227 -0
- package/src/api.ts +316 -0
- package/src/approval-auth.test.ts +24 -0
- package/src/approval-auth.ts +32 -0
- package/src/auth.ts +218 -0
- package/src/channel-config.test.ts +39 -0
- package/src/channel.adapters.ts +340 -0
- package/src/channel.deps.runtime.ts +29 -0
- package/src/channel.runtime.ts +17 -0
- package/src/channel.setup.ts +98 -0
- package/src/channel.test.ts +784 -0
- package/src/channel.ts +277 -0
- package/src/config-schema.test.ts +31 -0
- package/src/config-schema.ts +3 -0
- package/src/doctor-contract.test.ts +75 -0
- package/src/doctor-contract.ts +182 -0
- package/src/doctor.ts +57 -0
- package/src/gateway.ts +63 -0
- package/src/google-auth.runtime.test.ts +543 -0
- package/src/google-auth.runtime.ts +568 -0
- package/src/group-policy.ts +17 -0
- package/src/monitor-access.test.ts +491 -0
- package/src/monitor-access.ts +465 -0
- package/src/monitor-durable.test.ts +39 -0
- package/src/monitor-durable.ts +23 -0
- package/src/monitor-reply-delivery.ts +156 -0
- package/src/monitor-routing.ts +65 -0
- package/src/monitor-types.ts +33 -0
- package/src/monitor-webhook.test.ts +587 -0
- package/src/monitor-webhook.ts +303 -0
- package/src/monitor.reply-delivery.test.ts +144 -0
- package/src/monitor.test.ts +159 -0
- package/src/monitor.ts +527 -0
- package/src/monitor.webhook-routing.test.ts +257 -0
- package/src/runtime.ts +9 -0
- package/src/secret-contract.test.ts +60 -0
- package/src/secret-contract.ts +161 -0
- package/src/setup-core.ts +40 -0
- package/src/setup-surface.ts +243 -0
- package/src/setup.test.ts +619 -0
- package/src/targets.test.ts +453 -0
- package/src/targets.ts +66 -0
- package/src/types.config.ts +3 -0
- package/src/types.ts +73 -0
- package/test-api.ts +2 -0
- package/tsconfig.json +16 -0
- package/api.js +0 -7
- package/channel-config-api.js +0 -7
- package/channel-plugin-api.js +0 -7
- package/contract-api.js +0 -7
- package/doctor-contract-api.js +0 -7
- package/index.js +0 -7
- package/runtime-api.js +0 -7
- package/secret-contract-api.js +0 -7
- package/setup-entry.js +0 -7
- package/setup-plugin-api.js +0 -7
- package/test-api.js +0 -7
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
|
3
|
+
import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js";
|
|
4
|
+
import { resolveGoogleChatGroupRequireMention } from "./group-policy.js";
|
|
5
|
+
import {
|
|
6
|
+
isGoogleChatSpaceTarget,
|
|
7
|
+
isGoogleChatUserTarget,
|
|
8
|
+
normalizeGoogleChatTarget,
|
|
9
|
+
} from "./targets.js";
|
|
10
|
+
|
|
11
|
+
const mocks = vi.hoisted(() => ({
|
|
12
|
+
buildHostnameAllowlistPolicyFromSuffixAllowlist: vi.fn((hosts: string[]) => ({
|
|
13
|
+
hostnameAllowlist: hosts,
|
|
14
|
+
})),
|
|
15
|
+
fetchWithSsrFGuard: vi.fn(async (params: { url: string; init?: RequestInit }) => ({
|
|
16
|
+
response: await fetch(params.url, params.init),
|
|
17
|
+
release: async () => {},
|
|
18
|
+
})),
|
|
19
|
+
googleAuthCtor: vi.fn(),
|
|
20
|
+
gaxiosCtor: vi.fn(),
|
|
21
|
+
getAccessToken: vi.fn().mockResolvedValue({ token: "access-token" }),
|
|
22
|
+
oauthCtor: vi.fn(),
|
|
23
|
+
verifySignedJwtWithCertsAsync: vi.fn(),
|
|
24
|
+
verifyIdToken: vi.fn(),
|
|
25
|
+
getGoogleChatAccessToken: vi.fn().mockResolvedValue("token"),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock("klaw/plugin-sdk/ssrf-runtime", () => {
|
|
29
|
+
return {
|
|
30
|
+
buildHostnameAllowlistPolicyFromSuffixAllowlist:
|
|
31
|
+
mocks.buildHostnameAllowlistPolicyFromSuffixAllowlist,
|
|
32
|
+
fetchWithSsrFGuard: mocks.fetchWithSsrFGuard,
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
vi.mock("gaxios", () => ({
|
|
37
|
+
Gaxios: class {
|
|
38
|
+
defaults: unknown;
|
|
39
|
+
interceptors = {
|
|
40
|
+
request: { add: vi.fn() },
|
|
41
|
+
response: { add: vi.fn() },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
constructor(defaults?: unknown) {
|
|
45
|
+
this.defaults = defaults;
|
|
46
|
+
mocks.gaxiosCtor(defaults);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
vi.mock("google-auth-library", () => ({
|
|
52
|
+
GoogleAuth: class {
|
|
53
|
+
constructor(options?: unknown) {
|
|
54
|
+
mocks.googleAuthCtor(options);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
getClient = vi.fn().mockResolvedValue({
|
|
58
|
+
getAccessToken: mocks.getAccessToken,
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
OAuth2Client: class {
|
|
62
|
+
constructor(options?: unknown) {
|
|
63
|
+
mocks.oauthCtor(options);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
verifyIdToken = mocks.verifyIdToken;
|
|
67
|
+
verifySignedJwtWithCertsAsync = mocks.verifySignedJwtWithCertsAsync;
|
|
68
|
+
},
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
vi.mock("./auth.js", async () => {
|
|
72
|
+
const actual = await vi.importActual<typeof import("./auth.js")>("./auth.js");
|
|
73
|
+
return {
|
|
74
|
+
...actual,
|
|
75
|
+
getGoogleChatAccessToken: mocks.getGoogleChatAccessToken,
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const authActual = await vi.importActual<typeof import("./auth.js")>("./auth.js");
|
|
80
|
+
const { testing: authTesting, getGoogleChatAccessToken, verifyGoogleChatRequest } = authActual;
|
|
81
|
+
|
|
82
|
+
afterAll(() => {
|
|
83
|
+
vi.doUnmock("klaw/plugin-sdk/ssrf-runtime");
|
|
84
|
+
vi.doUnmock("gaxios");
|
|
85
|
+
vi.doUnmock("google-auth-library");
|
|
86
|
+
vi.doUnmock("./auth.js");
|
|
87
|
+
vi.resetModules();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const account = {
|
|
91
|
+
accountId: "default",
|
|
92
|
+
enabled: true,
|
|
93
|
+
credentialSource: "inline",
|
|
94
|
+
config: {},
|
|
95
|
+
} as ResolvedGoogleChatAccount;
|
|
96
|
+
|
|
97
|
+
function stubSuccessfulSend(name: string) {
|
|
98
|
+
const fetchMock = vi
|
|
99
|
+
.fn()
|
|
100
|
+
.mockResolvedValue(new Response(JSON.stringify({ name }), { status: 200 }));
|
|
101
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
102
|
+
return fetchMock;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function expectDownloadToRejectForResponse(response: Response) {
|
|
106
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
|
|
107
|
+
await expect(
|
|
108
|
+
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
|
|
109
|
+
).rejects.toThrow(/max bytes/i);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function mockCallArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0): unknown {
|
|
113
|
+
const call = mock.mock.calls[callIndex];
|
|
114
|
+
if (!call) {
|
|
115
|
+
throw new Error(`Expected mock call ${callIndex}`);
|
|
116
|
+
}
|
|
117
|
+
return call[argIndex];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe("normalizeGoogleChatTarget", () => {
|
|
121
|
+
it("normalizes provider prefixes", () => {
|
|
122
|
+
expect(normalizeGoogleChatTarget("googlechat:users/123")).toBe("users/123");
|
|
123
|
+
expect(normalizeGoogleChatTarget("google-chat:spaces/AAA")).toBe("spaces/AAA");
|
|
124
|
+
expect(normalizeGoogleChatTarget("gchat:user:User@Example.com")).toBe("users/user@example.com");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("normalizes email targets to users/<email>", () => {
|
|
128
|
+
expect(normalizeGoogleChatTarget("User@Example.com")).toBe("users/user@example.com");
|
|
129
|
+
expect(normalizeGoogleChatTarget("users/User@Example.com")).toBe("users/user@example.com");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("preserves space targets", () => {
|
|
133
|
+
expect(normalizeGoogleChatTarget("space:spaces/BBB")).toBe("spaces/BBB");
|
|
134
|
+
expect(normalizeGoogleChatTarget("spaces/CCC")).toBe("spaces/CCC");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("target helpers", () => {
|
|
139
|
+
it("detects user and space targets", () => {
|
|
140
|
+
expect(isGoogleChatUserTarget("users/abc")).toBe(true);
|
|
141
|
+
expect(isGoogleChatSpaceTarget("spaces/abc")).toBe(true);
|
|
142
|
+
expect(isGoogleChatUserTarget("spaces/abc")).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("googlechat group policy", () => {
|
|
147
|
+
it("uses generic channel group policy helpers", () => {
|
|
148
|
+
const cfg = {
|
|
149
|
+
channels: {
|
|
150
|
+
googlechat: {
|
|
151
|
+
groups: {
|
|
152
|
+
"spaces/AAA": {
|
|
153
|
+
requireMention: false,
|
|
154
|
+
},
|
|
155
|
+
"*": {
|
|
156
|
+
requireMention: true,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
} as any;
|
|
162
|
+
|
|
163
|
+
expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/AAA" })).toBe(false);
|
|
164
|
+
expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/BBB" })).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("downloadGoogleChatMedia", () => {
|
|
169
|
+
afterEach(() => {
|
|
170
|
+
authTesting.resetGoogleChatAuthForTests();
|
|
171
|
+
mocks.fetchWithSsrFGuard.mockClear();
|
|
172
|
+
vi.unstubAllGlobals();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("rejects when content-length exceeds max bytes", async () => {
|
|
176
|
+
const body = new ReadableStream({
|
|
177
|
+
start(controller) {
|
|
178
|
+
controller.enqueue(new Uint8Array([1, 2, 3]));
|
|
179
|
+
controller.close();
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
const response = new Response(body, {
|
|
183
|
+
status: 200,
|
|
184
|
+
headers: { "content-length": "50", "content-type": "application/octet-stream" },
|
|
185
|
+
});
|
|
186
|
+
await expectDownloadToRejectForResponse(response);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("rejects when streamed payload exceeds max bytes", async () => {
|
|
190
|
+
const chunks = [new Uint8Array(6), new Uint8Array(6)];
|
|
191
|
+
let index = 0;
|
|
192
|
+
const body = new ReadableStream({
|
|
193
|
+
pull(controller) {
|
|
194
|
+
if (index < chunks.length) {
|
|
195
|
+
controller.enqueue(chunks[index++]);
|
|
196
|
+
} else {
|
|
197
|
+
controller.close();
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
const response = new Response(body, {
|
|
202
|
+
status: 200,
|
|
203
|
+
headers: { "content-type": "application/octet-stream" },
|
|
204
|
+
});
|
|
205
|
+
await expectDownloadToRejectForResponse(response);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("sendGoogleChatMessage", () => {
|
|
210
|
+
afterEach(() => {
|
|
211
|
+
authTesting.resetGoogleChatAuthForTests();
|
|
212
|
+
mocks.fetchWithSsrFGuard.mockClear();
|
|
213
|
+
vi.unstubAllGlobals();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("adds messageReplyOption when sending to an existing thread", async () => {
|
|
217
|
+
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/123");
|
|
218
|
+
|
|
219
|
+
await sendGoogleChatMessage({
|
|
220
|
+
account,
|
|
221
|
+
space: "spaces/AAA",
|
|
222
|
+
text: "hello",
|
|
223
|
+
thread: "spaces/AAA/threads/xyz",
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const url = mockCallArg(fetchMock);
|
|
227
|
+
const init = mockCallArg(fetchMock, 0, 1) as RequestInit | undefined;
|
|
228
|
+
expect(String(url)).toContain("messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD");
|
|
229
|
+
if (typeof init?.body !== "string") {
|
|
230
|
+
throw new Error("Expected Google Chat request body");
|
|
231
|
+
}
|
|
232
|
+
const body = JSON.parse(init.body) as {
|
|
233
|
+
text?: unknown;
|
|
234
|
+
thread?: { name?: unknown };
|
|
235
|
+
};
|
|
236
|
+
expect(body.text).toBe("hello");
|
|
237
|
+
expect(body.thread?.name).toBe("spaces/AAA/threads/xyz");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("does not set messageReplyOption for non-thread sends", async () => {
|
|
241
|
+
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/124");
|
|
242
|
+
|
|
243
|
+
await sendGoogleChatMessage({
|
|
244
|
+
account,
|
|
245
|
+
space: "spaces/AAA",
|
|
246
|
+
text: "hello",
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const url = mockCallArg(fetchMock);
|
|
250
|
+
expect(String(url)).not.toContain("messageReplyOption=");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("reports malformed send JSON with a stable API error", async () => {
|
|
254
|
+
vi.stubGlobal(
|
|
255
|
+
"fetch",
|
|
256
|
+
vi.fn().mockResolvedValue(
|
|
257
|
+
new Response("{ nope", {
|
|
258
|
+
status: 200,
|
|
259
|
+
headers: { "content-type": "application/json" },
|
|
260
|
+
}),
|
|
261
|
+
),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
await expect(
|
|
265
|
+
sendGoogleChatMessage({
|
|
266
|
+
account,
|
|
267
|
+
space: "spaces/AAA",
|
|
268
|
+
text: "hello",
|
|
269
|
+
}),
|
|
270
|
+
).rejects.toThrow("Google Chat API request failed: malformed JSON response");
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
function mockTicket(payload: Record<string, unknown>) {
|
|
275
|
+
mocks.verifyIdToken.mockResolvedValue({
|
|
276
|
+
getPayload: () => payload,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
describe("verifyGoogleChatRequest", () => {
|
|
281
|
+
afterEach(() => {
|
|
282
|
+
authTesting.resetGoogleChatAuthForTests();
|
|
283
|
+
mocks.getAccessToken.mockClear();
|
|
284
|
+
mocks.gaxiosCtor.mockClear();
|
|
285
|
+
mocks.googleAuthCtor.mockClear();
|
|
286
|
+
mocks.oauthCtor.mockClear();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("injects a scoped transporter into GoogleAuth access-token clients", async () => {
|
|
290
|
+
await expect(
|
|
291
|
+
getGoogleChatAccessToken({
|
|
292
|
+
...account,
|
|
293
|
+
credentials: {
|
|
294
|
+
auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs",
|
|
295
|
+
auth_uri: "https://accounts.google.com/o/oauth2/auth",
|
|
296
|
+
client_email: "bot@example.iam.gserviceaccount.com",
|
|
297
|
+
private_key: "key",
|
|
298
|
+
token_uri: "https://oauth2.googleapis.com/token",
|
|
299
|
+
type: "service_account",
|
|
300
|
+
universe_domain: "googleapis.com",
|
|
301
|
+
},
|
|
302
|
+
}),
|
|
303
|
+
).resolves.toBe("access-token");
|
|
304
|
+
|
|
305
|
+
const googleAuthOptions = mockCallArg(mocks.googleAuthCtor) as {
|
|
306
|
+
clientOptions?: { transporter?: { defaults?: { fetchImplementation?: unknown } } };
|
|
307
|
+
credentials?: { client_email?: string; token_uri?: string };
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
expect(mocks.gaxiosCtor).toHaveBeenCalledOnce();
|
|
311
|
+
expect(googleAuthOptions.credentials?.client_email).toBe("bot@example.iam.gserviceaccount.com");
|
|
312
|
+
expect(googleAuthOptions.credentials?.token_uri).toBe("https://oauth2.googleapis.com/token");
|
|
313
|
+
expect(typeof googleAuthOptions.clientOptions?.transporter?.defaults?.fetchImplementation).toBe(
|
|
314
|
+
"function",
|
|
315
|
+
);
|
|
316
|
+
expect(mocks.getAccessToken).toHaveBeenCalledOnce();
|
|
317
|
+
expect("window" in globalThis).toBe(false);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("accepts Google Chat app-url tokens from the Chat issuer", async () => {
|
|
321
|
+
mocks.verifyIdToken.mockReset();
|
|
322
|
+
mockTicket({
|
|
323
|
+
email: "chat@system.gserviceaccount.com",
|
|
324
|
+
email_verified: true,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
await expect(
|
|
328
|
+
verifyGoogleChatRequest({
|
|
329
|
+
bearer: "token",
|
|
330
|
+
audienceType: "app-url",
|
|
331
|
+
audience: "https://example.com/googlechat",
|
|
332
|
+
}),
|
|
333
|
+
).resolves.toEqual({ ok: true });
|
|
334
|
+
|
|
335
|
+
const oauthOptions = mockCallArg(mocks.oauthCtor) as {
|
|
336
|
+
transporter?: { defaults?: { fetchImplementation?: unknown } };
|
|
337
|
+
};
|
|
338
|
+
expect(typeof oauthOptions.transporter?.defaults?.fetchImplementation).toBe("function");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("rejects add-on tokens when no principal binding is configured", async () => {
|
|
342
|
+
mocks.verifyIdToken.mockReset();
|
|
343
|
+
mockTicket({
|
|
344
|
+
email: "service-123@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
|
|
345
|
+
email_verified: true,
|
|
346
|
+
sub: "principal-1",
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
await expect(
|
|
350
|
+
verifyGoogleChatRequest({
|
|
351
|
+
bearer: "token",
|
|
352
|
+
audienceType: "app-url",
|
|
353
|
+
audience: "https://example.com/googlechat",
|
|
354
|
+
}),
|
|
355
|
+
).resolves.toEqual({
|
|
356
|
+
ok: false,
|
|
357
|
+
reason: "missing add-on principal binding",
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("accepts add-on tokens only when the bound principal matches", async () => {
|
|
362
|
+
mocks.verifyIdToken.mockReset();
|
|
363
|
+
mockTicket({
|
|
364
|
+
email: "service-123@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
|
|
365
|
+
email_verified: true,
|
|
366
|
+
sub: "principal-1",
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
await expect(
|
|
370
|
+
verifyGoogleChatRequest({
|
|
371
|
+
bearer: "token",
|
|
372
|
+
audienceType: "app-url",
|
|
373
|
+
audience: "https://example.com/googlechat",
|
|
374
|
+
expectedAddOnPrincipal: "principal-1",
|
|
375
|
+
}),
|
|
376
|
+
).resolves.toEqual({ ok: true });
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("rejects add-on tokens when the bound principal does not match", async () => {
|
|
380
|
+
mocks.verifyIdToken.mockReset();
|
|
381
|
+
mockTicket({
|
|
382
|
+
email: "service-123@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
|
|
383
|
+
email_verified: true,
|
|
384
|
+
sub: "principal-2",
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
await expect(
|
|
388
|
+
verifyGoogleChatRequest({
|
|
389
|
+
bearer: "token",
|
|
390
|
+
audienceType: "app-url",
|
|
391
|
+
audience: "https://example.com/googlechat",
|
|
392
|
+
expectedAddOnPrincipal: "principal-1",
|
|
393
|
+
}),
|
|
394
|
+
).resolves.toEqual({
|
|
395
|
+
ok: false,
|
|
396
|
+
reason: "unexpected add-on principal: principal-2",
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("fetches Chat certs through the guarded fetch for project-number tokens", async () => {
|
|
401
|
+
const release = vi.fn();
|
|
402
|
+
mocks.fetchWithSsrFGuard.mockClear();
|
|
403
|
+
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
|
404
|
+
response: new Response(JSON.stringify({ "kid-1": "cert-body" }), { status: 200 }),
|
|
405
|
+
release,
|
|
406
|
+
});
|
|
407
|
+
mocks.verifySignedJwtWithCertsAsync.mockReset().mockResolvedValue(undefined);
|
|
408
|
+
|
|
409
|
+
await expect(
|
|
410
|
+
verifyGoogleChatRequest({
|
|
411
|
+
bearer: "token",
|
|
412
|
+
audienceType: "project-number",
|
|
413
|
+
audience: "123456789",
|
|
414
|
+
}),
|
|
415
|
+
).resolves.toEqual({ ok: true });
|
|
416
|
+
|
|
417
|
+
expect(mocks.fetchWithSsrFGuard).toHaveBeenCalledWith({
|
|
418
|
+
url: "https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com",
|
|
419
|
+
auditContext: "googlechat.auth.certs",
|
|
420
|
+
});
|
|
421
|
+
expect(mocks.verifySignedJwtWithCertsAsync).toHaveBeenCalledWith(
|
|
422
|
+
"token",
|
|
423
|
+
{ "kid-1": "cert-body" },
|
|
424
|
+
"123456789",
|
|
425
|
+
["chat@system.gserviceaccount.com"],
|
|
426
|
+
);
|
|
427
|
+
expect(release).toHaveBeenCalledOnce();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("reports malformed Chat cert JSON with a stable auth error", async () => {
|
|
431
|
+
authTesting.resetGoogleChatAuthForTests();
|
|
432
|
+
const release = vi.fn(async () => {});
|
|
433
|
+
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
|
|
434
|
+
response: new Response("{ nope", {
|
|
435
|
+
status: 200,
|
|
436
|
+
headers: { "content-type": "application/json" },
|
|
437
|
+
}),
|
|
438
|
+
release,
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
await expect(
|
|
442
|
+
verifyGoogleChatRequest({
|
|
443
|
+
bearer: "token",
|
|
444
|
+
audienceType: "project-number",
|
|
445
|
+
audience: "123456789",
|
|
446
|
+
}),
|
|
447
|
+
).resolves.toEqual({
|
|
448
|
+
ok: false,
|
|
449
|
+
reason: "Google Chat cert fetch failed: malformed JSON response",
|
|
450
|
+
});
|
|
451
|
+
expect(release).toHaveBeenCalledOnce();
|
|
452
|
+
});
|
|
453
|
+
});
|
package/src/targets.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { normalizeLowercaseStringOrEmpty } from "klaw/plugin-sdk/string-coerce-runtime";
|
|
2
|
+
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
|
3
|
+
import { findGoogleChatDirectMessage } from "./api.js";
|
|
4
|
+
|
|
5
|
+
export function normalizeGoogleChatTarget(raw?: string | null): string | undefined {
|
|
6
|
+
const trimmed = raw?.trim();
|
|
7
|
+
if (!trimmed) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
const withoutPrefix = trimmed.replace(/^(googlechat|google-chat|gchat):/i, "");
|
|
11
|
+
const normalized = withoutPrefix
|
|
12
|
+
.replace(/^user:(users\/)?/i, "users/")
|
|
13
|
+
.replace(/^space:(spaces\/)?/i, "spaces/");
|
|
14
|
+
if (isGoogleChatUserTarget(normalized)) {
|
|
15
|
+
const suffix = normalized.slice("users/".length);
|
|
16
|
+
return suffix.includes("@") ? `users/${normalizeLowercaseStringOrEmpty(suffix)}` : normalized;
|
|
17
|
+
}
|
|
18
|
+
if (isGoogleChatSpaceTarget(normalized)) {
|
|
19
|
+
return normalized;
|
|
20
|
+
}
|
|
21
|
+
if (normalized.includes("@")) {
|
|
22
|
+
return `users/${normalizeLowercaseStringOrEmpty(normalized)}`;
|
|
23
|
+
}
|
|
24
|
+
return normalized;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isGoogleChatUserTarget(value: string): boolean {
|
|
28
|
+
return normalizeLowercaseStringOrEmpty(value).startsWith("users/");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isGoogleChatSpaceTarget(value: string): boolean {
|
|
32
|
+
return normalizeLowercaseStringOrEmpty(value).startsWith("spaces/");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stripMessageSuffix(target: string): string {
|
|
36
|
+
const index = target.indexOf("/messages/");
|
|
37
|
+
if (index === -1) {
|
|
38
|
+
return target;
|
|
39
|
+
}
|
|
40
|
+
return target.slice(0, index);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function resolveGoogleChatOutboundSpace(params: {
|
|
44
|
+
account: ResolvedGoogleChatAccount;
|
|
45
|
+
target: string;
|
|
46
|
+
}): Promise<string> {
|
|
47
|
+
const normalized = normalizeGoogleChatTarget(params.target);
|
|
48
|
+
if (!normalized) {
|
|
49
|
+
throw new Error("Missing Google Chat target.");
|
|
50
|
+
}
|
|
51
|
+
const base = stripMessageSuffix(normalized);
|
|
52
|
+
if (isGoogleChatSpaceTarget(base)) {
|
|
53
|
+
return base;
|
|
54
|
+
}
|
|
55
|
+
if (isGoogleChatUserTarget(base)) {
|
|
56
|
+
const dm = await findGoogleChatDirectMessage({
|
|
57
|
+
account: params.account,
|
|
58
|
+
userName: base,
|
|
59
|
+
});
|
|
60
|
+
if (!dm?.name) {
|
|
61
|
+
throw new Error(`No Google Chat DM found for ${base}`);
|
|
62
|
+
}
|
|
63
|
+
return dm.name;
|
|
64
|
+
}
|
|
65
|
+
return base;
|
|
66
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export type GoogleChatSpace = {
|
|
2
|
+
name?: string;
|
|
3
|
+
displayName?: string;
|
|
4
|
+
type?: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type GoogleChatUser = {
|
|
8
|
+
name?: string;
|
|
9
|
+
displayName?: string;
|
|
10
|
+
email?: string;
|
|
11
|
+
type?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type GoogleChatThread = {
|
|
15
|
+
name?: string;
|
|
16
|
+
threadKey?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type GoogleChatAttachmentDataRef = {
|
|
20
|
+
resourceName?: string;
|
|
21
|
+
attachmentUploadToken?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type GoogleChatAttachment = {
|
|
25
|
+
name?: string;
|
|
26
|
+
contentName?: string;
|
|
27
|
+
contentType?: string;
|
|
28
|
+
thumbnailUri?: string;
|
|
29
|
+
downloadUri?: string;
|
|
30
|
+
source?: string;
|
|
31
|
+
attachmentDataRef?: GoogleChatAttachmentDataRef;
|
|
32
|
+
driveDataRef?: Record<string, unknown>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type GoogleChatUserMention = {
|
|
36
|
+
user?: GoogleChatUser;
|
|
37
|
+
type?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type GoogleChatAnnotation = {
|
|
41
|
+
type?: string;
|
|
42
|
+
startIndex?: number;
|
|
43
|
+
length?: number;
|
|
44
|
+
userMention?: GoogleChatUserMention;
|
|
45
|
+
slashCommand?: Record<string, unknown>;
|
|
46
|
+
richLinkMetadata?: Record<string, unknown>;
|
|
47
|
+
customEmojiMetadata?: Record<string, unknown>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type GoogleChatMessage = {
|
|
51
|
+
name?: string;
|
|
52
|
+
text?: string;
|
|
53
|
+
argumentText?: string;
|
|
54
|
+
sender?: GoogleChatUser;
|
|
55
|
+
thread?: GoogleChatThread;
|
|
56
|
+
attachment?: GoogleChatAttachment[];
|
|
57
|
+
annotations?: GoogleChatAnnotation[];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type GoogleChatEvent = {
|
|
61
|
+
type?: string;
|
|
62
|
+
eventType?: string;
|
|
63
|
+
eventTime?: string;
|
|
64
|
+
space?: GoogleChatSpace;
|
|
65
|
+
user?: GoogleChatUser;
|
|
66
|
+
message?: GoogleChatMessage;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type GoogleChatReaction = {
|
|
70
|
+
name?: string;
|
|
71
|
+
user?: GoogleChatUser;
|
|
72
|
+
emoji?: { unicode?: string };
|
|
73
|
+
};
|
package/test-api.ts
ADDED
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../tsconfig.package-boundary.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"rootDir": "."
|
|
5
|
+
},
|
|
6
|
+
"include": ["./*.ts", "./src/**/*.ts"],
|
|
7
|
+
"exclude": [
|
|
8
|
+
"./**/*.test.ts",
|
|
9
|
+
"./dist/**",
|
|
10
|
+
"./node_modules/**",
|
|
11
|
+
"./src/test-support/**",
|
|
12
|
+
"./src/**/*test-helpers.ts",
|
|
13
|
+
"./src/**/*test-harness.ts",
|
|
14
|
+
"./src/**/*test-support.ts"
|
|
15
|
+
]
|
|
16
|
+
}
|
package/api.js
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
export * from "../../../dist/extensions/googlechat/api.js";
|
|
2
|
-
import * as module from "../../../dist/extensions/googlechat/api.js";
|
|
3
|
-
let defaultExport = "default" in module ? module.default : module;
|
|
4
|
-
for (let index = 0; index < 4 && defaultExport && typeof defaultExport === "object" && "default" in defaultExport; index += 1) {
|
|
5
|
-
defaultExport = defaultExport.default;
|
|
6
|
-
}
|
|
7
|
-
export { defaultExport as default };
|
package/channel-config-api.js
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
export * from "../../../dist/extensions/googlechat/channel-config-api.js";
|
|
2
|
-
import * as module from "../../../dist/extensions/googlechat/channel-config-api.js";
|
|
3
|
-
let defaultExport = "default" in module ? module.default : module;
|
|
4
|
-
for (let index = 0; index < 4 && defaultExport && typeof defaultExport === "object" && "default" in defaultExport; index += 1) {
|
|
5
|
-
defaultExport = defaultExport.default;
|
|
6
|
-
}
|
|
7
|
-
export { defaultExport as default };
|
package/channel-plugin-api.js
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
export * from "../../../dist/extensions/googlechat/channel-plugin-api.js";
|
|
2
|
-
import * as module from "../../../dist/extensions/googlechat/channel-plugin-api.js";
|
|
3
|
-
let defaultExport = "default" in module ? module.default : module;
|
|
4
|
-
for (let index = 0; index < 4 && defaultExport && typeof defaultExport === "object" && "default" in defaultExport; index += 1) {
|
|
5
|
-
defaultExport = defaultExport.default;
|
|
6
|
-
}
|
|
7
|
-
export { defaultExport as default };
|
package/contract-api.js
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
export * from "../../../dist/extensions/googlechat/contract-api.js";
|
|
2
|
-
import * as module from "../../../dist/extensions/googlechat/contract-api.js";
|
|
3
|
-
let defaultExport = "default" in module ? module.default : module;
|
|
4
|
-
for (let index = 0; index < 4 && defaultExport && typeof defaultExport === "object" && "default" in defaultExport; index += 1) {
|
|
5
|
-
defaultExport = defaultExport.default;
|
|
6
|
-
}
|
|
7
|
-
export { defaultExport as default };
|
package/doctor-contract-api.js
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
export * from "../../../dist/extensions/googlechat/doctor-contract-api.js";
|
|
2
|
-
import * as module from "../../../dist/extensions/googlechat/doctor-contract-api.js";
|
|
3
|
-
let defaultExport = "default" in module ? module.default : module;
|
|
4
|
-
for (let index = 0; index < 4 && defaultExport && typeof defaultExport === "object" && "default" in defaultExport; index += 1) {
|
|
5
|
-
defaultExport = defaultExport.default;
|
|
6
|
-
}
|
|
7
|
-
export { defaultExport as default };
|
package/index.js
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
export * from "../../../dist/extensions/googlechat/index.js";
|
|
2
|
-
import defaultModule from "../../../dist/extensions/googlechat/index.js";
|
|
3
|
-
let defaultExport = defaultModule;
|
|
4
|
-
for (let index = 0; index < 4 && defaultExport && typeof defaultExport === "object" && "default" in defaultExport; index += 1) {
|
|
5
|
-
defaultExport = defaultExport.default;
|
|
6
|
-
}
|
|
7
|
-
export { defaultExport as default };
|
package/runtime-api.js
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
export * from "../../../dist/extensions/googlechat/runtime-api.js";
|
|
2
|
-
import * as module from "../../../dist/extensions/googlechat/runtime-api.js";
|
|
3
|
-
let defaultExport = "default" in module ? module.default : module;
|
|
4
|
-
for (let index = 0; index < 4 && defaultExport && typeof defaultExport === "object" && "default" in defaultExport; index += 1) {
|
|
5
|
-
defaultExport = defaultExport.default;
|
|
6
|
-
}
|
|
7
|
-
export { defaultExport as default };
|
package/secret-contract-api.js
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
export * from "../../../dist/extensions/googlechat/secret-contract-api.js";
|
|
2
|
-
import * as module from "../../../dist/extensions/googlechat/secret-contract-api.js";
|
|
3
|
-
let defaultExport = "default" in module ? module.default : module;
|
|
4
|
-
for (let index = 0; index < 4 && defaultExport && typeof defaultExport === "object" && "default" in defaultExport; index += 1) {
|
|
5
|
-
defaultExport = defaultExport.default;
|
|
6
|
-
}
|
|
7
|
-
export { defaultExport as default };
|