Files
openclaw/extensions/msteams/src/token.test.ts
2026-07-12 18:08:26 -07:00

375 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Msteams tests cover token plugin behavior.
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MSTeamsConfig } from "../runtime-api.js";
import { readAccessToken } from "./token-response.js";
import {
hasConfiguredMSTeamsCredentials,
resolveDelegatedAccessToken,
resolveMSTeamsCredentials,
} from "./token.js";
const oauthTokenMocks = vi.hoisted(() => ({
refreshMSTeamsDelegatedTokens: vi.fn(),
}));
vi.mock("./oauth.token.js", () => ({
refreshMSTeamsDelegatedTokens: oauthTokenMocks.refreshMSTeamsDelegatedTokens,
}));
vi.mock("./storage.js", () => ({
resolveMSTeamsStorePath: ({ filename }: { filename: string }) => {
const stateDir = process.env.OPENCLAW_STATE_DIR;
if (!stateDir) {
throw new Error("OPENCLAW_STATE_DIR is required for token tests");
}
return `${stateDir}/${filename}`;
},
}));
vi.mock("./secret-input.js", () => ({
normalizeSecretInputString: (v: unknown) =>
typeof v === "string" && v.trim() ? v.trim() : undefined,
normalizeResolvedSecretInputString: (opts: { value: unknown; path: string }) =>
typeof opts.value === "string" && opts.value.trim() ? opts.value.trim() : undefined,
hasConfiguredSecretInput: (v: unknown) => typeof v === "string" && v.trim().length > 0,
}));
const ENV_KEYS = [
"MSTEAMS_APP_ID",
"MSTEAMS_APP_PASSWORD",
"MSTEAMS_TENANT_ID",
"MSTEAMS_AUTH_TYPE",
"MSTEAMS_CERTIFICATE_PATH",
"MSTEAMS_CERTIFICATE_THUMBPRINT",
"MSTEAMS_USE_MANAGED_IDENTITY",
"MSTEAMS_MANAGED_IDENTITY_CLIENT_ID",
"OPENCLAW_STATE_DIR",
] as const;
let savedEnv: Record<string, string | undefined> = {};
function saveAndClearEnv() {
savedEnv = {};
for (const key of ENV_KEYS) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
}
function restoreEnv() {
for (const key of ENV_KEYS) {
if (savedEnv[key] !== undefined) {
process.env[key] = savedEnv[key];
} else {
delete process.env[key];
}
}
}
describe("token secret credentials", () => {
beforeEach(saveAndClearEnv);
afterEach(restoreEnv);
it("returns true when appId + appPassword + tenantId are provided in config", () => {
const cfg = {
appId: "app-id",
appPassword: "app-pw",
tenantId: "tenant-id",
} satisfies MSTeamsConfig;
expect(hasConfiguredMSTeamsCredentials(cfg)).toBe(true);
});
it("returns false when appPassword is missing", () => {
const cfg = { appId: "app-id", tenantId: "tenant-id" } satisfies MSTeamsConfig;
expect(hasConfiguredMSTeamsCredentials(cfg)).toBe(false);
});
it("returns false when no config is given and no env vars set", () => {
expect(hasConfiguredMSTeamsCredentials(undefined)).toBe(false);
});
it("resolves secret credentials from config", () => {
const cfg = {
appId: "app-id",
appPassword: "app-pw",
tenantId: "tenant-id",
} satisfies MSTeamsConfig;
const result = resolveMSTeamsCredentials(cfg);
expect(result).toEqual({
type: "secret",
appId: "app-id",
appPassword: "app-pw",
tenantId: "tenant-id",
});
});
it("resolves secret credentials from env vars", () => {
process.env.MSTEAMS_APP_ID = "env-app-id";
process.env.MSTEAMS_APP_PASSWORD = "env-app-pw";
process.env.MSTEAMS_TENANT_ID = "env-tenant-id";
const result = resolveMSTeamsCredentials(undefined);
expect(result).toEqual({
type: "secret",
appId: "env-app-id",
appPassword: "env-app-pw",
tenantId: "env-tenant-id",
});
});
it("returns undefined when appPassword is missing", () => {
const cfg = { appId: "app-id", tenantId: "tenant-id" } satisfies MSTeamsConfig;
expect(resolveMSTeamsCredentials(cfg)).toBeUndefined();
});
});
describe("token federated credentials (certificate)", () => {
beforeEach(saveAndClearEnv);
afterEach(restoreEnv);
it("hasConfigured returns true when certificate path is provided", () => {
const cfg = {
appId: "app-id",
tenantId: "tenant-id",
authType: "federated",
certificatePath: "/cert.pem",
} satisfies MSTeamsConfig;
expect(hasConfiguredMSTeamsCredentials(cfg)).toBe(true);
});
it("hasConfigured returns false when neither cert nor MI is provided", () => {
const cfg = {
appId: "app-id",
tenantId: "tenant-id",
authType: "federated",
} satisfies MSTeamsConfig;
expect(hasConfiguredMSTeamsCredentials(cfg)).toBe(false);
});
it("resolves federated credentials with certificate from config", () => {
const cfg = {
appId: "app-id",
tenantId: "tenant-id",
authType: "federated",
certificatePath: "/cert.pem",
certificateThumbprint: "AABBCCDD",
} satisfies MSTeamsConfig;
const result = resolveMSTeamsCredentials(cfg);
expect(result).toEqual({
type: "federated",
appId: "app-id",
tenantId: "tenant-id",
certificatePath: "/cert.pem",
certificateThumbprint: "AABBCCDD",
useManagedIdentity: undefined,
managedIdentityClientId: undefined,
});
});
it("resolves federated credentials from env vars", () => {
process.env.MSTEAMS_AUTH_TYPE = "federated";
process.env.MSTEAMS_APP_ID = "env-app-id";
process.env.MSTEAMS_TENANT_ID = "env-tenant-id";
process.env.MSTEAMS_CERTIFICATE_PATH = "/env/cert.pem";
process.env.MSTEAMS_CERTIFICATE_THUMBPRINT = "EEFF0011";
const result = resolveMSTeamsCredentials(undefined);
expect(result).toEqual({
type: "federated",
appId: "env-app-id",
tenantId: "env-tenant-id",
certificatePath: "/env/cert.pem",
certificateThumbprint: "EEFF0011",
useManagedIdentity: undefined,
managedIdentityClientId: undefined,
});
});
});
describe("token federated credentials (managed identity)", () => {
beforeEach(saveAndClearEnv);
afterEach(restoreEnv);
it("resolves managed identity from config", () => {
const cfg = {
appId: "app-id",
tenantId: "tenant-id",
authType: "federated",
useManagedIdentity: true,
managedIdentityClientId: "mi-client-id",
} satisfies MSTeamsConfig;
const result = resolveMSTeamsCredentials(cfg);
expect(result).toEqual({
type: "federated",
appId: "app-id",
tenantId: "tenant-id",
certificatePath: undefined,
certificateThumbprint: undefined,
useManagedIdentity: true,
managedIdentityClientId: "mi-client-id",
});
});
it("resolves system-assigned managed identity (no clientId)", () => {
const cfg = {
appId: "app-id",
tenantId: "tenant-id",
authType: "federated",
useManagedIdentity: true,
} satisfies MSTeamsConfig;
const result = resolveMSTeamsCredentials(cfg);
expect(result).toEqual({
type: "federated",
appId: "app-id",
tenantId: "tenant-id",
certificatePath: undefined,
certificateThumbprint: undefined,
useManagedIdentity: true,
managedIdentityClientId: undefined,
});
});
it("hasConfigured returns true for managed identity via env", () => {
process.env.MSTEAMS_AUTH_TYPE = "federated";
process.env.MSTEAMS_APP_ID = "env-app-id";
process.env.MSTEAMS_TENANT_ID = "env-tenant-id";
process.env.MSTEAMS_USE_MANAGED_IDENTITY = "true";
expect(hasConfiguredMSTeamsCredentials(undefined)).toBe(true);
});
it("config useManagedIdentity=false overrides env MSTEAMS_USE_MANAGED_IDENTITY=true", () => {
process.env.MSTEAMS_USE_MANAGED_IDENTITY = "true";
const cfg = {
appId: "app-id",
tenantId: "tenant-id",
authType: "federated",
certificatePath: "/cert.pem",
useManagedIdentity: false,
} satisfies MSTeamsConfig;
const result = resolveMSTeamsCredentials(cfg);
expect(result).toEqual({
type: "federated",
appId: "app-id",
tenantId: "tenant-id",
certificatePath: "/cert.pem",
certificateThumbprint: undefined,
useManagedIdentity: undefined,
managedIdentityClientId: undefined,
});
});
});
describe("token backward compatibility", () => {
beforeEach(saveAndClearEnv);
afterEach(restoreEnv);
it("defaults to secret when authType is absent", () => {
const cfg = {
appId: "app-id",
appPassword: "pw",
tenantId: "tenant-id",
} satisfies MSTeamsConfig;
const result = resolveMSTeamsCredentials(cfg);
expect(result).toEqual({
type: "secret",
appId: "app-id",
appPassword: "pw",
tenantId: "tenant-id",
});
});
it("explicit authType=secret behaves same as absent", () => {
const cfg = {
appId: "app-id",
appPassword: "pw",
tenantId: "tenant-id",
authType: "secret",
} satisfies MSTeamsConfig;
const result = resolveMSTeamsCredentials(cfg);
expect(result).toEqual({
type: "secret",
appId: "app-id",
appPassword: "pw",
tenantId: "tenant-id",
});
});
});
describe("resolveDelegatedAccessToken", () => {
let stateDir: string | undefined;
beforeEach(() => {
saveAndClearEnv();
stateDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-msteams-token-"));
process.env.OPENCLAW_STATE_DIR = stateDir;
oauthTokenMocks.refreshMSTeamsDelegatedTokens.mockReset();
});
afterEach(() => {
restoreEnv();
if (stateDir) {
rmSync(stateDir, { recursive: true, force: true });
stateDir = undefined;
}
});
function writeDelegatedTokens(expiresAt: number) {
if (!stateDir) {
throw new Error("missing stateDir");
}
writeFileSync(
path.join(stateDir, "msteams-delegated.json"),
`${JSON.stringify({
accessToken: "stale-access",
refreshToken: "refresh-token",
expiresAt,
scopes: ["User.Read"],
})}\n`,
"utf8",
);
}
it("reuses a valid delegated access token before expiry", async () => {
writeDelegatedTokens(Date.now() + 60_000);
await expect(
resolveDelegatedAccessToken({
tenantId: "tenant",
clientId: "client",
clientSecret: "secret",
}),
).resolves.toBe("stale-access");
expect(oauthTokenMocks.refreshMSTeamsDelegatedTokens).not.toHaveBeenCalled();
});
it("does not reuse delegated tokens with invalid Date-range expiry", async () => {
writeDelegatedTokens(Number.MAX_VALUE);
oauthTokenMocks.refreshMSTeamsDelegatedTokens.mockRejectedValueOnce(new Error("expired"));
await expect(
resolveDelegatedAccessToken({
tenantId: "tenant",
clientId: "client",
clientSecret: "secret",
}),
).resolves.toBeUndefined();
expect(oauthTokenMocks.refreshMSTeamsDelegatedTokens).toHaveBeenCalledOnce();
});
});
describe("readAccessToken", () => {
it("reads string and object token forms", () => {
expect(readAccessToken("abc")).toBe("abc");
expect(readAccessToken({ accessToken: "access-token" })).toBe("access-token");
expect(readAccessToken({ token: "fallback-token" })).toBe("fallback-token");
});
it("returns null for unsupported token payloads", () => {
expect(readAccessToken({ accessToken: 123 })).toBeNull();
expect(readAccessToken({ token: false })).toBeNull();
expect(readAccessToken(null)).toBeNull();
expect(readAccessToken(undefined)).toBeNull();
});
});