feishu-user-plugin 1.3.10 → 1.3.12
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/.claude-plugin/plugin.json +2 -2
- package/.cursor-plugin/plugin.json +27 -0
- package/.mcpb/manifest.json +91 -0
- package/CHANGELOG.md +118 -0
- package/PRIVACY.md +105 -0
- package/README.en.md +130 -413
- package/README.md +88 -258
- package/package.json +5 -3
- package/scripts/build-mcpb.js +119 -0
- package/scripts/check-description-drift.js +73 -0
- package/scripts/check-docs-sync.js +7 -16
- package/scripts/check-mcp-registry-version.js +43 -0
- package/scripts/check-mcpb-version.js +33 -0
- package/scripts/check-scopes.js +99 -0
- package/scripts/check-tool-count.js +4 -3
- package/scripts/check-version.js +5 -0
- package/scripts/sync-claude-md.sh +3 -4
- package/scripts/sync-team-skills.sh +72 -57
- package/scripts/verify-app-name.js +64 -0
- package/skills/feishu-user-plugin/SKILL.md +3 -3
- package/skills/feishu-user-plugin/references/search.md +3 -3
- package/src/auth/credentials-monitor.js +185 -0
- package/src/auth/credentials.js +49 -0
- package/src/auth/identity-state.js +204 -0
- package/src/auth/lark-desktop.js +135 -0
- package/src/auth/uat.js +49 -35
- package/src/cli.js +87 -0
- package/src/clients/official/base.js +145 -14
- package/src/clients/official/calendar.js +3 -1
- package/src/clients/official/im.js +76 -2
- package/src/clients/official/okr.js +2 -1
- package/src/error-codes.js +40 -0
- package/src/events/lockfile.js +40 -4
- package/src/events/owner.js +11 -2
- package/src/index.js +1 -1
- package/src/logger.js +11 -5
- package/src/oauth.js +46 -10
- package/src/server.js +102 -37
- package/src/setup.js +44 -0
- package/src/test-all.js +40 -0
- package/src/test-cli-tool.js +87 -0
- package/src/test-credentials-monitor.js +124 -0
- package/src/test-display-label.js +88 -0
- package/src/test-error-codes.js +85 -0
- package/src/test-identity-state.js +172 -0
- package/src/test-lark-desktop.js +300 -0
- package/src/test-lockfile-pid.js +90 -0
- package/src/test-lru-cache.js +145 -0
- package/src/test-negative-cache.js +85 -0
- package/src/test-populate-sender-names.js +98 -0
- package/src/test-search-messages.js +101 -0
- package/src/test-send-shape.js +115 -0
- package/src/test-via-user.js +94 -0
- package/src/test-with-uat-retry.js +135 -0
- package/src/tools/_registry.js +24 -1
- package/src/tools/calendar.js +5 -5
- package/src/tools/im-read.js +52 -4
- package/src/tools/messaging-user.js +1 -1
- package/src/utils.js +83 -0
- package/scripts/generate-og-image.js +0 -39
- package/skills/feishu-user-plugin/references/CLAUDE.md +0 -523
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// src/test-lark-desktop.js — unit tests for src/auth/lark-desktop.js
|
|
2
|
+
// + src/auth/credentials.js larkHash bindings.
|
|
3
|
+
// Plain assert + fixture-based; no external deps.
|
|
4
|
+
//
|
|
5
|
+
// Run: `node src/test-lark-desktop.js`
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const assert = require('assert');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
|
|
14
|
+
const FIX_ROOT = path.join(os.tmpdir(), 'feishu-test-sdk-storage-' + process.pid + '-' + Date.now());
|
|
15
|
+
|
|
16
|
+
function makeFixture(hashes) {
|
|
17
|
+
fs.mkdirSync(FIX_ROOT, { recursive: true });
|
|
18
|
+
for (const [hash, mtimeOffset] of hashes) {
|
|
19
|
+
const dir = path.join(FIX_ROOT, hash);
|
|
20
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
21
|
+
const dbPath = path.join(dir, 'cookie_store.db');
|
|
22
|
+
fs.writeFileSync(dbPath, 'fake');
|
|
23
|
+
const t = (Date.now() / 1000) + mtimeOffset;
|
|
24
|
+
fs.utimesSync(dbPath, t, t);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function cleanupFixture() {
|
|
29
|
+
fs.rmSync(FIX_ROOT, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ld = require('./auth/lark-desktop');
|
|
33
|
+
|
|
34
|
+
// --- Task 1: read-only basics ---
|
|
35
|
+
|
|
36
|
+
function testListAccountHashes() {
|
|
37
|
+
cleanupFixture();
|
|
38
|
+
makeFixture([
|
|
39
|
+
['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', -100],
|
|
40
|
+
['bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 0],
|
|
41
|
+
['cccccccccccccccccccccccccccccccc', -50],
|
|
42
|
+
['notahash', 0], // should be filtered out (not 32-hex)
|
|
43
|
+
['DEADBEEFDEADBEEFDEADBEEFDEADBEEF', 0], // uppercase — also filtered (we accept lowercase only)
|
|
44
|
+
]);
|
|
45
|
+
// uppercase entry has no cookie_store.db tweaking — make sure dir exists at minimum
|
|
46
|
+
// (already created; just confirming file presence)
|
|
47
|
+
const list = ld.listAccountHashes({ dir: FIX_ROOT });
|
|
48
|
+
assert.strictEqual(list.length, 3, `filters non-hex names; got ${list.length}: ${list.map(h=>h.hash).join(',')}`);
|
|
49
|
+
assert.strictEqual(list[0].hash, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'sorted by mtime desc');
|
|
50
|
+
assert.strictEqual(list[2].hash, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
|
51
|
+
assert.ok(typeof list[0].mtimeMs === 'number');
|
|
52
|
+
assert.ok(list[0].dir.endsWith('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'));
|
|
53
|
+
cleanupFixture();
|
|
54
|
+
console.log('PASS: listAccountHashes');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function testListAccountHashesEmpty() {
|
|
58
|
+
cleanupFixture();
|
|
59
|
+
fs.mkdirSync(FIX_ROOT, { recursive: true });
|
|
60
|
+
// No hash dirs — should return []
|
|
61
|
+
assert.deepStrictEqual(ld.listAccountHashes({ dir: FIX_ROOT }), []);
|
|
62
|
+
cleanupFixture();
|
|
63
|
+
// Non-existent dir
|
|
64
|
+
assert.deepStrictEqual(ld.listAccountHashes({ dir: '/nonexistent-' + Date.now() }), []);
|
|
65
|
+
console.log('PASS: listAccountHashes empty / missing');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function testListAccountHashesIgnoresMissingDb() {
|
|
69
|
+
cleanupFixture();
|
|
70
|
+
fs.mkdirSync(path.join(FIX_ROOT, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), { recursive: true });
|
|
71
|
+
// No cookie_store.db file inside — entry should be skipped (we treat
|
|
72
|
+
// "no DB → never logged in / cleared" so it can't represent an active account)
|
|
73
|
+
const list = ld.listAccountHashes({ dir: FIX_ROOT });
|
|
74
|
+
assert.strictEqual(list.length, 0);
|
|
75
|
+
cleanupFixture();
|
|
76
|
+
console.log('PASS: listAccountHashes ignores hash dirs without cookie_store.db');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function testMostRecentHash() {
|
|
80
|
+
cleanupFixture();
|
|
81
|
+
makeFixture([
|
|
82
|
+
['1111111111111111111111111111aaaa', -200],
|
|
83
|
+
['2222222222222222222222222222bbbb', 0],
|
|
84
|
+
]);
|
|
85
|
+
const top = ld.mostRecentHash({ dir: FIX_ROOT });
|
|
86
|
+
assert.strictEqual(top.hash, '2222222222222222222222222222bbbb');
|
|
87
|
+
assert.strictEqual(ld.mostRecentHash({ dir: '/nonexistent-' + Date.now() }), null);
|
|
88
|
+
cleanupFixture();
|
|
89
|
+
console.log('PASS: mostRecentHash');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function testGetSdkStorageDirSafety() {
|
|
93
|
+
const dir = ld.getSdkStorageDir();
|
|
94
|
+
if (process.platform === 'darwin') {
|
|
95
|
+
assert.ok(dir === null || typeof dir === 'string');
|
|
96
|
+
} else {
|
|
97
|
+
assert.strictEqual(dir, null);
|
|
98
|
+
}
|
|
99
|
+
console.log('PASS: getSdkStorageDir platform safety');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- Task 2: profile hash bindings on credentials.js ---
|
|
103
|
+
|
|
104
|
+
function testProfileHashBindings() {
|
|
105
|
+
const sandbox = path.join(os.tmpdir(), 'feishu-test-creds-' + process.pid + '-' + Date.now());
|
|
106
|
+
fs.mkdirSync(path.join(sandbox, '.feishu-user-plugin'), { recursive: true, mode: 0o700 });
|
|
107
|
+
const credPath = path.join(sandbox, '.feishu-user-plugin', 'credentials.json');
|
|
108
|
+
|
|
109
|
+
const baseFile = {
|
|
110
|
+
version: 1,
|
|
111
|
+
active: 'default',
|
|
112
|
+
profiles: {
|
|
113
|
+
default: { LARK_APP_ID: 'cli_aaa' },
|
|
114
|
+
work: { LARK_APP_ID: 'cli_bbb' },
|
|
115
|
+
},
|
|
116
|
+
profileHints: {},
|
|
117
|
+
};
|
|
118
|
+
fs.writeFileSync(credPath, JSON.stringify(baseFile, null, 2));
|
|
119
|
+
|
|
120
|
+
const origHome = process.env.HOME;
|
|
121
|
+
process.env.HOME = sandbox;
|
|
122
|
+
|
|
123
|
+
// Force re-require so any cached internal state in credentials.js is fresh.
|
|
124
|
+
// (credentials.js doesn't actually cache — it re-reads on every call — but
|
|
125
|
+
// belt-and-suspenders.)
|
|
126
|
+
delete require.cache[require.resolve('./auth/credentials')];
|
|
127
|
+
const credentials = require('./auth/credentials');
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
// Initially unbound
|
|
131
|
+
assert.strictEqual(credentials.findProfileByHash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), null);
|
|
132
|
+
assert.strictEqual(credentials.getProfileLarkHash('default'), null);
|
|
133
|
+
assert.strictEqual(credentials.getProfileLarkHash('work'), null);
|
|
134
|
+
|
|
135
|
+
// Bind default
|
|
136
|
+
credentials.setProfileLarkHash('default', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
|
137
|
+
assert.strictEqual(credentials.getProfileLarkHash('default'), 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
|
138
|
+
assert.strictEqual(credentials.findProfileByHash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), 'default');
|
|
139
|
+
|
|
140
|
+
// Bind work
|
|
141
|
+
credentials.setProfileLarkHash('work', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
|
|
142
|
+
assert.strictEqual(credentials.findProfileByHash('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'), 'work');
|
|
143
|
+
// default still bound
|
|
144
|
+
assert.strictEqual(credentials.findProfileByHash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), 'default');
|
|
145
|
+
|
|
146
|
+
// Validation: bad hex
|
|
147
|
+
assert.throws(() => credentials.setProfileLarkHash('default', 'not-hex'),
|
|
148
|
+
/must be 32-char hex/);
|
|
149
|
+
// Validation: missing profile
|
|
150
|
+
assert.throws(() => credentials.setProfileLarkHash('nope', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'),
|
|
151
|
+
/not found/);
|
|
152
|
+
|
|
153
|
+
// findProfileByHash with bad input → null
|
|
154
|
+
assert.strictEqual(credentials.findProfileByHash('not-hex'), null);
|
|
155
|
+
assert.strictEqual(credentials.findProfileByHash(null), null);
|
|
156
|
+
assert.strictEqual(credentials.findProfileByHash(undefined), null);
|
|
157
|
+
|
|
158
|
+
// Clear by passing null
|
|
159
|
+
credentials.setProfileLarkHash('default', null);
|
|
160
|
+
assert.strictEqual(credentials.getProfileLarkHash('default'), null);
|
|
161
|
+
assert.strictEqual(credentials.findProfileByHash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), null);
|
|
162
|
+
} finally {
|
|
163
|
+
process.env.HOME = origHome;
|
|
164
|
+
fs.rmSync(sandbox, { recursive: true, force: true });
|
|
165
|
+
delete require.cache[require.resolve('./auth/credentials')];
|
|
166
|
+
}
|
|
167
|
+
console.log('PASS: profile hash bindings');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- Task 4: detectSwitch logic (pure) ---
|
|
171
|
+
|
|
172
|
+
function testDetectSwitchDebounce() {
|
|
173
|
+
const result = ld.detectSwitch({
|
|
174
|
+
prevSnapshot: {},
|
|
175
|
+
lastSwitchAt: Date.now() - 1000, // 1s ago, < 5s debounce
|
|
176
|
+
seenUnboundHashes: new Set(),
|
|
177
|
+
listFn: () => [{ hash: 'a'.repeat(32), mtimeMs: Date.now(), dir: '/x' }],
|
|
178
|
+
credsApi: { getActiveProfileName: () => 'default', getProfileLarkHash: () => null, findProfileByHash: () => 'default' },
|
|
179
|
+
});
|
|
180
|
+
assert.deepStrictEqual(result, { switchTo: null, isUnbound: false });
|
|
181
|
+
console.log('PASS: detectSwitch debounce');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function testDetectSwitchAlreadyOnMostRecent() {
|
|
185
|
+
const HASH = 'a'.repeat(32);
|
|
186
|
+
const result = ld.detectSwitch({
|
|
187
|
+
prevSnapshot: {},
|
|
188
|
+
lastSwitchAt: 0,
|
|
189
|
+
seenUnboundHashes: new Set(),
|
|
190
|
+
listFn: () => [{ hash: HASH, mtimeMs: Date.now(), dir: '/x' }],
|
|
191
|
+
credsApi: { getActiveProfileName: () => 'default', getProfileLarkHash: () => HASH, findProfileByHash: () => 'default' },
|
|
192
|
+
});
|
|
193
|
+
assert.deepStrictEqual(result, { switchTo: null, isUnbound: false });
|
|
194
|
+
console.log('PASS: detectSwitch already on most-recent');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function testDetectSwitchNoMtimeAdvance() {
|
|
198
|
+
const HASH = 'a'.repeat(32);
|
|
199
|
+
const fixedMtime = Date.now() - 10_000;
|
|
200
|
+
const result = ld.detectSwitch({
|
|
201
|
+
prevSnapshot: { [HASH]: fixedMtime },
|
|
202
|
+
lastSwitchAt: 0,
|
|
203
|
+
seenUnboundHashes: new Set(),
|
|
204
|
+
listFn: () => [{ hash: HASH, mtimeMs: fixedMtime, dir: '/x' }],
|
|
205
|
+
credsApi: { getActiveProfileName: () => 'default', getProfileLarkHash: () => 'b'.repeat(32), findProfileByHash: () => 'work' },
|
|
206
|
+
});
|
|
207
|
+
assert.deepStrictEqual(result, { switchTo: null, isUnbound: false });
|
|
208
|
+
console.log('PASS: detectSwitch no mtime advance');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function testDetectSwitchValid() {
|
|
212
|
+
const HASH = 'a'.repeat(32);
|
|
213
|
+
const result = ld.detectSwitch({
|
|
214
|
+
prevSnapshot: { [HASH]: 1000 },
|
|
215
|
+
lastSwitchAt: 0,
|
|
216
|
+
seenUnboundHashes: new Set(),
|
|
217
|
+
listFn: () => [{ hash: HASH, mtimeMs: 5000, dir: '/x' }],
|
|
218
|
+
credsApi: { getActiveProfileName: () => 'default', getProfileLarkHash: () => 'b'.repeat(32), findProfileByHash: () => 'work' },
|
|
219
|
+
});
|
|
220
|
+
assert.deepStrictEqual(result, { switchTo: { hash: HASH, profile: 'work' }, isUnbound: false });
|
|
221
|
+
console.log('PASS: detectSwitch valid switch');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function testDetectSwitchUnboundEmitsOnce() {
|
|
225
|
+
const HASH = 'a'.repeat(32);
|
|
226
|
+
const seen = new Set();
|
|
227
|
+
const logs = [];
|
|
228
|
+
const log = (msg) => logs.push(msg);
|
|
229
|
+
const args = {
|
|
230
|
+
prevSnapshot: { [HASH]: 1000 },
|
|
231
|
+
lastSwitchAt: 0,
|
|
232
|
+
seenUnboundHashes: seen,
|
|
233
|
+
listFn: () => [{ hash: HASH, mtimeMs: Date.now(), dir: '/x' }],
|
|
234
|
+
credsApi: { getActiveProfileName: () => 'default', getProfileLarkHash: () => null, findProfileByHash: () => null },
|
|
235
|
+
log,
|
|
236
|
+
};
|
|
237
|
+
let r = ld.detectSwitch(args);
|
|
238
|
+
assert.deepStrictEqual(r, { switchTo: null, isUnbound: true, hash: HASH });
|
|
239
|
+
assert.strictEqual(logs.length, 1, 'first call emits hint');
|
|
240
|
+
assert.match(logs[0], /not bound to any MCP profile/);
|
|
241
|
+
assert.match(logs[0], new RegExp(`--bind-hash ${HASH}`));
|
|
242
|
+
r = ld.detectSwitch(args);
|
|
243
|
+
assert.strictEqual(logs.length, 1, 'second call deduplicated');
|
|
244
|
+
console.log('PASS: detectSwitch unbound emits hint once per session');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function testDetectSwitchUnboundStaleNoHint() {
|
|
248
|
+
// mtime older than UNBOUND_FRESH_WINDOW_MS → no hint emitted
|
|
249
|
+
const HASH = 'a'.repeat(32);
|
|
250
|
+
const seen = new Set();
|
|
251
|
+
const logs = [];
|
|
252
|
+
const r = ld.detectSwitch({
|
|
253
|
+
prevSnapshot: { [HASH]: 1000 },
|
|
254
|
+
lastSwitchAt: 0,
|
|
255
|
+
seenUnboundHashes: seen,
|
|
256
|
+
listFn: () => [{ hash: HASH, mtimeMs: Date.now() - 120_000, dir: '/x' }], // 2 min ago
|
|
257
|
+
credsApi: { getActiveProfileName: () => 'default', getProfileLarkHash: () => null, findProfileByHash: () => null },
|
|
258
|
+
log: (msg) => logs.push(msg),
|
|
259
|
+
});
|
|
260
|
+
// Stale unbound hash: still reports isUnbound=true but doesn't add to seen / doesn't log
|
|
261
|
+
assert.strictEqual(r.isUnbound, true);
|
|
262
|
+
assert.strictEqual(logs.length, 0);
|
|
263
|
+
assert.strictEqual(seen.size, 0);
|
|
264
|
+
console.log('PASS: detectSwitch unbound stale → no hint');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function testDetectSwitchEmptyList() {
|
|
268
|
+
const r = ld.detectSwitch({
|
|
269
|
+
prevSnapshot: {},
|
|
270
|
+
lastSwitchAt: 0,
|
|
271
|
+
seenUnboundHashes: new Set(),
|
|
272
|
+
listFn: () => [],
|
|
273
|
+
credsApi: { getActiveProfileName: () => 'default', getProfileLarkHash: () => null, findProfileByHash: () => null },
|
|
274
|
+
});
|
|
275
|
+
assert.deepStrictEqual(r, { switchTo: null, isUnbound: false });
|
|
276
|
+
console.log('PASS: detectSwitch empty list');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// --- Run all ---
|
|
280
|
+
|
|
281
|
+
function run() {
|
|
282
|
+
testListAccountHashes();
|
|
283
|
+
testListAccountHashesEmpty();
|
|
284
|
+
testListAccountHashesIgnoresMissingDb();
|
|
285
|
+
testMostRecentHash();
|
|
286
|
+
testGetSdkStorageDirSafety();
|
|
287
|
+
testProfileHashBindings();
|
|
288
|
+
testDetectSwitchDebounce();
|
|
289
|
+
testDetectSwitchAlreadyOnMostRecent();
|
|
290
|
+
testDetectSwitchNoMtimeAdvance();
|
|
291
|
+
testDetectSwitchValid();
|
|
292
|
+
testDetectSwitchUnboundEmitsOnce();
|
|
293
|
+
testDetectSwitchUnboundStaleNoHint();
|
|
294
|
+
testDetectSwitchEmptyList();
|
|
295
|
+
console.log('\nAll lark-desktop tests passed.');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (require.main === module) {
|
|
299
|
+
run();
|
|
300
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// src/test-lockfile-pid.js — verify acquireLongLived's v1.3.12 PID
|
|
2
|
+
// liveness check.
|
|
3
|
+
//
|
|
4
|
+
// Pre-v1.3.12 the lock was judged "alive" purely by mtime: heartbeat every
|
|
5
|
+
// 15s, stale after 60s. If the owner process got SIGKILL'd (or crashed
|
|
6
|
+
// mid-heartbeat), the lock looked alive for up to 60s. With the WS event
|
|
7
|
+
// subscription tied to the lock, a hung owner blocked event ingestion for
|
|
8
|
+
// the entire window.
|
|
9
|
+
//
|
|
10
|
+
// New behaviour: when stat says mtime is fresh, ALSO read the lock body and
|
|
11
|
+
// `process.kill(pid, 0)`. If ESRCH → process is gone, lock is steal-eligible
|
|
12
|
+
// immediately regardless of mtime.
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
const assert = require('node:assert/strict');
|
|
20
|
+
const { acquireLongLived } = require('./events/lockfile');
|
|
21
|
+
|
|
22
|
+
function run() {
|
|
23
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fish-pid-lock-'));
|
|
24
|
+
const lockPath = path.join(dir, 'test.lock');
|
|
25
|
+
|
|
26
|
+
// --- 1. Write a lock body for a pid that definitely doesn't exist.
|
|
27
|
+
// PID 1 is init/launchd — always exists; we need a pid that's
|
|
28
|
+
// certainly gone. Use a huge integer well outside the kernel's
|
|
29
|
+
// normal range; the most portable check is process.kill(pid, 0).
|
|
30
|
+
const fakePid = 999_999_999;
|
|
31
|
+
const body = JSON.stringify({ version: 1, pid: fakePid, start_time: Math.floor(Date.now() / 1000), role: 'test_dead_owner' });
|
|
32
|
+
fs.writeFileSync(lockPath, body, { mode: 0o600 });
|
|
33
|
+
// Fresh mtime — pre-v1.3.12 this would block acquisition for 60s.
|
|
34
|
+
fs.utimesSync(lockPath, new Date(), new Date());
|
|
35
|
+
|
|
36
|
+
const handle = acquireLongLived(lockPath, { info: { role: 'new_owner' }, staleMs: 60_000 });
|
|
37
|
+
assert.ok(handle, 'should be able to steal lock when holder pid is dead, even when mtime is fresh');
|
|
38
|
+
|
|
39
|
+
// Read body — should now contain THIS process's pid.
|
|
40
|
+
const newBody = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
41
|
+
assert.equal(newBody.pid, process.pid);
|
|
42
|
+
|
|
43
|
+
handle.release();
|
|
44
|
+
|
|
45
|
+
// --- 2. Live pid (this process) prevents acquisition even past staleMs
|
|
46
|
+
// when content shows we're still alive — but for safety we treat
|
|
47
|
+
// EPERM (different user, can't probe) as alive too. Skip EPERM
|
|
48
|
+
// case since it requires multi-user setup.
|
|
49
|
+
const livePid = process.pid;
|
|
50
|
+
const liveBody = JSON.stringify({ version: 1, pid: livePid, start_time: Math.floor(Date.now() / 1000), role: 'live_owner' });
|
|
51
|
+
fs.writeFileSync(lockPath, liveBody, { mode: 0o600 });
|
|
52
|
+
fs.utimesSync(lockPath, new Date(), new Date());
|
|
53
|
+
const blocked = acquireLongLived(lockPath, { info: {}, staleMs: 60_000 });
|
|
54
|
+
assert.equal(blocked, null, 'live pid + fresh mtime → cannot steal');
|
|
55
|
+
fs.unlinkSync(lockPath); // cleanup
|
|
56
|
+
|
|
57
|
+
// --- 3. Stale mtime + live pid: previously would have stolen; new behaviour
|
|
58
|
+
// keeps the steal because mtime says heartbeat is dead.
|
|
59
|
+
// (We don't try to second-guess a stuck process — mtime is the
|
|
60
|
+
// primary signal; PID check only adds the ability to reclaim
|
|
61
|
+
// FASTER when process is definitively dead.)
|
|
62
|
+
fs.writeFileSync(lockPath, JSON.stringify({ version: 1, pid: livePid, start_time: Math.floor(Date.now() / 1000) - 999 }), { mode: 0o600 });
|
|
63
|
+
// Backdate mtime well past staleMs.
|
|
64
|
+
const backdate = new Date(Date.now() - 120_000);
|
|
65
|
+
fs.utimesSync(lockPath, backdate, backdate);
|
|
66
|
+
const stolenLive = acquireLongLived(lockPath, { info: {}, staleMs: 60_000 });
|
|
67
|
+
assert.ok(stolenLive, 'stale mtime should still allow takeover (back-compat)');
|
|
68
|
+
stolenLive.release();
|
|
69
|
+
|
|
70
|
+
// --- 4. Body missing pid field (legacy locks from older versions) — fall
|
|
71
|
+
// back to mtime-only check (existing behaviour, no regression).
|
|
72
|
+
fs.writeFileSync(lockPath, JSON.stringify({ version: 1 }), { mode: 0o600 });
|
|
73
|
+
fs.utimesSync(lockPath, new Date(), new Date());
|
|
74
|
+
const noPidBlocked = acquireLongLived(lockPath, { info: {}, staleMs: 60_000 });
|
|
75
|
+
assert.equal(noPidBlocked, null, 'no pid in body → fall back to mtime, fresh mtime blocks');
|
|
76
|
+
fs.unlinkSync(lockPath);
|
|
77
|
+
|
|
78
|
+
// --- 5. Malformed lock body (not JSON) — mtime-only fallback.
|
|
79
|
+
fs.writeFileSync(lockPath, 'not json at all', { mode: 0o600 });
|
|
80
|
+
fs.utimesSync(lockPath, new Date(), new Date());
|
|
81
|
+
const malformedBlocked = acquireLongLived(lockPath, { info: {}, staleMs: 60_000 });
|
|
82
|
+
assert.equal(malformedBlocked, null, 'malformed body → fall back to mtime, fresh mtime blocks');
|
|
83
|
+
fs.unlinkSync(lockPath);
|
|
84
|
+
|
|
85
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
86
|
+
console.log('lockfile-pid.js: PASS');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (require.main === module) run();
|
|
90
|
+
module.exports = { run };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// src/test-lru-cache.js — unit test for src/utils/lru-cache.js.
|
|
2
|
+
//
|
|
3
|
+
// Replaces the v1.3.12 `new Map()` _userNameCache / _appNameCache. Pre-fix the
|
|
4
|
+
// caches grew unboundedly across the server's lifetime (one entry per unique
|
|
5
|
+
// open_id ever seen) and never expired — a 1-week-uptime MCP would carry
|
|
6
|
+
// stale display names from messages of users who renamed themselves days ago.
|
|
7
|
+
//
|
|
8
|
+
// LRU with TTL solves both:
|
|
9
|
+
// - max=500 caps the per-process memory at O(KiB) regardless of message volume
|
|
10
|
+
// - ttlMs=10min ensures rename / leave-tenant changes get re-resolved
|
|
11
|
+
//
|
|
12
|
+
// We test the basic operations + interactions between TTL and LRU.
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const assert = require('node:assert/strict');
|
|
17
|
+
const { LRUCache } = require('./utils');
|
|
18
|
+
|
|
19
|
+
async function run() {
|
|
20
|
+
// --- 1. set/get/has roundtrip ---
|
|
21
|
+
{
|
|
22
|
+
const c = new LRUCache({ max: 5, ttlMs: 1000 });
|
|
23
|
+
c.set('a', 1);
|
|
24
|
+
assert.equal(c.get('a'), 1);
|
|
25
|
+
assert.equal(c.has('a'), true);
|
|
26
|
+
assert.equal(c.get('missing'), undefined);
|
|
27
|
+
assert.equal(c.has('missing'), false);
|
|
28
|
+
assert.equal(c.size, 1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --- 2. LRU eviction: oldest dropped when over max ---
|
|
32
|
+
{
|
|
33
|
+
const c = new LRUCache({ max: 3, ttlMs: 60_000 });
|
|
34
|
+
c.set('a', 1);
|
|
35
|
+
c.set('b', 2);
|
|
36
|
+
c.set('c', 3);
|
|
37
|
+
c.set('d', 4); // evicts 'a'
|
|
38
|
+
assert.equal(c.has('a'), false);
|
|
39
|
+
assert.equal(c.get('b'), 2);
|
|
40
|
+
assert.equal(c.get('c'), 3);
|
|
41
|
+
assert.equal(c.get('d'), 4);
|
|
42
|
+
assert.equal(c.size, 3);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- 3. Access promotes recency: get prevents eviction ---
|
|
46
|
+
{
|
|
47
|
+
const c = new LRUCache({ max: 3, ttlMs: 60_000 });
|
|
48
|
+
c.set('a', 1);
|
|
49
|
+
c.set('b', 2);
|
|
50
|
+
c.set('c', 3);
|
|
51
|
+
c.get('a'); // 'a' becomes most-recently-used; 'b' is now LRU
|
|
52
|
+
c.set('d', 4); // evicts 'b'
|
|
53
|
+
assert.equal(c.has('a'), true);
|
|
54
|
+
assert.equal(c.has('b'), false);
|
|
55
|
+
assert.equal(c.has('c'), true);
|
|
56
|
+
assert.equal(c.has('d'), true);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- 4. TTL expiry: get returns undefined for expired ---
|
|
60
|
+
{
|
|
61
|
+
const c = new LRUCache({ max: 10, ttlMs: 50 });
|
|
62
|
+
c.set('a', 1);
|
|
63
|
+
assert.equal(c.get('a'), 1);
|
|
64
|
+
await new Promise(r => setTimeout(r, 80));
|
|
65
|
+
assert.equal(c.get('a'), undefined, 'expired entries return undefined');
|
|
66
|
+
assert.equal(c.has('a'), false);
|
|
67
|
+
// Expired entry is purged: size drops.
|
|
68
|
+
assert.equal(c.size, 0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --- 5. Setting an existing key refreshes TTL ---
|
|
72
|
+
{
|
|
73
|
+
const c = new LRUCache({ max: 10, ttlMs: 100 });
|
|
74
|
+
c.set('a', 1);
|
|
75
|
+
await new Promise(r => setTimeout(r, 60));
|
|
76
|
+
c.set('a', 1); // refresh
|
|
77
|
+
await new Promise(r => setTimeout(r, 60));
|
|
78
|
+
// 120ms total since first set, only 60ms since refresh — still valid.
|
|
79
|
+
assert.equal(c.get('a'), 1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- 6. delete + clear ---
|
|
83
|
+
{
|
|
84
|
+
const c = new LRUCache({ max: 5, ttlMs: 1000 });
|
|
85
|
+
c.set('a', 1);
|
|
86
|
+
c.set('b', 2);
|
|
87
|
+
c.delete('a');
|
|
88
|
+
assert.equal(c.has('a'), false);
|
|
89
|
+
assert.equal(c.size, 1);
|
|
90
|
+
c.clear();
|
|
91
|
+
assert.equal(c.size, 0);
|
|
92
|
+
assert.equal(c.has('b'), false);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --- 7. Map-compatible shim (we replace `new Map()` in base.js — the
|
|
96
|
+
// existing call sites use .has / .get / .set / .clear, which the class
|
|
97
|
+
// implements identically. Smoke-check parity here.) ---
|
|
98
|
+
{
|
|
99
|
+
const c = new LRUCache({ max: 5, ttlMs: 1000 });
|
|
100
|
+
c.set('open_x', 'Alice');
|
|
101
|
+
assert.equal(c.has('open_x'), true);
|
|
102
|
+
assert.equal(c.get('open_x'), 'Alice');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- 8. Iteration support: the comment claims "API-compatible with the
|
|
106
|
+
// old Map", so spread / for-of / entries / keys / values must all work.
|
|
107
|
+
// Map is iterable via Symbol.iterator yielding [key, value] tuples.
|
|
108
|
+
{
|
|
109
|
+
const c = new LRUCache({ max: 5, ttlMs: 60_000 });
|
|
110
|
+
c.set('a', 1);
|
|
111
|
+
c.set('b', 2);
|
|
112
|
+
c.set('c', 3);
|
|
113
|
+
const collected = [...c];
|
|
114
|
+
assert.equal(collected.length, 3);
|
|
115
|
+
// Map insertion order, [key, value] tuples.
|
|
116
|
+
assert.deepEqual(collected[0], ['a', 1]);
|
|
117
|
+
assert.deepEqual(collected[2], ['c', 3]);
|
|
118
|
+
|
|
119
|
+
const forOfKeys = [];
|
|
120
|
+
for (const [k] of c) forOfKeys.push(k);
|
|
121
|
+
assert.deepEqual(forOfKeys, ['a', 'b', 'c']);
|
|
122
|
+
|
|
123
|
+
assert.deepEqual([...c.keys()], ['a', 'b', 'c']);
|
|
124
|
+
assert.deepEqual([...c.values()], [1, 2, 3]);
|
|
125
|
+
assert.deepEqual([...c.entries()], [['a', 1], ['b', 2], ['c', 3]]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- 9. Iteration skips expired entries (TTL gate is consistent across
|
|
129
|
+
// get/has and iteration so callers don't see stale data via spread).
|
|
130
|
+
{
|
|
131
|
+
const c = new LRUCache({ max: 5, ttlMs: 50 });
|
|
132
|
+
c.set('a', 1);
|
|
133
|
+
c.set('b', 2);
|
|
134
|
+
await new Promise(r => setTimeout(r, 80));
|
|
135
|
+
c.set('c', 3); // fresh after expiry of a/b
|
|
136
|
+
const collected = [...c];
|
|
137
|
+
assert.equal(collected.length, 1);
|
|
138
|
+
assert.deepEqual(collected[0], ['c', 3]);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
console.log('lru-cache.js: PASS');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (require.main === module) run().catch(e => { console.error(e); process.exit(1); });
|
|
145
|
+
module.exports = { run };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// src/test-negative-cache.js — verify _populateSenderNames writes a null
|
|
2
|
+
// sentinel for un-resolvable open_ids so repeated read_messages calls
|
|
3
|
+
// don't re-fire the same contact API request.
|
|
4
|
+
//
|
|
5
|
+
// Pre-fix bug: contacts.js::getUserById returned null on failure without
|
|
6
|
+
// writing to _userNameCache, so every subsequent _populateSenderNames
|
|
7
|
+
// invocation re-added the same id to unknownUserIds and dispatched another
|
|
8
|
+
// API call. In a hot chat with N un-resolvable senders that's N redundant
|
|
9
|
+
// API calls per read_messages — observed in the 2026-05 incident's stderr.
|
|
10
|
+
//
|
|
11
|
+
// Fix lives in _populateSenderNames itself (not in contacts.js): after each
|
|
12
|
+
// Promise.allSettled batch, ids still absent from _userNameCache get a null
|
|
13
|
+
// sentinel written. has(id)==true / get(id)==null on next call → the loop
|
|
14
|
+
// skips the id entirely, _computeDisplayLabel falls back to "(open_id)"
|
|
15
|
+
// the same way it would have without a cache.
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const assert = require('node:assert/strict');
|
|
20
|
+
const { LarkOfficialClient } = require('./clients/official');
|
|
21
|
+
|
|
22
|
+
async function run() {
|
|
23
|
+
const c = new LarkOfficialClient('cli_test', 'fake_secret');
|
|
24
|
+
// Stub self tenant probe so the real one doesn't hit Feishu.
|
|
25
|
+
c._resolveSelfTenantKey = async () => 'tenant_self';
|
|
26
|
+
c._selfTenantKey = 'tenant_self';
|
|
27
|
+
|
|
28
|
+
// Mock getUserById to simulate a un-resolvable user: cache nothing,
|
|
29
|
+
// return null. The fix in _populateSenderNames is what should write the
|
|
30
|
+
// null sentinel afterwards.
|
|
31
|
+
let userCallCount = 0;
|
|
32
|
+
c.getUserById = async (userId) => {
|
|
33
|
+
if (c._userNameCache.has(userId)) return c._userNameCache.get(userId);
|
|
34
|
+
userCallCount++;
|
|
35
|
+
return null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Same for getAppName.
|
|
39
|
+
let appCallCount = 0;
|
|
40
|
+
c.getAppName = async (appId) => {
|
|
41
|
+
if (c._appNameCache.has(appId)) return c._appNameCache.get(appId);
|
|
42
|
+
appCallCount++;
|
|
43
|
+
return null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// --- 1. User negative-cache: un-resolvable ou_bad ---
|
|
47
|
+
const items1 = [{ senderId: 'ou_bad', senderType: 'user' }];
|
|
48
|
+
await c._populateSenderNames(items1, null);
|
|
49
|
+
assert.equal(userCallCount, 1, 'first populate dispatches one API call');
|
|
50
|
+
assert.equal(items1[0].senderName, null);
|
|
51
|
+
assert.equal(items1[0].displayLabel, '(ou_bad)');
|
|
52
|
+
|
|
53
|
+
// Cache must now hold a null sentinel.
|
|
54
|
+
assert.equal(c._userNameCache.has('ou_bad'), true, 'null sentinel written for un-resolvable id');
|
|
55
|
+
assert.equal(c._userNameCache.get('ou_bad'), null);
|
|
56
|
+
|
|
57
|
+
// --- 2. Same id, second populate → cache hit, no new API call ---
|
|
58
|
+
const items2 = [{ senderId: 'ou_bad', senderType: 'user' }];
|
|
59
|
+
await c._populateSenderNames(items2, null);
|
|
60
|
+
assert.equal(userCallCount, 1, 'cached null skips dispatch');
|
|
61
|
+
assert.equal(items2[0].displayLabel, '(ou_bad)');
|
|
62
|
+
|
|
63
|
+
// --- 3. Mixed batch: new id triggers exactly one new call ---
|
|
64
|
+
const items3 = [
|
|
65
|
+
{ senderId: 'ou_bad', senderType: 'user' },
|
|
66
|
+
{ senderId: 'ou_new', senderType: 'user' },
|
|
67
|
+
];
|
|
68
|
+
await c._populateSenderNames(items3, null);
|
|
69
|
+
assert.equal(userCallCount, 2, 'new id alone dispatches one new call');
|
|
70
|
+
|
|
71
|
+
// --- 4. App negative-cache: un-resolvable cli_bad ---
|
|
72
|
+
const items4 = [{ senderId: 'cli_bad', senderType: 'app' }];
|
|
73
|
+
await c._populateSenderNames(items4, null);
|
|
74
|
+
assert.equal(appCallCount, 1);
|
|
75
|
+
assert.equal(items4[0].displayLabel, '[Bot] (cli_bad)');
|
|
76
|
+
|
|
77
|
+
const items5 = [{ senderId: 'cli_bad', senderType: 'app' }];
|
|
78
|
+
await c._populateSenderNames(items5, null);
|
|
79
|
+
assert.equal(appCallCount, 1, 'cached null app skips dispatch');
|
|
80
|
+
|
|
81
|
+
console.log('negative-cache.js: PASS');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (require.main === module) run().catch(e => { console.error(e); process.exit(1); });
|
|
85
|
+
module.exports = { run };
|