|
1 | | -import fs from "node:fs"; |
| 1 | +import fs from "node:fs/promises"; |
2 | 2 | import os from "node:os"; |
3 | 3 | import path from "node:path"; |
4 | | -import { afterEach, describe, expect, it } from "vitest"; |
5 | | -import { detectQQBotLegacyStateMigrations } from "./state-migrations.js"; |
| 4 | +import { |
| 5 | + createPluginStateKeyedStoreForTests, |
| 6 | + resetPluginStateStoreForTests, |
| 7 | +} from "openclaw/plugin-sdk/plugin-state-test-runtime"; |
| 8 | +import type { |
| 9 | + OpenKeyedStoreOptions, |
| 10 | + PluginDoctorStateMigrationContext, |
| 11 | + PluginStateKeyedStore, |
| 12 | +} from "openclaw/plugin-sdk/runtime-doctor"; |
| 13 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
| 14 | +import { stateMigrations } from "../doctor-contract-api.js"; |
| 15 | +import { buildQQBotStateKey } from "./engine/utils/state-keys.js"; |
| 16 | + |
| 17 | +type CredentialBackup = { |
| 18 | + accountId: string; |
| 19 | + appId: string; |
| 20 | + clientSecret: string; |
| 21 | + savedAt: string; |
| 22 | +}; |
6 | 23 |
|
7 | 24 | const createdDirs: string[] = []; |
8 | 25 |
|
9 | | -function createTempDir(prefix: string): string { |
10 | | - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); |
| 26 | +async function createTempDir(prefix: string): Promise<string> { |
| 27 | + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); |
11 | 28 | createdDirs.push(dir); |
12 | 29 | return dir; |
13 | 30 | } |
14 | 31 |
|
15 | | -function writeJson(filePath: string, value: unknown): void { |
16 | | - fs.mkdirSync(path.dirname(filePath), { recursive: true }); |
17 | | - fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); |
| 32 | +async function writeJson(filePath: string, value: unknown): Promise<void> { |
| 33 | + await fs.mkdir(path.dirname(filePath), { recursive: true }); |
| 34 | + await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); |
| 35 | +} |
| 36 | + |
| 37 | +function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext { |
| 38 | + return { |
| 39 | + openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) { |
| 40 | + return createPluginStateKeyedStoreForTests<T>("qqbot", { |
| 41 | + ...options, |
| 42 | + env: options.env ?? env, |
| 43 | + }); |
| 44 | + }, |
| 45 | + }; |
| 46 | +} |
| 47 | + |
| 48 | +function createEvictingDoctorContext(params: { |
| 49 | + values: Map<string, CredentialBackup>; |
| 50 | + evictedKey: string; |
| 51 | +}): PluginDoctorStateMigrationContext { |
| 52 | + let shouldEvict = true; |
| 53 | + const store: PluginStateKeyedStore<CredentialBackup> = { |
| 54 | + async register(key, value) { |
| 55 | + params.values.set(key, value); |
| 56 | + if (shouldEvict) { |
| 57 | + shouldEvict = false; |
| 58 | + params.values.delete(params.evictedKey); |
| 59 | + } |
| 60 | + }, |
| 61 | + async registerIfAbsent(key, value) { |
| 62 | + if (params.values.has(key)) { |
| 63 | + return false; |
| 64 | + } |
| 65 | + await store.register(key, value); |
| 66 | + return true; |
| 67 | + }, |
| 68 | + async lookup(key) { |
| 69 | + return params.values.get(key); |
| 70 | + }, |
| 71 | + async consume(key) { |
| 72 | + const value = params.values.get(key); |
| 73 | + params.values.delete(key); |
| 74 | + return value; |
| 75 | + }, |
| 76 | + async delete(key) { |
| 77 | + return params.values.delete(key); |
| 78 | + }, |
| 79 | + async entries() { |
| 80 | + return [...params.values].map(([key, value]) => ({ key, value, createdAt: 0 })); |
| 81 | + }, |
| 82 | + async clear() { |
| 83 | + params.values.clear(); |
| 84 | + }, |
| 85 | + }; |
| 86 | + return { |
| 87 | + openPluginStateKeyedStore<T>() { |
| 88 | + return store as unknown as PluginStateKeyedStore<T>; |
| 89 | + }, |
| 90 | + }; |
18 | 91 | } |
19 | 92 |
|
20 | | -describe("qqbot state migrations", () => { |
21 | | - afterEach(() => { |
| 93 | +describe("qqbot doctor state migration", () => { |
| 94 | + let stateDir = ""; |
| 95 | + let env: NodeJS.ProcessEnv; |
| 96 | + |
| 97 | + beforeEach(async () => { |
| 98 | + resetPluginStateStoreForTests(); |
| 99 | + stateDir = await createTempDir("qqbot-state-"); |
| 100 | + env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; |
| 101 | + }); |
| 102 | + |
| 103 | + afterEach(async () => { |
| 104 | + resetPluginStateStoreForTests(); |
22 | 105 | for (const dir of createdDirs.splice(0)) { |
23 | | - fs.rmSync(dir, { recursive: true, force: true }); |
| 106 | + await fs.rm(dir, { recursive: true, force: true }); |
24 | 107 | } |
25 | 108 | }); |
26 | 109 |
|
27 | | - it("detects credential backups as doctor-owned plugin-state imports", async () => { |
28 | | - const stateDir = createTempDir("qqbot-state-"); |
| 110 | + function migrationParams() { |
| 111 | + return { |
| 112 | + config: {}, |
| 113 | + env, |
| 114 | + stateDir, |
| 115 | + oauthDir: path.join(stateDir, "oauth"), |
| 116 | + context: createDoctorContext(env), |
| 117 | + }; |
| 118 | + } |
| 119 | + |
| 120 | + it("imports an active-state credential backup and archives the source", async () => { |
29 | 121 | const sourcePath = path.join(stateDir, "qqbot", "data", "credential-backup-default.json"); |
30 | | - writeJson(sourcePath, { |
| 122 | + const backup: CredentialBackup = { |
31 | 123 | accountId: "default", |
32 | 124 | appId: "app-1", |
33 | 125 | clientSecret: "secret-1", |
34 | 126 | savedAt: "2026-06-02T00:00:00.000Z", |
35 | | - }); |
| 127 | + }; |
| 128 | + await writeJson(sourcePath, backup); |
36 | 129 |
|
37 | | - const plans = detectQQBotLegacyStateMigrations({ env: {}, stateDir }); |
38 | | - |
39 | | - expect(plans).toHaveLength(1); |
40 | | - const [plan] = plans; |
41 | | - expect(plan).toMatchObject({ |
42 | | - kind: "plugin-state-import", |
43 | | - label: "QQBot credential backup", |
44 | | - sourcePath, |
45 | | - pluginId: "qqbot", |
46 | | - namespace: "credential-backups", |
47 | | - cleanupSource: "rename", |
| 130 | + const migration = stateMigrations[0]; |
| 131 | + await expect(migration.detectLegacyState(migrationParams())).resolves.toMatchObject({ |
| 132 | + preview: [expect.stringContaining("QQBot credential backups: 1 file")], |
48 | 133 | }); |
49 | | - if (plan?.kind !== "plugin-state-import") { |
50 | | - throw new Error("expected plugin-state-import plan"); |
51 | | - } |
52 | | - const [entry] = await plan.readEntries(); |
53 | | - expect(entry).toMatchObject({ |
54 | | - key: expect.stringMatching(/^[a-f0-9]{64}$/), |
55 | | - value: { |
56 | | - accountId: "default", |
57 | | - appId: "app-1", |
58 | | - clientSecret: "secret-1", |
59 | | - savedAt: "2026-06-02T00:00:00.000Z", |
60 | | - }, |
| 134 | + await expect(migration.migrateLegacyState(migrationParams())).resolves.toEqual({ |
| 135 | + changes: [ |
| 136 | + "Migrated 1 QQBot credential backup -> plugin state", |
| 137 | + expect.stringContaining("Archived QQBot credential backup legacy source"), |
| 138 | + ], |
| 139 | + warnings: [], |
61 | 140 | }); |
| 141 | + |
| 142 | + await expect(fs.access(sourcePath)).rejects.toThrow(); |
| 143 | + await expect(fs.access(`${sourcePath}.migrated`)).resolves.toBeUndefined(); |
| 144 | + if (process.platform !== "win32") { |
| 145 | + expect((await fs.stat(`${sourcePath}.migrated`)).mode & 0o777).toBe(0o600); |
| 146 | + } |
| 147 | + await expect( |
| 148 | + createDoctorContext(env) |
| 149 | + .openPluginStateKeyedStore<CredentialBackup>({ |
| 150 | + namespace: "credential-backups", |
| 151 | + maxEntries: 1000, |
| 152 | + }) |
| 153 | + .lookup(buildQQBotStateKey("credential-backup", "default")), |
| 154 | + ).resolves.toEqual(backup); |
62 | 155 | }); |
63 | 156 |
|
64 | | - it("keeps per-account credential backups ahead of legacy single-file backups", async () => { |
65 | | - const stateDir = createTempDir("qqbot-state-"); |
| 157 | + it("prefers per-account backups over the legacy singleton", async () => { |
66 | 158 | const dataDir = path.join(stateDir, "qqbot", "data"); |
67 | | - writeJson(path.join(dataDir, "credential-backup.json"), { |
| 159 | + const singlePath = path.join(dataDir, "credential-backup.json"); |
| 160 | + const accountPath = path.join(dataDir, "credential-backup-default.json"); |
| 161 | + await writeJson(singlePath, { |
68 | 162 | accountId: "default", |
69 | 163 | appId: "stale-app", |
70 | 164 | clientSecret: "stale-secret", |
71 | 165 | savedAt: "2026-06-01T00:00:00.000Z", |
72 | 166 | }); |
73 | | - writeJson(path.join(dataDir, "credential-backup-default.json"), { |
| 167 | + await writeJson(accountPath, { |
74 | 168 | accountId: "default", |
75 | 169 | appId: "current-app", |
76 | 170 | clientSecret: "current-secret", |
77 | 171 | savedAt: "2026-06-02T00:00:00.000Z", |
78 | 172 | }); |
79 | 173 |
|
80 | | - const plans = detectQQBotLegacyStateMigrations({ env: {}, stateDir }); |
| 174 | + const result = await stateMigrations[0].migrateLegacyState(migrationParams()); |
81 | 175 |
|
82 | | - expect(plans).toHaveLength(2); |
83 | | - const [firstPlan] = plans; |
84 | | - if (firstPlan?.kind !== "plugin-state-import") { |
85 | | - throw new Error("expected plugin-state-import plan"); |
86 | | - } |
87 | | - const [entry] = await firstPlan.readEntries(); |
88 | | - expect(entry?.value).toMatchObject({ |
89 | | - accountId: "default", |
90 | | - appId: "current-app", |
91 | | - clientSecret: "current-secret", |
92 | | - }); |
| 176 | + expect(result.warnings).toEqual([]); |
| 177 | + await expect( |
| 178 | + createDoctorContext(env) |
| 179 | + .openPluginStateKeyedStore<CredentialBackup>({ |
| 180 | + namespace: "credential-backups", |
| 181 | + maxEntries: 1000, |
| 182 | + }) |
| 183 | + .lookup(buildQQBotStateKey("credential-backup", "default")), |
| 184 | + ).resolves.toMatchObject({ appId: "current-app", clientSecret: "current-secret" }); |
| 185 | + await expect(fs.access(`${singlePath}.migrated`)).resolves.toBeUndefined(); |
| 186 | + await expect(fs.access(`${accountPath}.migrated`)).resolves.toBeUndefined(); |
93 | 187 | }); |
94 | 188 |
|
95 | | - it("does not trust mismatched per-account backup filenames", () => { |
96 | | - const stateDir = createTempDir("qqbot-state-"); |
97 | | - const dataDir = path.join(stateDir, "qqbot", "data"); |
98 | | - writeJson(path.join(dataDir, "credential-backup-other.json"), { |
| 189 | + it("ignores mismatched per-account backup filenames", async () => { |
| 190 | + await writeJson(path.join(stateDir, "qqbot", "data", "credential-backup-other.json"), { |
99 | 191 | accountId: "default", |
100 | 192 | appId: "wrong-app", |
101 | 193 | clientSecret: "wrong-secret", |
102 | 194 | savedAt: "2026-06-02T00:00:00.000Z", |
103 | 195 | }); |
104 | 196 |
|
105 | | - expect(detectQQBotLegacyStateMigrations({ env: {}, stateDir })).toEqual([]); |
| 197 | + await expect(stateMigrations[0].detectLegacyState(migrationParams())).resolves.toBeNull(); |
| 198 | + }); |
| 199 | + |
| 200 | + it("does not scan credential backups outside the active state directory", async () => { |
| 201 | + const homeDir = await createTempDir("qqbot-home-"); |
| 202 | + env.HOME = homeDir; |
| 203 | + await writeJson( |
| 204 | + path.join(homeDir, ".openclaw", "qqbot", "data", "credential-backup-default.json"), |
| 205 | + { |
| 206 | + accountId: "default", |
| 207 | + appId: "other-state-app", |
| 208 | + clientSecret: "other-state-secret", |
| 209 | + savedAt: "2026-06-02T00:00:00.000Z", |
| 210 | + }, |
| 211 | + ); |
| 212 | + |
| 213 | + await expect(stateMigrations[0].detectLegacyState(migrationParams())).resolves.toBeNull(); |
| 214 | + }); |
| 215 | + |
| 216 | + it("restores credential state and preserves sources when plugin capacity evicts a row", async () => { |
| 217 | + const sourcePath = path.join(stateDir, "qqbot", "data", "credential-backup-new.json"); |
| 218 | + await writeJson(sourcePath, { |
| 219 | + accountId: "new", |
| 220 | + appId: "new-app", |
| 221 | + clientSecret: "new-secret", |
| 222 | + savedAt: "2026-06-02T00:00:00.000Z", |
| 223 | + }); |
| 224 | + const existingKey = buildQQBotStateKey("credential-backup", "existing"); |
| 225 | + const incomingKey = buildQQBotStateKey("credential-backup", "new"); |
| 226 | + const existingBackup: CredentialBackup = { |
| 227 | + accountId: "existing", |
| 228 | + appId: "existing-app", |
| 229 | + clientSecret: "existing-secret", |
| 230 | + savedAt: "2026-06-01T00:00:00.000Z", |
| 231 | + }; |
| 232 | + const values = new Map([[existingKey, existingBackup]]); |
| 233 | + const params = migrationParams(); |
| 234 | + params.context = createEvictingDoctorContext({ values, evictedKey: existingKey }); |
| 235 | + |
| 236 | + const result = await stateMigrations[0].migrateLegacyState(params); |
| 237 | + |
| 238 | + expect(result.changes).toEqual([]); |
| 239 | + expect(result.warnings).toEqual([expect.stringContaining("plugin state capacity evicted")]); |
| 240 | + expect(values).toEqual(new Map([[existingKey, existingBackup]])); |
| 241 | + expect(values.has(incomingKey)).toBe(false); |
| 242 | + await expect(fs.access(sourcePath)).resolves.toBeUndefined(); |
| 243 | + await expect(fs.access(`${sourcePath}.migrated`)).rejects.toThrow(); |
106 | 244 | }); |
107 | 245 |
|
108 | | - it("does not migrate QQBot runtime caches", () => { |
109 | | - const stateDir = createTempDir("qqbot-state-"); |
110 | | - const homeDir = createTempDir("qqbot-home-"); |
111 | | - writeJson(path.join(homeDir, ".openclaw", "qqbot", "sessions", "session-default.json"), { |
| 246 | + it("does not migrate QQBot runtime caches", async () => { |
| 247 | + await writeJson(path.join(stateDir, "qqbot", "sessions", "session-default.json"), { |
112 | 248 | sessionId: "session-1", |
113 | 249 | }); |
114 | | - writeJson(path.join(homeDir, ".openclaw", "qqbot", "data", "known-users.json"), []); |
115 | | - fs.mkdirSync(path.join(homeDir, ".openclaw", "qqbot", "data"), { recursive: true }); |
116 | | - fs.writeFileSync( |
117 | | - path.join(homeDir, ".openclaw", "qqbot", "data", "ref-index.jsonl"), |
118 | | - `${JSON.stringify({ k: "ref-1", v: {}, t: Date.now() })}\n`, |
119 | | - ); |
| 250 | + await writeJson(path.join(stateDir, "qqbot", "data", "known-users.json"), []); |
| 251 | + await fs.writeFile(path.join(stateDir, "qqbot", "data", "ref-index.jsonl"), "{}\n"); |
120 | 252 |
|
121 | | - expect(detectQQBotLegacyStateMigrations({ env: { HOME: homeDir }, stateDir })).toEqual([]); |
| 253 | + await expect(stateMigrations[0].detectLegacyState(migrationParams())).resolves.toBeNull(); |
122 | 254 | }); |
123 | 255 | }); |
0 commit comments