mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
ff83e3efe8
Doctor migrations and auth-profile writes open per-agent and shared state databases under the fixture's temporary directory. Clearing the plugin state store or the runtime auth snapshots does not release those handles, so Windows fails the directory removal with EBUSY while Linux unlinks the open files and stays green. Close the cached databases before each removal, matching the ordering the zalouser and zalo fixtures already use.
495 lines
17 KiB
TypeScript
495 lines
17 KiB
TypeScript
// Msteams tests cover doctor contract api plugin behavior.
|
|
import { createHash } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import {
|
|
createPluginStateKeyedStoreForTests,
|
|
resetPluginStateStoreForTests,
|
|
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
|
import type {
|
|
OpenKeyedStoreOptions,
|
|
PluginDoctorStateMigrationContext,
|
|
} from "openclaw/plugin-sdk/runtime-doctor-migrations";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import {
|
|
legacyConfigRules,
|
|
normalizeCompatibilityConfig,
|
|
stateMigrations,
|
|
} from "./doctor-contract-api.js";
|
|
import {
|
|
buildMSTeamsConversationStateKey,
|
|
MSTEAMS_CONVERSATIONS_NAMESPACE,
|
|
type MSTeamsLegacyConversationStoreData,
|
|
} from "./src/conversation-store-state.js";
|
|
import type { StoredConversationReference } from "./src/conversation-store.js";
|
|
import {
|
|
MSTEAMS_DELEGATED_TOKEN_KEY,
|
|
MSTEAMS_DELEGATED_TOKEN_MAX_ENTRIES,
|
|
MSTEAMS_DELEGATED_TOKEN_NAMESPACE,
|
|
} from "./src/delegated-state.js";
|
|
import type { MSTeamsDelegatedTokens } from "./src/oauth.shared.js";
|
|
import {
|
|
buildMSTeamsPollStateKey,
|
|
buildMSTeamsPollVoteBucketKey,
|
|
MSTEAMS_POLL_VOTE_BUCKETS_NAMESPACE,
|
|
MSTEAMS_POLLS_NAMESPACE,
|
|
selectMSTeamsPollVoteBucket,
|
|
type MSTeamsPoll,
|
|
type StoredMSTeamsPoll,
|
|
type StoredMSTeamsPollVoteBucket,
|
|
} from "./src/polls.js";
|
|
import {
|
|
makeMSTeamsSsoTokenStoreKey,
|
|
MSTEAMS_SSO_TOKENS_NAMESPACE,
|
|
type MSTeamsSsoStoredToken,
|
|
} from "./src/sso-token-store.js";
|
|
|
|
function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
|
|
return {
|
|
openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
|
|
return createPluginStateKeyedStoreForTests<T>("msteams", {
|
|
...options,
|
|
env: options.env ?? env,
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
function encodeSessionKey(sessionKey: string): string {
|
|
return Buffer.from(sessionKey, "utf8").toString("base64url");
|
|
}
|
|
|
|
function learningStoreKey(storePath: string, sessionKey: string): string {
|
|
return createHash("sha256").update(`${storePath}\0${sessionKey}`, "utf8").digest("hex");
|
|
}
|
|
|
|
function migrationById(id: string) {
|
|
const migration = stateMigrations.find((entry) => entry.id === id);
|
|
if (!migration) {
|
|
throw new Error(`missing migration ${id}`);
|
|
}
|
|
return migration;
|
|
}
|
|
|
|
describe("msteams doctor state migration", () => {
|
|
let stateDir = "";
|
|
let env: NodeJS.ProcessEnv;
|
|
|
|
beforeEach(async () => {
|
|
resetPluginStateStoreForTests();
|
|
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-doctor-"));
|
|
env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
|
});
|
|
|
|
afterEach(async () => {
|
|
resetPluginStateStoreForTests();
|
|
await fs.rm(stateDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("imports legacy conversations into plugin state", async () => {
|
|
const filePath = path.join(stateDir, "msteams-conversations.json");
|
|
const ref: StoredConversationReference = {
|
|
conversation: { id: "19:conv@thread.tacv2" },
|
|
channelId: "msteams",
|
|
serviceUrl: "https://service.example.com",
|
|
user: { id: "user-1" },
|
|
};
|
|
await fs.writeFile(
|
|
filePath,
|
|
`${JSON.stringify({
|
|
version: 1,
|
|
conversations: {
|
|
"19:conv@thread.tacv2": ref,
|
|
},
|
|
} satisfies MSTeamsLegacyConversationStoreData)}\n`,
|
|
);
|
|
|
|
const migration = migrationById("msteams-conversations-json-to-plugin-state");
|
|
const context = createDoctorContext(env);
|
|
await expect(
|
|
migration.detectLegacyState({
|
|
config: {},
|
|
env,
|
|
stateDir,
|
|
oauthDir: path.join(stateDir, "oauth"),
|
|
context,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
preview: [expect.stringContaining("Microsoft Teams conversations")],
|
|
});
|
|
|
|
const result = await migration.migrateLegacyState({
|
|
config: {},
|
|
env,
|
|
stateDir,
|
|
oauthDir: path.join(stateDir, "oauth"),
|
|
context,
|
|
});
|
|
|
|
expect(result.warnings).toEqual([]);
|
|
expect(result.changes).toEqual([
|
|
expect.stringContaining("Migrated 1 Microsoft Teams conversation entry"),
|
|
expect.stringContaining("Archived Microsoft Teams conversation legacy source"),
|
|
]);
|
|
await expect(fs.access(filePath)).rejects.toThrow();
|
|
await expect(fs.access(`${filePath}.migrated`)).resolves.toBeUndefined();
|
|
const store = context.openPluginStateKeyedStore<StoredConversationReference>({
|
|
namespace: MSTEAMS_CONVERSATIONS_NAMESPACE,
|
|
maxEntries: 2000,
|
|
});
|
|
await expect(
|
|
store.lookup(buildMSTeamsConversationStateKey("19:conv@thread.tacv2")),
|
|
).resolves.toMatchObject({
|
|
conversation: { id: "19:conv@thread.tacv2" },
|
|
user: { id: "user-1" },
|
|
});
|
|
});
|
|
|
|
it("imports legacy polls and vote buckets into plugin state", async () => {
|
|
const filePath = path.join(stateDir, "msteams-polls.json");
|
|
const poll: MSTeamsPoll = {
|
|
id: "poll-legacy",
|
|
question: "Lunch?",
|
|
options: ["Pizza", "Sushi"],
|
|
maxSelections: 1,
|
|
createdAt: new Date().toISOString(),
|
|
votes: {
|
|
"user-legacy": ["0"],
|
|
"user-new": ["1"],
|
|
},
|
|
};
|
|
await fs.writeFile(
|
|
filePath,
|
|
`${JSON.stringify({
|
|
version: 1,
|
|
polls: {
|
|
"poll-legacy": poll,
|
|
},
|
|
})}\n`,
|
|
);
|
|
const context = createDoctorContext(env);
|
|
const voteBucketStore = context.openPluginStateKeyedStore<StoredMSTeamsPollVoteBucket>({
|
|
namespace: MSTEAMS_POLL_VOTE_BUCKETS_NAMESPACE,
|
|
maxEntries: 32_032,
|
|
});
|
|
const legacyBucket = selectMSTeamsPollVoteBucket("poll-legacy", "user-legacy");
|
|
await voteBucketStore.register(buildMSTeamsPollVoteBucketKey("poll-legacy", legacyBucket), {
|
|
pollId: "poll-legacy",
|
|
bucket: legacyBucket,
|
|
votes: { "user-legacy": ["1"] },
|
|
updatedAt: poll.createdAt,
|
|
});
|
|
|
|
const migration = migrationById("msteams-polls-json-to-plugin-state");
|
|
const result = await migration.migrateLegacyState({
|
|
config: {},
|
|
env,
|
|
stateDir,
|
|
oauthDir: path.join(stateDir, "oauth"),
|
|
context,
|
|
});
|
|
|
|
expect(result.warnings).toEqual([]);
|
|
expect(result.changes).toEqual([
|
|
expect.stringContaining("Migrated 1 Microsoft Teams poll entry"),
|
|
expect.stringContaining("Archived Microsoft Teams poll legacy source"),
|
|
]);
|
|
const pollStore = context.openPluginStateKeyedStore<StoredMSTeamsPoll>({
|
|
namespace: MSTEAMS_POLLS_NAMESPACE,
|
|
maxEntries: 2000,
|
|
});
|
|
await expect(pollStore.lookup(buildMSTeamsPollStateKey("poll-legacy"))).resolves.toMatchObject({
|
|
id: "poll-legacy",
|
|
question: "Lunch?",
|
|
});
|
|
const newBucket = selectMSTeamsPollVoteBucket("poll-legacy", "user-new");
|
|
await expect(
|
|
voteBucketStore.lookup(buildMSTeamsPollVoteBucketKey("poll-legacy", legacyBucket)),
|
|
).resolves.toMatchObject({
|
|
votes: { "user-legacy": ["1"] },
|
|
});
|
|
await expect(
|
|
voteBucketStore.lookup(buildMSTeamsPollVoteBucketKey("poll-legacy", newBucket)),
|
|
).resolves.toMatchObject({
|
|
votes: { "user-new": ["1"] },
|
|
});
|
|
await expect(fs.access(`${filePath}.migrated`)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("imports legacy SSO tokens into the existing plugin-state token namespace", async () => {
|
|
const filePath = path.join(stateDir, "msteams-sso-tokens.json");
|
|
const token: MSTeamsSsoStoredToken = {
|
|
connectionName: "conn::alpha",
|
|
userId: "user::one",
|
|
token: "test-token-value",
|
|
updatedAt: "2026-04-10T00:00:00.000Z",
|
|
};
|
|
await fs.writeFile(
|
|
filePath,
|
|
`${JSON.stringify({
|
|
version: 1,
|
|
tokens: {
|
|
"legacy::wrong-key": token,
|
|
},
|
|
})}\n`,
|
|
);
|
|
|
|
const migration = migrationById("msteams-sso-tokens-json-to-plugin-state");
|
|
const context = createDoctorContext(env);
|
|
const result = await migration.migrateLegacyState({
|
|
config: {},
|
|
env,
|
|
stateDir,
|
|
oauthDir: path.join(stateDir, "oauth"),
|
|
context,
|
|
});
|
|
|
|
expect(result.warnings).toEqual([]);
|
|
expect(result.changes).toEqual([
|
|
expect.stringContaining("Migrated 1 Microsoft Teams SSO token entry"),
|
|
expect.stringContaining("Archived Microsoft Teams SSO-token legacy source"),
|
|
]);
|
|
const store = context.openPluginStateKeyedStore<MSTeamsSsoStoredToken>({
|
|
namespace: MSTEAMS_SSO_TOKENS_NAMESPACE,
|
|
maxEntries: 5000,
|
|
});
|
|
await expect(
|
|
store.lookup(makeMSTeamsSsoTokenStoreKey("conn::alpha", "user::one")),
|
|
).resolves.toEqual(token);
|
|
expect(result.changes.join("\n")).not.toContain(token.token);
|
|
expect(result.warnings.join("\n")).not.toContain(token.token);
|
|
await expect(fs.access(`${filePath}.migrated`)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("imports delegated OAuth tokens into plugin state before archiving the file", async () => {
|
|
const filePath = path.join(stateDir, "msteams-delegated.json");
|
|
const token: MSTeamsDelegatedTokens = {
|
|
accessToken: "delegated-access",
|
|
refreshToken: "delegated-refresh",
|
|
expiresAt: 1_800_000_000_000,
|
|
scopes: ["User.Read", "offline_access"],
|
|
userPrincipalName: "user@example.com",
|
|
};
|
|
await fs.writeFile(filePath, JSON.stringify(token));
|
|
const migration = migrationById("msteams-delegated-token-json-to-plugin-state");
|
|
const context = createDoctorContext(env);
|
|
const params = {
|
|
config: {},
|
|
env,
|
|
stateDir,
|
|
oauthDir: path.join(stateDir, "oauth"),
|
|
context,
|
|
};
|
|
|
|
await expect(migration.detectLegacyState(params)).resolves.toEqual({
|
|
preview: [
|
|
`- Microsoft Teams delegated OAuth token -> plugin state (${MSTEAMS_DELEGATED_TOKEN_NAMESPACE})`,
|
|
],
|
|
});
|
|
const result = await migration.migrateLegacyState(params);
|
|
|
|
expect(result.warnings).toEqual([]);
|
|
expect(result.changes).toEqual([
|
|
"Migrated Microsoft Teams delegated OAuth token -> plugin state",
|
|
expect.stringContaining("Archived Microsoft Teams delegated OAuth token legacy source"),
|
|
]);
|
|
const store = context.openPluginStateKeyedStore<MSTeamsDelegatedTokens>({
|
|
namespace: MSTEAMS_DELEGATED_TOKEN_NAMESPACE,
|
|
maxEntries: MSTEAMS_DELEGATED_TOKEN_MAX_ENTRIES,
|
|
overflowPolicy: "reject-new",
|
|
});
|
|
await expect(store.lookup(MSTEAMS_DELEGATED_TOKEN_KEY)).resolves.toEqual(token);
|
|
await expect(fs.access(`${filePath}.migrated`)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("does not register a doctor migration for pending-upload cache files", () => {
|
|
expect(stateMigrations.map((migration) => migration.id)).not.toContain(
|
|
"msteams-pending-uploads-json-to-plugin-state",
|
|
);
|
|
});
|
|
|
|
it("imports legacy feedback learnings into plugin state", async () => {
|
|
const agentStoreTemplate = path.join(stateDir, "agents", "{agentId}", "sessions");
|
|
const mainStorePath = path.join(stateDir, "agents", "main", "sessions");
|
|
const workStorePath = path.join(stateDir, "agents", "work", "sessions");
|
|
const encodedSessionKey = "msteams:user1";
|
|
const encodedSourcePath = path.join(
|
|
mainStorePath,
|
|
`${encodeSessionKey(encodedSessionKey)}.learnings.json`,
|
|
);
|
|
const sanitizedSessionKey = "msteams:channel:19:abc@thread.tacv2";
|
|
const sanitizedSourcePath = path.join(
|
|
workStorePath,
|
|
"msteams_channel_19_abc_thread_tacv2.learnings.json",
|
|
);
|
|
await fs.mkdir(mainStorePath, { recursive: true });
|
|
await fs.mkdir(workStorePath, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(workStorePath, "sessions.json"),
|
|
JSON.stringify({ sessions: { [sanitizedSessionKey]: {} } }),
|
|
);
|
|
await fs.writeFile(encodedSourcePath, JSON.stringify(["Be concise", "Use examples"]));
|
|
await fs.writeFile(sanitizedSourcePath, JSON.stringify(["Prefer cards for channel feedback"]));
|
|
|
|
const migration = migrationById("msteams-feedback-learnings-json-to-plugin-state");
|
|
const context = createDoctorContext(env);
|
|
await context
|
|
.openPluginStateKeyedStore({
|
|
namespace: "feedback-learnings",
|
|
maxEntries: 10_000,
|
|
})
|
|
.register(learningStoreKey(mainStorePath, encodedSessionKey), {
|
|
sessionKey: encodedSessionKey,
|
|
learnings: ["Use examples", "New runtime note"],
|
|
updatedAt: 1900,
|
|
});
|
|
|
|
await expect(
|
|
migration.detectLegacyState({
|
|
config: {
|
|
session: { store: agentStoreTemplate },
|
|
agents: { list: [{ id: "work" }] },
|
|
},
|
|
env,
|
|
stateDir,
|
|
oauthDir: path.join(stateDir, "oauth"),
|
|
context,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
preview: [expect.stringContaining("2 files")],
|
|
});
|
|
|
|
const result = await migration.migrateLegacyState({
|
|
config: {
|
|
session: { store: agentStoreTemplate },
|
|
agents: { list: [{ id: "work" }] },
|
|
},
|
|
env,
|
|
stateDir,
|
|
oauthDir: path.join(stateDir, "oauth"),
|
|
context,
|
|
});
|
|
|
|
expect(result.changes).toEqual([
|
|
expect.stringContaining("Migrated 2 Microsoft Teams feedback-learning entries"),
|
|
expect.stringContaining("Archived Microsoft Teams feedback-learning legacy source"),
|
|
expect.stringContaining("Archived Microsoft Teams feedback-learning legacy source"),
|
|
]);
|
|
expect(result.warnings).toEqual([]);
|
|
await expect(fs.access(encodedSourcePath)).rejects.toThrow();
|
|
await expect(fs.access(sanitizedSourcePath)).rejects.toThrow();
|
|
await expect(fs.access(`${encodedSourcePath}.migrated`)).resolves.toBeUndefined();
|
|
await expect(fs.access(`${sanitizedSourcePath}.migrated`)).resolves.toBeUndefined();
|
|
|
|
const store = context.openPluginStateKeyedStore({
|
|
namespace: "feedback-learnings",
|
|
maxEntries: 10_000,
|
|
});
|
|
await expect(
|
|
store.lookup(learningStoreKey(mainStorePath, encodedSessionKey)),
|
|
).resolves.toMatchObject({
|
|
sessionKey: encodedSessionKey,
|
|
learnings: ["Be concise", "Use examples", "New runtime note"],
|
|
});
|
|
await expect(
|
|
store.lookup(learningStoreKey(workStorePath, sanitizedSessionKey)),
|
|
).resolves.toMatchObject({
|
|
sessionKey: sanitizedSessionKey,
|
|
learnings: ["Prefer cards for channel feedback"],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("msteams streaming legacy config rules", () => {
|
|
const rule = legacyConfigRules.find((entry) => entry.message.includes("chunkMode"));
|
|
|
|
it("matches flat streaming aliases", () => {
|
|
expect(rule?.match?.({ blockStreaming: true }, {})).toBe(true);
|
|
expect(rule?.match?.({ chunkMode: "newline" }, {})).toBe(true);
|
|
expect(rule?.match?.({ streamMode: "block" }, {})).toBe(true);
|
|
expect(rule?.match?.({ streaming: { mode: "partial" } }, {})).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("msteams normalizeCompatibilityConfig streaming aliases", () => {
|
|
function msteamsConfig(entry: Record<string, unknown>) {
|
|
return { channels: { msteams: entry } } as never;
|
|
}
|
|
|
|
it("moves flat aliases into the nested streaming shape", () => {
|
|
const result = normalizeCompatibilityConfig({
|
|
cfg: msteamsConfig({
|
|
streamMode: "block",
|
|
chunkMode: "newline",
|
|
blockStreaming: true,
|
|
blockStreamingCoalesce: { idleMs: 250 },
|
|
}),
|
|
});
|
|
|
|
const msteams = result.config.channels?.msteams as Record<string, unknown>;
|
|
expect(msteams.streaming).toEqual({
|
|
mode: "block",
|
|
chunkMode: "newline",
|
|
block: { enabled: true, coalesce: { idleMs: 250 } },
|
|
});
|
|
expect(msteams.streamMode).toBeUndefined();
|
|
expect(msteams.chunkMode).toBeUndefined();
|
|
expect(msteams.blockStreaming).toBeUndefined();
|
|
expect(msteams.blockStreamingCoalesce).toBeUndefined();
|
|
for (const change of [
|
|
"Moved channels.msteams.streamMode → channels.msteams.streaming.mode (block).",
|
|
"Moved channels.msteams.chunkMode → channels.msteams.streaming.chunkMode.",
|
|
"Moved channels.msteams.blockStreaming → channels.msteams.streaming.block.enabled.",
|
|
"Moved channels.msteams.blockStreamingCoalesce → channels.msteams.streaming.block.coalesce.",
|
|
]) {
|
|
expect(result.changes).toContain(change);
|
|
}
|
|
});
|
|
|
|
it("removes a conflicting streamMode when streaming.mode is already set", () => {
|
|
const result = normalizeCompatibilityConfig({
|
|
cfg: msteamsConfig({
|
|
streamMode: "off",
|
|
streaming: { mode: "block" },
|
|
}),
|
|
});
|
|
|
|
const msteams = result.config.channels?.msteams as Record<string, unknown>;
|
|
expect(msteams.streamMode).toBeUndefined();
|
|
expect(msteams.streaming).toEqual({ mode: "block" });
|
|
// Doctor drops mutations without change messages, so the conflict removal
|
|
// must be reported or the invalid flat key would never be persisted away.
|
|
expect(result.changes).toEqual([
|
|
"Removed channels.msteams.streamMode (channels.msteams.streaming.mode already set).",
|
|
]);
|
|
});
|
|
|
|
it("removes flat aliases when the nested value is already set", () => {
|
|
const result = normalizeCompatibilityConfig({
|
|
cfg: msteamsConfig({
|
|
blockStreaming: false,
|
|
streaming: { block: { enabled: true } },
|
|
}),
|
|
});
|
|
|
|
const msteams = result.config.channels?.msteams as Record<string, unknown>;
|
|
expect(msteams.streaming).toEqual({ block: { enabled: true } });
|
|
expect(msteams.blockStreaming).toBeUndefined();
|
|
expect(result.changes).toContain(
|
|
"Removed channels.msteams.blockStreaming (channels.msteams.streaming.block.enabled already set).",
|
|
);
|
|
});
|
|
|
|
it("is idempotent: a second run reports no changes", () => {
|
|
const first = normalizeCompatibilityConfig({
|
|
cfg: msteamsConfig({ streamMode: "block", chunkMode: "newline" }),
|
|
});
|
|
expect(first.changes.length).toBeGreaterThan(0);
|
|
|
|
const second = normalizeCompatibilityConfig({ cfg: first.config });
|
|
expect(second.changes).toEqual([]);
|
|
expect(second.config).toBe(first.config);
|
|
});
|
|
});
|