@chainlesschain/personal-data-hub 0.2.0 → 0.2.2
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/__tests__/adapters/ai-chat-cookie-capture-spec.test.js +211 -0
- package/__tests__/adapters/ai-chat-health-checker.test.js +262 -0
- package/__tests__/adapters/ai-chat-history.test.js +8 -7
- package/__tests__/adapters/ai-chat-vendors.test.js +149 -8
- package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +269 -0
- package/__tests__/adapters/system-data-android-ingest.test.js +144 -0
- package/__tests__/adapters/system-data-android.test.js +387 -0
- package/__tests__/adapters/wechat-bootstrap.test.js +240 -0
- package/__tests__/adapters/wechat-env-probe.test.js +162 -0
- package/__tests__/adapters/wechat-frida-agent.test.js +322 -0
- package/__tests__/adapters/wechat-frida-integration.test.js +149 -0
- package/__tests__/adapters/wechat-frida-key-provider.test.js +188 -0
- package/__tests__/adapters/wechat-md5-key-provider.test.js +101 -0
- package/__tests__/analysis-skills.test.js +147 -0
- package/__tests__/analysis.test.js +329 -1
- package/__tests__/e2e/ai-chat-cross-source-journey.test.js +213 -0
- package/__tests__/e2e/full-user-journey.test.js +188 -0
- package/__tests__/integration/ai-chat-history-registry.test.js +228 -0
- package/__tests__/integration/aichat-wizard-end-to-end.test.js +282 -0
- package/__tests__/integration/cross-adapter-pipelines.test.js +396 -0
- package/__tests__/integration/social-bilibili-pipeline.test.js +261 -0
- package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +390 -0
- package/__tests__/registry.test.js +4 -2
- package/__tests__/social-adapters.test.js +63 -14
- package/__tests__/social-bilibili-snapshot.test.js +278 -0
- package/__tests__/wechat-adapter.test.js +118 -0
- package/lib/adapters/ai-chat-history/ai-chat-adapter.js +55 -16
- package/lib/adapters/ai-chat-history/cookie-capture-spec.js +331 -0
- package/lib/adapters/ai-chat-history/health-checker.js +210 -0
- package/lib/adapters/ai-chat-history/schema-map.js +42 -5
- package/lib/adapters/ai-chat-history/vendor-spec.js +1 -0
- package/lib/adapters/ai-chat-history/vendors/doubao.js +255 -0
- package/lib/adapters/ai-chat-history/wizard-controller.js +473 -0
- package/lib/adapters/alipay-bill/alipay-bill-adapter.js +4 -0
- package/lib/adapters/social-bilibili/adapter.js +500 -0
- package/lib/adapters/social-bilibili/index.js +21 -169
- package/lib/adapters/social-kuaishou/index.js +237 -0
- package/lib/adapters/social-toutiao/index.js +236 -0
- package/lib/adapters/system-data-android/adapter.js +348 -0
- package/lib/adapters/system-data-android/index.js +76 -0
- package/lib/adapters/wechat/bootstrap.js +146 -0
- package/lib/adapters/wechat/content-parser.js +11 -2
- package/lib/adapters/wechat/db-reader.js +88 -10
- package/lib/adapters/wechat/env-probe.js +218 -0
- package/lib/adapters/wechat/frida-agent/loader.js +74 -0
- package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +248 -0
- package/lib/adapters/wechat/index.js +9 -0
- package/lib/adapters/wechat/key-providers/frida-key-provider.js +252 -0
- package/lib/adapters/wechat/key-providers/index.js +22 -0
- package/lib/adapters/wechat/key-providers/key-provider-base.js +44 -0
- package/lib/adapters/wechat/key-providers/md5-key-provider.js +81 -0
- package/lib/adapters/wechat/normalize.js +12 -3
- package/lib/analysis-skills/spending.js +4 -1
- package/lib/analysis.js +191 -2
- package/lib/index.js +16 -0
- package/lib/prompt-builder.js +11 -1
- package/lib/query-parser.js +7 -1
- package/lib/vault.js +77 -0
- package/package.json +8 -1
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Integration test — A8 v0.1 Bilibili snapshot → vault pipeline.
|
|
5
|
+
*
|
|
6
|
+
* Exercises the full chain WITHOUT any real WebView / OkHttp / Android JNI:
|
|
7
|
+
*
|
|
8
|
+
* Synthetic snapshot JSON (4 kinds)
|
|
9
|
+
* ↓
|
|
10
|
+
* AdapterRegistry (real) + LocalVault (real, SQLCipher)
|
|
11
|
+
* ↓
|
|
12
|
+
* adapter.sync({ inputPath }) → _syncViaSnapshot yields raw events
|
|
13
|
+
* ↓
|
|
14
|
+
* registry.putRawEvent → vault.raw_events
|
|
15
|
+
* ↓
|
|
16
|
+
* adapter.normalize(raw) → batch
|
|
17
|
+
* ↓
|
|
18
|
+
* vault.putBatch → events / persons / places / items / topics tables
|
|
19
|
+
*
|
|
20
|
+
* Two scenarios:
|
|
21
|
+
* A. happy path — 4-kind snapshot ingests; vault yields exact counts;
|
|
22
|
+
* KG triples derive; originalId stable across re-sync (idempotency)
|
|
23
|
+
* B. partial snapshot — only history + follow; vault gets correct subset
|
|
24
|
+
*
|
|
25
|
+
* Win note: bs3mc has a known NODE_MODULE_VERSION mismatch on this dev box
|
|
26
|
+
* (Node 22.22.2 ABI v127 vs prebuild ABI v140); test passes on CI Linux
|
|
27
|
+
* which uses the matched prebuild. See memory pdh-plan-a-android-standalone-
|
|
28
|
+
* design §"bs3mc NODE_MODULE_VERSION mismatch".
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
32
|
+
|
|
33
|
+
const fs = require("node:fs");
|
|
34
|
+
const path = require("node:path");
|
|
35
|
+
const os = require("node:os");
|
|
36
|
+
|
|
37
|
+
const {
|
|
38
|
+
LocalVault,
|
|
39
|
+
generateKeyHex,
|
|
40
|
+
AdapterRegistry,
|
|
41
|
+
} = require("../../lib");
|
|
42
|
+
const {
|
|
43
|
+
BilibiliAdapter,
|
|
44
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
45
|
+
} = require("../../lib/adapters/social-bilibili");
|
|
46
|
+
|
|
47
|
+
function makeRig() {
|
|
48
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bili-int-"));
|
|
49
|
+
const vault = new LocalVault({ path: path.join(dir, "v.db"), key: generateKeyHex() });
|
|
50
|
+
vault.open();
|
|
51
|
+
const registry = new AdapterRegistry({ vault });
|
|
52
|
+
return { vault, registry, dir };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function cleanup(rig) {
|
|
56
|
+
if (!rig) return;
|
|
57
|
+
try { rig.vault.close(); } catch (_e) { /* noop */ }
|
|
58
|
+
try { fs.rmSync(rig.dir, { recursive: true, force: true }); } catch (_e) { /* noop */ }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeSnapshot(dir, snapshot) {
|
|
62
|
+
const p = path.join(dir, "social-bilibili.json");
|
|
63
|
+
fs.writeFileSync(p, JSON.stringify(snapshot), "utf-8");
|
|
64
|
+
return p;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sampleSnapshot(opts = {}) {
|
|
68
|
+
const include = {
|
|
69
|
+
history: true,
|
|
70
|
+
favourite: true,
|
|
71
|
+
dynamic: true,
|
|
72
|
+
follow: true,
|
|
73
|
+
...opts.include,
|
|
74
|
+
};
|
|
75
|
+
const events = [];
|
|
76
|
+
if (include.history) {
|
|
77
|
+
events.push({
|
|
78
|
+
kind: "history",
|
|
79
|
+
id: "BV1abc",
|
|
80
|
+
capturedAt: 1715000000000,
|
|
81
|
+
title: "Rust 异步学习",
|
|
82
|
+
bvid: "BV1abc",
|
|
83
|
+
avid: 42,
|
|
84
|
+
duration: 600,
|
|
85
|
+
uploader: "技术UP主",
|
|
86
|
+
uploaderMid: 100,
|
|
87
|
+
part: "01 介绍",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (include.favourite) {
|
|
91
|
+
events.push({
|
|
92
|
+
kind: "favourite",
|
|
93
|
+
id: "fav-BV2def",
|
|
94
|
+
capturedAt: 1714000000000,
|
|
95
|
+
title: "前端架构",
|
|
96
|
+
bvid: "BV2def",
|
|
97
|
+
folderName: "学习",
|
|
98
|
+
uploader: "码农UP",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (include.dynamic) {
|
|
102
|
+
events.push({
|
|
103
|
+
kind: "dynamic",
|
|
104
|
+
id: "dyn-99",
|
|
105
|
+
capturedAt: 1713000000000,
|
|
106
|
+
summary: "今天发了一个新视频",
|
|
107
|
+
dynamicType: "av",
|
|
108
|
+
rid: "99",
|
|
109
|
+
authorMid: 200,
|
|
110
|
+
authorName: "我关注的UP",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (include.follow) {
|
|
114
|
+
events.push({
|
|
115
|
+
kind: "follow",
|
|
116
|
+
id: "follow-300",
|
|
117
|
+
capturedAt: 1712000000000,
|
|
118
|
+
mid: 300,
|
|
119
|
+
uname: "美食UP",
|
|
120
|
+
face: "https://i0.hdslb.com/300.jpg",
|
|
121
|
+
sign: "好吃的视频",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
|
|
126
|
+
snapshottedAt: 1716000000000,
|
|
127
|
+
account: { uid: "12345", displayName: "alice" },
|
|
128
|
+
events,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
describe("Integration — A8 Bilibili snapshot → vault end-to-end", () => {
|
|
133
|
+
let rig;
|
|
134
|
+
afterEach(() => { cleanup(rig); rig = null; });
|
|
135
|
+
|
|
136
|
+
it("4 kinds ingest into vault with exact entity counts", async () => {
|
|
137
|
+
rig = makeRig();
|
|
138
|
+
const adapter = new BilibiliAdapter();
|
|
139
|
+
rig.registry.register(adapter);
|
|
140
|
+
|
|
141
|
+
const snapshotPath = writeSnapshot(rig.dir, sampleSnapshot());
|
|
142
|
+
const report = await rig.registry.syncAdapter("social-bilibili", {
|
|
143
|
+
inputPath: snapshotPath,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// 3 events (history + favourite + dynamic) + 1 person (follow) +
|
|
147
|
+
// 2 items (history-video + favourite-video)
|
|
148
|
+
expect(report.status).toBe("ok");
|
|
149
|
+
expect(report.entityCounts.events).toBe(3);
|
|
150
|
+
expect(report.entityCounts.persons).toBe(1);
|
|
151
|
+
expect(report.entityCounts.items).toBe(2);
|
|
152
|
+
|
|
153
|
+
// Vault round-trip
|
|
154
|
+
const events = rig.vault.queryEvents({ limit: 100 });
|
|
155
|
+
expect(events).toHaveLength(3);
|
|
156
|
+
const subtypes = events.map((e) => e.subtype).sort();
|
|
157
|
+
expect(subtypes).toEqual(["browse", "browse", "like"]); // history+dynamic+favourite
|
|
158
|
+
|
|
159
|
+
const persons = rig.vault.queryPersons({ limit: 100 });
|
|
160
|
+
expect(persons).toHaveLength(1);
|
|
161
|
+
expect(persons[0].names[0]).toBe("美食UP");
|
|
162
|
+
|
|
163
|
+
const items = rig.vault.queryItems({ limit: 100 });
|
|
164
|
+
expect(items).toHaveLength(2);
|
|
165
|
+
expect(items.map((i) => i.name).sort()).toEqual(["Rust 异步学习", "前端架构"]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("re-sync is idempotent — same snapshot twice does NOT double entities", async () => {
|
|
169
|
+
rig = makeRig();
|
|
170
|
+
const adapter = new BilibiliAdapter();
|
|
171
|
+
rig.registry.register(adapter);
|
|
172
|
+
const snapshotPath = writeSnapshot(rig.dir, sampleSnapshot());
|
|
173
|
+
|
|
174
|
+
// First sync
|
|
175
|
+
const report1 = await rig.registry.syncAdapter("social-bilibili", {
|
|
176
|
+
inputPath: snapshotPath,
|
|
177
|
+
});
|
|
178
|
+
expect(report1.status).toBe("ok");
|
|
179
|
+
|
|
180
|
+
// Second sync — same snapshot
|
|
181
|
+
const report2 = await rig.registry.syncAdapter("social-bilibili", {
|
|
182
|
+
inputPath: snapshotPath,
|
|
183
|
+
});
|
|
184
|
+
expect(report2.status).toBe("ok");
|
|
185
|
+
|
|
186
|
+
// Stable originalId means re-sync de-dups at raw_events layer.
|
|
187
|
+
// The person/item entities should remain at 1 / 2 respectively
|
|
188
|
+
// because their IDs derive from bvid / mid (stable). Events can
|
|
189
|
+
// legitimately double-write because each "browse" is a separate
|
|
190
|
+
// occurrence — registry doesn't dedup events.
|
|
191
|
+
const persons = rig.vault.queryPersons({ limit: 100 });
|
|
192
|
+
expect(persons).toHaveLength(1);
|
|
193
|
+
|
|
194
|
+
const items = rig.vault.queryItems({ limit: 100 });
|
|
195
|
+
// Items with same bvid produce same ID, so item table stays at 2
|
|
196
|
+
// (UPSERT semantics via primary-key id).
|
|
197
|
+
expect(items).toHaveLength(2);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("partial snapshot (history + follow only) yields exact subset", async () => {
|
|
201
|
+
rig = makeRig();
|
|
202
|
+
const adapter = new BilibiliAdapter();
|
|
203
|
+
rig.registry.register(adapter);
|
|
204
|
+
|
|
205
|
+
const snapshotPath = writeSnapshot(
|
|
206
|
+
rig.dir,
|
|
207
|
+
sampleSnapshot({ include: { favourite: false, dynamic: false } })
|
|
208
|
+
);
|
|
209
|
+
const report = await rig.registry.syncAdapter("social-bilibili", {
|
|
210
|
+
inputPath: snapshotPath,
|
|
211
|
+
});
|
|
212
|
+
expect(report.status).toBe("ok");
|
|
213
|
+
expect(report.entityCounts.events).toBe(1); // history only
|
|
214
|
+
expect(report.entityCounts.persons).toBe(1); // follow
|
|
215
|
+
expect(report.entityCounts.items).toBe(1); // history video
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("empty events array → ok status with 0 entity counts", async () => {
|
|
219
|
+
rig = makeRig();
|
|
220
|
+
const adapter = new BilibiliAdapter();
|
|
221
|
+
rig.registry.register(adapter);
|
|
222
|
+
|
|
223
|
+
const snapshotPath = writeSnapshot(rig.dir, {
|
|
224
|
+
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
|
|
225
|
+
snapshottedAt: Date.now(),
|
|
226
|
+
events: [],
|
|
227
|
+
});
|
|
228
|
+
const report = await rig.registry.syncAdapter("social-bilibili", {
|
|
229
|
+
inputPath: snapshotPath,
|
|
230
|
+
});
|
|
231
|
+
expect(report.status).toBe("ok");
|
|
232
|
+
expect(report.entityCounts.events).toBe(0);
|
|
233
|
+
expect(report.entityCounts.persons).toBe(0);
|
|
234
|
+
expect(report.entityCounts.items).toBe(0);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("schemaVersion mismatch surfaces in SyncReport.error (not silent)", async () => {
|
|
238
|
+
rig = makeRig();
|
|
239
|
+
const adapter = new BilibiliAdapter();
|
|
240
|
+
rig.registry.register(adapter);
|
|
241
|
+
|
|
242
|
+
const snapshotPath = writeSnapshot(rig.dir, {
|
|
243
|
+
schemaVersion: 99, // wrong
|
|
244
|
+
snapshottedAt: Date.now(),
|
|
245
|
+
events: [],
|
|
246
|
+
});
|
|
247
|
+
const report = await rig.registry.syncAdapter("social-bilibili", {
|
|
248
|
+
inputPath: snapshotPath,
|
|
249
|
+
});
|
|
250
|
+
expect(report.status).toBe("error");
|
|
251
|
+
expect(String(report.error)).toMatch(/schemaVersion mismatch/);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("registry queryable by adapter.name after register()", () => {
|
|
255
|
+
rig = makeRig();
|
|
256
|
+
const adapter = new BilibiliAdapter();
|
|
257
|
+
rig.registry.register(adapter);
|
|
258
|
+
expect(rig.registry.has("social-bilibili")).toBe(true);
|
|
259
|
+
expect(rig.registry.list().some((m) => m.name === "social-bilibili")).toBe(true);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeChat Phase 12.6.7-10 end-to-end integration test.
|
|
3
|
+
*
|
|
4
|
+
* Exercises the full chain WITHOUT any real adb / Frida / device:
|
|
5
|
+
*
|
|
6
|
+
* env-probe (injected facts)
|
|
7
|
+
* ↓
|
|
8
|
+
* bootstrap.js (KeyProvider choice + adapter ctor)
|
|
9
|
+
* ↓
|
|
10
|
+
* AdapterRegistry (real, in-memory)
|
|
11
|
+
* ↓
|
|
12
|
+
* wechat-accounts.json persistence (real fs, temp dir)
|
|
13
|
+
* ↓
|
|
14
|
+
* list / unregister flow
|
|
15
|
+
*
|
|
16
|
+
* Three scenarios:
|
|
17
|
+
* A. md5 happy path — pre-WeChat-8 device:
|
|
18
|
+
* probe="md5" → wechatDataPath provided → register OK →
|
|
19
|
+
* registry has "wechat" adapter → persisted row chosenKeyProvider="md5"
|
|
20
|
+
* → unregister → row removed + registry empty
|
|
21
|
+
* B. frida happy path — rooted 8.0+ device:
|
|
22
|
+
* probe="frida" + root + frida-server up → register OK →
|
|
23
|
+
* chosenKeyProvider="frida" → persisted row reflects choice
|
|
24
|
+
* C. unsupported path — 8.0+ without root:
|
|
25
|
+
* probe="unsupported" → bootstrap rejects → no registry change,
|
|
26
|
+
* no row written, ok:false with reasons surfaced
|
|
27
|
+
* D. idempotent re-register — same uin twice:
|
|
28
|
+
* first registration with wechatDataPath A, second with B →
|
|
29
|
+
* single row remains, wechatDataPath=B (replaces, doesn't dupe)
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
"use strict";
|
|
33
|
+
|
|
34
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
35
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
36
|
+
import { tmpdir } from "node:os";
|
|
37
|
+
import { join } from "node:path";
|
|
38
|
+
|
|
39
|
+
const {
|
|
40
|
+
bootstrapWechatAdapter,
|
|
41
|
+
} = require("../../lib/adapters/wechat/bootstrap");
|
|
42
|
+
const { AdapterRegistry } = require("../../lib/registry");
|
|
43
|
+
const { LocalVault } = require("../../lib/vault");
|
|
44
|
+
const { InMemoryKeyProvider } = require("../../lib/key-providers");
|
|
45
|
+
const { generateKeyHex } = require("../../lib/key-providers");
|
|
46
|
+
|
|
47
|
+
// Mirror of the hub-side store helpers so the integration test exercises
|
|
48
|
+
// the same persistence shape both desktop + cli wirings use.
|
|
49
|
+
const { readFileSync, writeFileSync, existsSync } = require("node:fs");
|
|
50
|
+
function loadWechatAccounts(filePath) {
|
|
51
|
+
try {
|
|
52
|
+
if (!existsSync(filePath)) return [];
|
|
53
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
54
|
+
const parsed = JSON.parse(raw);
|
|
55
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
56
|
+
} catch (_e) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function saveWechatAccounts(filePath, accounts) {
|
|
61
|
+
writeFileSync(filePath, JSON.stringify(accounts, null, 2), {
|
|
62
|
+
encoding: "utf-8",
|
|
63
|
+
mode: 0o600,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function mkProbe(overrides = {}) {
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
suggestedKeyProvider: "md5",
|
|
71
|
+
reasons: ["WeChat 7.0.22 (< 8.0) — legacy MD5(IMEI+UIN) path supported"],
|
|
72
|
+
device: { reachable: true, serial: "INTEG_TEST", abi: "arm64-v8a" },
|
|
73
|
+
root: { detected: false, magiskInstalled: false },
|
|
74
|
+
frida: { serverRunning: false, port: null },
|
|
75
|
+
wechat: { installed: true, versionName: "7.0.22", majorVersion: 7 },
|
|
76
|
+
warnings: [],
|
|
77
|
+
...overrides,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Replay the wiring's registerWechatAdapter() inline — mirroring the
|
|
82
|
+
// closure on the real hub object — so the integration test exercises
|
|
83
|
+
// the exact code path the IPC/WS layer drives in production.
|
|
84
|
+
async function registerWechatViaHub({ registry, hubDir, opts }) {
|
|
85
|
+
const r = await bootstrapWechatAdapter(opts);
|
|
86
|
+
if (!r.ok) return r;
|
|
87
|
+
|
|
88
|
+
if (registry.has(r.adapter.name)) registry.unregister(r.adapter.name);
|
|
89
|
+
registry.register(r.adapter);
|
|
90
|
+
|
|
91
|
+
const accountsPath = join(hubDir, "wechat-accounts.json");
|
|
92
|
+
const accounts = loadWechatAccounts(accountsPath);
|
|
93
|
+
const next = accounts.filter(
|
|
94
|
+
(c) => !(c.account && c.account.uin === opts.account.uin),
|
|
95
|
+
);
|
|
96
|
+
next.push({
|
|
97
|
+
account: { uin: opts.account.uin },
|
|
98
|
+
dbPath: opts.dbPath || null,
|
|
99
|
+
wechatDataPath: opts.wechatDataPath || null,
|
|
100
|
+
chosenKeyProvider: r.keyProvider && r.keyProvider.name,
|
|
101
|
+
registeredAt: Date.now(),
|
|
102
|
+
lastSyncAt: null,
|
|
103
|
+
});
|
|
104
|
+
saveWechatAccounts(accountsPath, next);
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
name: r.adapter.name,
|
|
109
|
+
chosenKeyProvider: r.keyProvider.name,
|
|
110
|
+
probe: r.probe,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function unregisterWechatViaHub({ registry, hubDir, uin }) {
|
|
115
|
+
const accountsPath = join(hubDir, "wechat-accounts.json");
|
|
116
|
+
const accounts = loadWechatAccounts(accountsPath);
|
|
117
|
+
const target = accounts.find((c) => c.account && c.account.uin === uin);
|
|
118
|
+
const next = accounts.filter(
|
|
119
|
+
(c) => !(c.account && c.account.uin === uin),
|
|
120
|
+
);
|
|
121
|
+
saveWechatAccounts(accountsPath, next);
|
|
122
|
+
if (target && registry.has("wechat")) registry.unregister("wechat");
|
|
123
|
+
return { ok: true, removed: !!target, uin };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function listWechatViaHub({ hubDir }) {
|
|
127
|
+
return loadWechatAccounts(join(hubDir, "wechat-accounts.json")).map((row) => ({
|
|
128
|
+
uin: row.account ? row.account.uin : null,
|
|
129
|
+
dbPath: row.dbPath || null,
|
|
130
|
+
hasWechatDataPath: !!row.wechatDataPath,
|
|
131
|
+
chosenKeyProvider: row.chosenKeyProvider || null,
|
|
132
|
+
registeredAt: row.registeredAt || null,
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
describe("WeChat Phase 12.6.7-10 — end-to-end integration", () => {
|
|
137
|
+
let hubDir;
|
|
138
|
+
let dataDir;
|
|
139
|
+
let registry;
|
|
140
|
+
|
|
141
|
+
beforeEach(() => {
|
|
142
|
+
hubDir = mkdtempSync(join(tmpdir(), "pdh-wechat-integ-"));
|
|
143
|
+
dataDir = mkdtempSync(join(tmpdir(), "pdh-wechat-data-"));
|
|
144
|
+
|
|
145
|
+
// A real registry without vault/sinks — we don't sync, just
|
|
146
|
+
// register/unregister. Phase 12.6.7 boundary: bootstrap doesn't
|
|
147
|
+
// touch the registry, the wiring does (replicated above).
|
|
148
|
+
const vault = new LocalVault({
|
|
149
|
+
path: join(hubDir, "vault.db"),
|
|
150
|
+
key: generateKeyHex(),
|
|
151
|
+
});
|
|
152
|
+
vault.open();
|
|
153
|
+
registry = new AdapterRegistry({ vault });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
afterEach(() => {
|
|
157
|
+
try { rmSync(hubDir, { recursive: true, force: true }); } catch (_e) {}
|
|
158
|
+
try { rmSync(dataDir, { recursive: true, force: true }); } catch (_e) {}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("A. md5 happy path — pre-WeChat-8 device", () => {
|
|
162
|
+
it("probe → register → adapter in registry + row persisted with md5 provider", async () => {
|
|
163
|
+
const r = await registerWechatViaHub({
|
|
164
|
+
registry,
|
|
165
|
+
hubDir,
|
|
166
|
+
opts: {
|
|
167
|
+
account: { uin: "1234567890" },
|
|
168
|
+
wechatDataPath: dataDir,
|
|
169
|
+
_probe: mkProbe(),
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Bootstrap chain succeeded
|
|
174
|
+
expect(r.ok).toBe(true);
|
|
175
|
+
expect(r.chosenKeyProvider).toBe("md5");
|
|
176
|
+
expect(r.name).toBe("wechat");
|
|
177
|
+
expect(r.probe.suggestedKeyProvider).toBe("md5");
|
|
178
|
+
|
|
179
|
+
// Registry picked up the adapter
|
|
180
|
+
expect(registry.has("wechat")).toBe(true);
|
|
181
|
+
const adapter = registry.get("wechat");
|
|
182
|
+
expect(adapter.account.uin).toBe("1234567890");
|
|
183
|
+
|
|
184
|
+
// Persistence reflects choice
|
|
185
|
+
const list = listWechatViaHub({ hubDir });
|
|
186
|
+
expect(list).toHaveLength(1);
|
|
187
|
+
expect(list[0]).toMatchObject({
|
|
188
|
+
uin: "1234567890",
|
|
189
|
+
chosenKeyProvider: "md5",
|
|
190
|
+
hasWechatDataPath: true,
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("unregister removes row + drops registry entry", async () => {
|
|
195
|
+
await registerWechatViaHub({
|
|
196
|
+
registry,
|
|
197
|
+
hubDir,
|
|
198
|
+
opts: {
|
|
199
|
+
account: { uin: "1234567890" },
|
|
200
|
+
wechatDataPath: dataDir,
|
|
201
|
+
_probe: mkProbe(),
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
expect(registry.has("wechat")).toBe(true);
|
|
205
|
+
|
|
206
|
+
const ur = await unregisterWechatViaHub({
|
|
207
|
+
registry,
|
|
208
|
+
hubDir,
|
|
209
|
+
uin: "1234567890",
|
|
210
|
+
});
|
|
211
|
+
expect(ur).toMatchObject({ ok: true, removed: true });
|
|
212
|
+
expect(registry.has("wechat")).toBe(false);
|
|
213
|
+
expect(listWechatViaHub({ hubDir })).toEqual([]);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("B. frida happy path — rooted 8.0+ device", () => {
|
|
218
|
+
it("probe='frida' + root yields FridaKeyProvider in persisted row", async () => {
|
|
219
|
+
const r = await registerWechatViaHub({
|
|
220
|
+
registry,
|
|
221
|
+
hubDir,
|
|
222
|
+
opts: {
|
|
223
|
+
account: { uin: "wxid_alice" },
|
|
224
|
+
_probe: mkProbe({
|
|
225
|
+
suggestedKeyProvider: "frida",
|
|
226
|
+
wechat: { installed: true, versionName: "8.0.50", majorVersion: 8 },
|
|
227
|
+
root: { detected: true, magiskInstalled: true },
|
|
228
|
+
frida: { serverRunning: true, port: 27042 },
|
|
229
|
+
reasons: ["WeChat 8.0.50 — Frida hook on libwcdb.so"],
|
|
230
|
+
}),
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
expect(r.ok).toBe(true);
|
|
234
|
+
expect(r.chosenKeyProvider).toBe("frida");
|
|
235
|
+
expect(registry.has("wechat")).toBe(true);
|
|
236
|
+
|
|
237
|
+
const list = listWechatViaHub({ hubDir });
|
|
238
|
+
expect(list[0].chosenKeyProvider).toBe("frida");
|
|
239
|
+
// Frida path doesn't require wechatDataPath
|
|
240
|
+
expect(list[0].hasWechatDataPath).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("C. unsupported path", () => {
|
|
245
|
+
it("8.0+ without root → no registry change, no row, ok:false with reasons", async () => {
|
|
246
|
+
const r = await registerWechatViaHub({
|
|
247
|
+
registry,
|
|
248
|
+
hubDir,
|
|
249
|
+
opts: {
|
|
250
|
+
account: { uin: "wxid_bob" },
|
|
251
|
+
_probe: mkProbe({
|
|
252
|
+
ok: false,
|
|
253
|
+
suggestedKeyProvider: "unsupported",
|
|
254
|
+
reasons: [
|
|
255
|
+
"WeChat 8.0.50 requires root for SQLCipher key extraction",
|
|
256
|
+
],
|
|
257
|
+
wechat: { installed: true, versionName: "8.0.50", majorVersion: 8 },
|
|
258
|
+
root: { detected: false, magiskInstalled: false },
|
|
259
|
+
}),
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
expect(r.ok).toBe(false);
|
|
263
|
+
expect(r.reason).toBe("ENV_UNSUPPORTED");
|
|
264
|
+
expect(r.probe.reasons.join(" ")).toMatch(/requires root/);
|
|
265
|
+
|
|
266
|
+
expect(registry.has("wechat")).toBe(false);
|
|
267
|
+
expect(listWechatViaHub({ hubDir })).toEqual([]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("md5 path missing wechatDataPath → ok:false MD5_NEEDS_WECHAT_DATA_PATH", async () => {
|
|
271
|
+
const r = await registerWechatViaHub({
|
|
272
|
+
registry,
|
|
273
|
+
hubDir,
|
|
274
|
+
opts: {
|
|
275
|
+
account: { uin: "1234567890" },
|
|
276
|
+
_probe: mkProbe(),
|
|
277
|
+
// wechatDataPath intentionally omitted
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
expect(r.ok).toBe(false);
|
|
281
|
+
expect(r.reason).toBe("MD5_NEEDS_WECHAT_DATA_PATH");
|
|
282
|
+
expect(registry.has("wechat")).toBe(false);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("D. idempotent re-register", () => {
|
|
287
|
+
it("same uin twice → single row, latest wechatDataPath wins", async () => {
|
|
288
|
+
const dataA = mkdtempSync(join(tmpdir(), "pdh-wechat-dataA-"));
|
|
289
|
+
const dataB = mkdtempSync(join(tmpdir(), "pdh-wechat-dataB-"));
|
|
290
|
+
try {
|
|
291
|
+
await registerWechatViaHub({
|
|
292
|
+
registry,
|
|
293
|
+
hubDir,
|
|
294
|
+
opts: {
|
|
295
|
+
account: { uin: "1234567890" },
|
|
296
|
+
wechatDataPath: dataA,
|
|
297
|
+
_probe: mkProbe(),
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
await registerWechatViaHub({
|
|
301
|
+
registry,
|
|
302
|
+
hubDir,
|
|
303
|
+
opts: {
|
|
304
|
+
account: { uin: "1234567890" },
|
|
305
|
+
wechatDataPath: dataB,
|
|
306
|
+
_probe: mkProbe(),
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const list = listWechatViaHub({ hubDir });
|
|
311
|
+
expect(list).toHaveLength(1);
|
|
312
|
+
expect(list[0].uin).toBe("1234567890");
|
|
313
|
+
expect(registry.has("wechat")).toBe(true);
|
|
314
|
+
|
|
315
|
+
// Adapter's _dbPath is null in both calls (we didn't pass dbPath),
|
|
316
|
+
// but the persisted row uses the latest wechatDataPath.
|
|
317
|
+
const raw = readFileSync(
|
|
318
|
+
join(hubDir, "wechat-accounts.json"),
|
|
319
|
+
"utf-8",
|
|
320
|
+
);
|
|
321
|
+
const persisted = JSON.parse(raw);
|
|
322
|
+
expect(persisted[0].wechatDataPath).toBe(dataB);
|
|
323
|
+
} finally {
|
|
324
|
+
try { rmSync(dataA, { recursive: true, force: true }); } catch (_e) {}
|
|
325
|
+
try { rmSync(dataB, { recursive: true, force: true }); } catch (_e) {}
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("two distinct uins coexist as separate rows", async () => {
|
|
330
|
+
await registerWechatViaHub({
|
|
331
|
+
registry,
|
|
332
|
+
hubDir,
|
|
333
|
+
opts: { account: { uin: "alice" }, wechatDataPath: dataDir, _probe: mkProbe() },
|
|
334
|
+
});
|
|
335
|
+
await registerWechatViaHub({
|
|
336
|
+
registry,
|
|
337
|
+
hubDir,
|
|
338
|
+
opts: { account: { uin: "bob" }, wechatDataPath: dataDir, _probe: mkProbe() },
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const list = listWechatViaHub({ hubDir });
|
|
342
|
+
expect(list.map((r) => r.uin).sort()).toEqual(["alice", "bob"]);
|
|
343
|
+
// Single registry slot named "wechat" — second register replaces first
|
|
344
|
+
// adapter instance, but registry still has exactly one entry. This is
|
|
345
|
+
// the v0.5 limit: the registry namespaces by adapter.name not by uin.
|
|
346
|
+
// The persisted accounts file is the source of truth for "which uins
|
|
347
|
+
// can sync"; bootstrap re-runs at sync time per account.
|
|
348
|
+
expect(registry.has("wechat")).toBe(true);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("override semantics (Phase 12.6.7 §18.10)", () => {
|
|
353
|
+
it("keyProviderOverride='frida' wins over probe='md5'", async () => {
|
|
354
|
+
const r = await registerWechatViaHub({
|
|
355
|
+
registry,
|
|
356
|
+
hubDir,
|
|
357
|
+
opts: {
|
|
358
|
+
account: { uin: "wxid_force" },
|
|
359
|
+
keyProviderOverride: "frida",
|
|
360
|
+
_probe: mkProbe(), // suggests md5
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
expect(r.ok).toBe(true);
|
|
364
|
+
expect(r.chosenKeyProvider).toBe("frida");
|
|
365
|
+
// Probe transparency: original suggestion still surfaces unchanged
|
|
366
|
+
expect(r.probe.suggestedKeyProvider).toBe("md5");
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("keyProviderOverride='md5' wins over probe='frida'", async () => {
|
|
370
|
+
const r = await registerWechatViaHub({
|
|
371
|
+
registry,
|
|
372
|
+
hubDir,
|
|
373
|
+
opts: {
|
|
374
|
+
account: { uin: "1234567890" },
|
|
375
|
+
wechatDataPath: dataDir,
|
|
376
|
+
keyProviderOverride: "md5",
|
|
377
|
+
_probe: mkProbe({
|
|
378
|
+
suggestedKeyProvider: "frida",
|
|
379
|
+
wechat: { installed: true, versionName: "8.0.50", majorVersion: 8 },
|
|
380
|
+
root: { detected: true, magiskInstalled: true },
|
|
381
|
+
frida: { serverRunning: true, port: 27042 },
|
|
382
|
+
}),
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
expect(r.ok).toBe(true);
|
|
386
|
+
expect(r.chosenKeyProvider).toBe("md5");
|
|
387
|
+
expect(r.probe.suggestedKeyProvider).toBe("frida");
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
});
|
|
@@ -178,7 +178,9 @@ describe("AdapterRegistry.syncAdapter", () => {
|
|
|
178
178
|
it("refuses concurrent sync of two adapters in one registry", async () => {
|
|
179
179
|
freshVault();
|
|
180
180
|
const reg = new AdapterRegistry({ vault });
|
|
181
|
-
|
|
181
|
+
// Use 500 events (not 5000) — still big enough to be mid-flight when
|
|
182
|
+
// the second syncAdapter() lands, but fits comfortably in 10s.
|
|
183
|
+
reg.register(new MockAdapter({ name: "a", count: 500 }));
|
|
182
184
|
reg.register(new MockAdapter({ name: "b", count: 5 }));
|
|
183
185
|
|
|
184
186
|
const p1 = reg.syncAdapter("a");
|
|
@@ -194,7 +196,7 @@ describe("AdapterRegistry.syncAdapter", () => {
|
|
|
194
196
|
// assert no double-sync corruption. The active-sync invariant is
|
|
195
197
|
// additionally enforced by the activeSync flag.
|
|
196
198
|
expect(racedReject == null || /already syncing/.test(racedReject.message)).toBe(true);
|
|
197
|
-
});
|
|
199
|
+
}, 30_000);
|
|
198
200
|
});
|
|
199
201
|
|
|
200
202
|
// ─── KG + RAG sinks ──────────────────────────────────────────────────────
|