Files
OmniRoute/tests/unit/copilot-m365-invocation-refresh-10718.test.ts
N123 Project 6cd4d38e21 fix(m365): BizChat invocation shape drift + HAR-import UX + Antigravity alias note (#11069)
5 — M365 Copilot (BizChat) individual/consumer path — 3 itens: (1) forma de invocação do #10718 derivou de novo (2026-08-21 capture): optionsSets 14→34, allowedMessageTypes 6→30, tone "magic"→"Magic", plugins []→[{BingWebSearch}], disconnectBehavior em todos os tiers, +8 keys de clientInfo; verificado contra conta real com round-trip WebSocket (ping-then-close → resposta real). (2) Aviso sobre o alias Antigravity gemini-3.1-pro-high ainda não publicado (3.8.49 pré-data). (3) Botão "Import .har file" no modal de credencial M365.

Conflito resolvido em copilot-m365-frames.ts (board vs release tip): mantive o forwarding de opts.plugins/toolChoice/customInstructions do HEAD com os NOVOS defaults da captura (BingWebSearch builtin, tone "Magic"). Alinhei 3 testes pré-existentes que afirmavam o contrato antigo (m365-bizchat-frames-4042 clientInfo, m365-tone-model-variants tone, copilot-m365-tool-calls plugins) — propagação de contrato, não mascaramento. Rebaselinei AddApiKeyModal 1073→1080 (crescimento próprio da parte 3, ~Har import button) com anotação.

Validação: typecheck limpo, 142/142 testes m365/copilot verdes, changelog-integrit/file-size/eslint OK.
2026-08-21 22:32:19 -03:00

247 lines
9.7 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
// #10718 — the substrate started dropping the old type:4 chat invocation shape
// (immediate bare type:3 close, "(empty response)" on every request). A fresh
// TLS-MITM capture of a working m365.cloud.microsoft/chat round-trip (2026-08)
// showed a materially different argument shape AND a type:1 target:"Metrics"
// frame written in the SAME socket write right after the invocation — an
// invocation without its Metrics pair is silently ignored.
//
// These tests pin the recaptured wire shape and the refresh_token pre-flight
// (the browser-issued access_token lives ~75 min with no refresh path before
// this). Live round-trip on a real EDU (A3/Starter) tenant is the separate
// Rule #18 validation gate.
import {
RECORD_SEPARATOR,
metricsFrame,
buildChatInvocation,
resolveChatInvocationOverrides,
M365_DEFAULT_OPTION_SETS,
ALLOWED_MESSAGE_TYPES,
} from "../../open-sse/executors/copilot-m365-frames.ts";
import {
decodeJwtClaims,
tokenNeedsRefresh,
refreshM365AccessToken,
M365_OAUTH_CLIENT_ID,
M365_REFRESH_LEAD_MS,
} from "../../open-sse/executors/copilot-m365-connection.ts";
// ── Metrics follow-up frame ────────────────────────────────────────────────
test("#10718: metricsFrame emits the exact bytes observed in the browser capture", () => {
assert.equal(
metricsFrame(),
'{"arguments":[{"Timestamps":{"ConnectionEstablished":"","ConnectionStart":"","UserInputStart":"","UserInputSubmit":""}}],"target":"Metrics","type":1}' +
RECORD_SEPARATOR
);
});
// ── Recaptured invocation shape ────────────────────────────────────────────
test("2026-08-21: buildChatInvocation matches the recaptured arguments[0] key set", () => {
const arg = buildChatInvocation({
text: "Say OK in one word.",
traceId: "11111111-1111-1111-1111-111111111111",
sessionId: "22222222-2222-2222-2222-222222222222",
requestId: "33333333-3333-3333-3333-333333333333",
conversationId: "44444444-4444-4444-4444-444444444444",
}).arguments[0] as Record<string, unknown>;
// Exact key set from the 2026-08-21 capture (issue: individual/consumer M365
// Copilot calls got only SignalR keepalive pings and no type:1 update at all
// — "Stream ended before producing a non-ping SSE event" client-side). The
// #10718 shape below is missing exactly the keys this capture added.
assert.deepEqual(Object.keys(arg).sort(), [
"allowedMessageTypes",
"clientCorrelationId",
"clientInfo",
"conversationId",
"disconnectBehavior",
"extraExtensionParameters",
"isSbsSupported",
"isStartOfSession",
"message",
"options",
"optionsSets",
"plugins",
"productThreadType",
"renderReferencesBehindEOS",
"sessionId",
"sliceIds",
"source",
"streamingMode",
"threadLevelGptId",
"tone",
"toolChoice",
"traceId",
]);
assert.equal(arg.productThreadType, "Office");
assert.deepEqual(arg.clientInfo, {
clientAppName: "Office",
clientPlatform: "mcmcopilot-web",
clientEntrypoint: "mcmcopilot-officeweb",
clientSessionId: "22222222-2222-2222-2222-222222222222",
ProductCategory: "Chat",
clientAppType: "Web",
productEntryPoint: "ChatPanel",
deviceOS: "Windows",
deviceType: "Desktop",
clientPlatformVersion: "10",
});
assert.equal(arg.conversationId, "44444444-4444-4444-4444-444444444444");
assert.equal(arg.toolChoice, null);
// The individual/consumer surface now sends "Magic" (capitalized), matching
// the enterprise tone literal — the #10718 lowercase "magic" is stale.
assert.equal(arg.tone, "Magic");
assert.equal(arg.isSbsSupported, true);
assert.equal(arg.renderReferencesBehindEOS, true);
assert.deepEqual(arg.extraExtensionParameters, {});
assert.deepEqual(arg.plugins, [{ Id: "BingWebSearch", Source: "BuiltIn" }]);
// Sent on every tier now, not gated to enterprise as #8971 described.
assert.equal(arg.disconnectBehavior, "continue");
});
test("2026-08-21: the message object carries the recaptured rich shape", () => {
const arg = buildChatInvocation({
text: "Say OK in one word.",
traceId: "t",
sessionId: "s",
requestId: "r",
conversationId: "c",
}).arguments[0] as Record<string, unknown>;
const message = arg.message as Record<string, unknown>;
assert.deepEqual(Object.keys(message).sort(), [
"adaptiveCards",
"attachments",
"author",
"clientInfo",
"clientPreferences",
"connectedFederatedConnections",
"entityAnnotationTypes",
"experienceType",
"inputMethod",
"locale",
"locationInfo",
"messageType",
"requestId",
"text",
]);
assert.equal(message.author, "user");
assert.equal(message.messageType, "Chat");
assert.equal(message.requestId, "r");
assert.equal(message.experienceType, "Default");
assert.deepEqual(message.entityAnnotationTypes, [
"People",
"File",
"Event",
"Email",
"TeamsMessage",
]);
assert.equal(message.attachments, null);
assert.deepEqual(message.locationInfo, { timeZone: "UTC", timeZoneOffset: 0 });
assert.deepEqual(message.connectedFederatedConnections, ["dummyId"]);
// Same clientInfo object echoed inside message, per the capture.
assert.deepEqual(message.clientInfo, arg.clientInfo);
});
test("2026-08-21: default tier lists are the recaptured 34-entry optionsSets / 30-entry allowedMessageTypes", () => {
const overrides = resolveChatInvocationOverrides(undefined);
assert.equal(overrides.optionsSets.length, 34);
assert.equal(overrides.allowedMessageTypes.length, 30);
assert.equal(overrides.tone, "Magic");
assert.equal(overrides.disconnectBehavior, "continue");
const optionSets = M365_DEFAULT_OPTION_SETS as readonly string[];
const messageTypes = ALLOWED_MESSAGE_TYPES as readonly string[];
// Entries the 2026-08-21 capture showed that the #10718 lists lacked.
for (const present of [
"cwc_code_interpreter",
"rich_responses",
"async_client_interaction",
"flux_v3_references",
]) {
assert.ok(optionSets.includes(present), `${present} must be in the default option sets`);
}
for (const present of ["InternalSearchQuery", "GeneratedCode", "AuthError", "TriggerPlugin"]) {
assert.ok(messageTypes.includes(present), `${present} must be in allowedMessageTypes`);
}
// Entries the #10718 capture showed and this capture still confirms.
assert.ok(optionSets.includes("cwcfluxgptv"));
assert.ok(messageTypes.includes("EndOfRequest"));
});
// ── refresh_token helpers ──────────────────────────────────────────────────
function fakeJwt(claims: Record<string, unknown>): string {
const b64 = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
return `${b64({ alg: "none" })}.${b64(claims)}.sig`;
}
test("#10718: decodeJwtClaims reads exp/tid without verification; non-JWT returns null", () => {
const claims = decodeJwtClaims(fakeJwt({ exp: 123, tid: "tenant-id", oid: "oid" }));
assert.equal(claims?.exp, 123);
assert.equal(claims?.tid, "tenant-id");
assert.equal(decodeJwtClaims("not.a-jwt"), null);
assert.equal(decodeJwtClaims("opaque-jwe-token.with.five.parts.here.and-more"), null);
});
test("#10718: tokenNeedsRefresh — unreadable/expired/inside-lead needs refresh, fresh does not", () => {
const now = Math.floor(Date.now() / 1000);
assert.equal(tokenNeedsRefresh("opaque"), true);
assert.equal(tokenNeedsRefresh(fakeJwt({ exp: now - 60 })), true);
// Inside the 5-minute lead window.
assert.equal(tokenNeedsRefresh(fakeJwt({ exp: now + M365_REFRESH_LEAD_MS / 1000 - 30 })), true);
assert.equal(tokenNeedsRefresh(fakeJwt({ exp: now + 3600 })), false);
});
test("#10718: refreshM365AccessToken redeems the public client grant and returns rotated tokens", async () => {
const originalFetch = globalThis.fetch;
let capturedUrl = "";
let capturedBody = "";
globalThis.fetch = (async (url: unknown, init?: RequestInit) => {
capturedUrl = String(url);
capturedBody = String(init?.body);
return new Response(
JSON.stringify({
access_token: "NEW-ACCESS",
refresh_token: "ROTATED-REFRESH",
expires_in: 4777,
}),
{ status: 200 }
);
}) as typeof fetch;
try {
const result = await refreshM365AccessToken("OLD-REFRESH", "tenant-id");
assert.ok("accessToken" in result);
assert.equal(result.accessToken, "NEW-ACCESS");
assert.equal(result.refreshToken, "ROTATED-REFRESH");
assert.equal(result.expiresIn, 4777);
assert.match(capturedUrl, /login\.microsoftonline\.com\/tenant-id\/oauth2\/v2\.0\/token/);
assert.match(capturedBody, /grant_type=refresh_token/);
assert.match(capturedBody, new RegExp(`client_id=${M365_OAUTH_CLIENT_ID}`));
assert.match(capturedBody, /refresh_token=OLD-REFRESH/);
} finally {
globalThis.fetch = originalFetch;
}
});
test("#10718: refreshM365AccessToken surfaces AAD errors and network failures as {error}", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ error: "invalid_grant", error_description: "AADSTS700082" }), {
status: 400,
})) as typeof fetch;
const aadError = await refreshM365AccessToken("STALE");
assert.deepEqual(aadError, { error: "invalid_grant" });
globalThis.fetch = (async () => {
throw new Error("ENOTFOUND");
}) as typeof fetch;
const netError = await refreshM365AccessToken("ANY");
assert.deepEqual(netError, { error: "ENOTFOUND" });
globalThis.fetch = originalFetch;
});