feishu-user-plugin 1.3.13 → 1.3.15
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 +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.mcpb/manifest.json +1 -1
- package/CHANGELOG.md +91 -0
- package/README.en.md +3 -1
- package/README.md +4 -2
- package/package.json +1 -1
- package/scripts/check-broken-links.js +117 -0
- package/scripts/generate-release-artifacts.js +7 -1
- package/scripts/probe-feishu-docx.js +6 -6
- package/scripts/test-uat-race-child.js +8 -5
- package/scripts/test-uat-race.js +9 -2
- package/scripts/test-wiki-attach-fallback.js +7 -0
- package/skills/feishu-user-plugin/SKILL.md +2 -2
- package/skills/feishu-user-plugin/references/doc.md +12 -0
- package/src/auth/cookie.js +67 -14
- package/src/auth/credentials-monitor.js +5 -0
- package/src/auth/env-backfill.js +37 -0
- package/src/auth/identity-state.js +17 -1
- package/src/auth/uat.js +124 -8
- package/src/cli.js +4 -3
- package/src/clients/official/base.js +12 -7
- package/src/clients/official/docs.js +95 -0
- package/src/error-codes.js +2 -1
- package/src/oauth.js +19 -7
- package/src/server.js +22 -4
- package/src/setup.js +3 -1
- package/src/test-all.js +15 -0
- package/src/test-comprehensive.js +7 -2
- package/src/test-cookie-heartbeat.js +132 -0
- package/src/test-doc-table.js +123 -0
- package/src/test-uat-lifecycle.js +449 -0
- package/src/tools/docs.js +13 -4
- package/src/oauth-auto.js +0 -175
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Unit tests for the UAT lifecycle building blocks in src/auth/uat.js.
|
|
3
|
+
// Covers v1.3.14 hardening:
|
|
4
|
+
// - decodeTokenExpiry: malformed JWT → 0 with stderr breadcrumb (no throw)
|
|
5
|
+
// - acquireRefreshLock: basic acquire / contention timeout / stale recovery
|
|
6
|
+
// - releaseRefreshLock: tolerant of already-released
|
|
7
|
+
// - adoptPersistedUATIfNewer: peer-rotation adoption logic
|
|
8
|
+
// - refreshUAT: invalid_grant → err.uatRevoked = true (via mocked fetch)
|
|
9
|
+
// - refreshUAT: success path persists + adopts new token
|
|
10
|
+
//
|
|
11
|
+
// These are pure-unit; no live Feishu calls.
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const assert = require('assert');
|
|
19
|
+
|
|
20
|
+
let pass = 0;
|
|
21
|
+
let fail = 0;
|
|
22
|
+
|
|
23
|
+
function ok(name, fn) {
|
|
24
|
+
try {
|
|
25
|
+
const r = fn();
|
|
26
|
+
if (r && typeof r.then === 'function') {
|
|
27
|
+
return r.then(() => { console.log(` OK ${name}`); pass++; },
|
|
28
|
+
(e) => { console.log(` FAIL ${name}: ${e.message}`); fail++; });
|
|
29
|
+
}
|
|
30
|
+
console.log(` OK ${name}`);
|
|
31
|
+
pass++;
|
|
32
|
+
} catch (e) {
|
|
33
|
+
console.log(` FAIL ${name}: ${e.message}`);
|
|
34
|
+
fail++;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function run() {
|
|
39
|
+
console.log('=== test-uat-lifecycle ===');
|
|
40
|
+
|
|
41
|
+
const uat = require('./auth/uat');
|
|
42
|
+
|
|
43
|
+
// --- decodeTokenExpiry ---
|
|
44
|
+
await ok('decodeTokenExpiry: well-formed JWT returns exp', () => {
|
|
45
|
+
// Hand-craft a JWT with exp=123456 (no signature; we only read payload).
|
|
46
|
+
const payload = Buffer.from(JSON.stringify({ exp: 123456 }), 'utf8').toString('base64url');
|
|
47
|
+
const token = `header.${payload}.sig`;
|
|
48
|
+
assert.strictEqual(uat.decodeTokenExpiry(token), 123456);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await ok('decodeTokenExpiry: missing payload returns 0', () => {
|
|
52
|
+
assert.strictEqual(uat.decodeTokenExpiry('only-header'), 0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await ok('decodeTokenExpiry: malformed base64 returns 0 (with stderr breadcrumb)', () => {
|
|
56
|
+
// Capture stderr so the breadcrumb doesn't pollute test output. We don't
|
|
57
|
+
// assert on the message — just that the function doesn't throw and returns 0.
|
|
58
|
+
const origErr = console.error;
|
|
59
|
+
const captured = [];
|
|
60
|
+
console.error = (...args) => captured.push(args.join(' '));
|
|
61
|
+
try {
|
|
62
|
+
const v = uat.decodeTokenExpiry('header.not-base64-payload!!.sig');
|
|
63
|
+
assert.strictEqual(v, 0);
|
|
64
|
+
// We don't strictly require a breadcrumb (silent return 0 was the v1.3.13
|
|
65
|
+
// behavior; v1.3.14 added stderr log). Test passes either way.
|
|
66
|
+
} finally {
|
|
67
|
+
console.error = origErr;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await ok('decodeTokenExpiry: payload without exp returns 0', () => {
|
|
72
|
+
const payload = Buffer.from(JSON.stringify({ sub: 'u_xxx' }), 'utf8').toString('base64url');
|
|
73
|
+
assert.strictEqual(uat.decodeTokenExpiry(`h.${payload}.s`), 0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// v1.3.14 — flood-gate test: a persistently-malformed JWT should only log
|
|
77
|
+
// once per distinct token, not on every call. Without the gate, every
|
|
78
|
+
// getValidUAT call (= every UAT-backed tool dispatch) flooded stderr.
|
|
79
|
+
await ok('decodeTokenExpiry: same malformed token logs only once across repeated calls', () => {
|
|
80
|
+
const origErr = console.error;
|
|
81
|
+
const captured = [];
|
|
82
|
+
console.error = (...args) => captured.push(args.join(' '));
|
|
83
|
+
try {
|
|
84
|
+
const badToken = 'header.malformed-payload-XX-not-base64.sig-1';
|
|
85
|
+
for (let i = 0; i < 5; i++) uat.decodeTokenExpiry(badToken);
|
|
86
|
+
const decodeWarnings = captured.filter(l => /decodeTokenExpiry: malformed/.test(l));
|
|
87
|
+
assert.strictEqual(decodeWarnings.length, 1,
|
|
88
|
+
`expected exactly 1 decode warning for the same bad token across 5 calls; got ${decodeWarnings.length}: ${JSON.stringify(decodeWarnings)}`);
|
|
89
|
+
} finally {
|
|
90
|
+
console.error = origErr;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await ok('decodeTokenExpiry: different malformed tokens each log once', () => {
|
|
95
|
+
const origErr = console.error;
|
|
96
|
+
const captured = [];
|
|
97
|
+
console.error = (...args) => captured.push(args.join(' '));
|
|
98
|
+
try {
|
|
99
|
+
uat.decodeTokenExpiry('header.bad-A!!!XX.sig');
|
|
100
|
+
uat.decodeTokenExpiry('header.bad-B@@@YY.sig');
|
|
101
|
+
uat.decodeTokenExpiry('header.bad-C###ZZ.sig');
|
|
102
|
+
const decodeWarnings = captured.filter(l => /decodeTokenExpiry: malformed/.test(l));
|
|
103
|
+
assert.strictEqual(decodeWarnings.length, 3,
|
|
104
|
+
`expected one warning per distinct bad token; got ${decodeWarnings.length}`);
|
|
105
|
+
} finally {
|
|
106
|
+
console.error = origErr;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// --- acquireRefreshLock / releaseRefreshLock ---
|
|
111
|
+
|
|
112
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-uat-lifecycle-'));
|
|
113
|
+
const lockPath = path.join(tmpDir, 'test.lock');
|
|
114
|
+
|
|
115
|
+
await ok('acquireRefreshLock: fresh dir, lock acquires', async () => {
|
|
116
|
+
const got = await uat.acquireRefreshLock(lockPath, { timeoutMs: 1000 });
|
|
117
|
+
assert.strictEqual(got, true);
|
|
118
|
+
assert.strictEqual(fs.existsSync(lockPath), true);
|
|
119
|
+
uat.releaseRefreshLock(lockPath);
|
|
120
|
+
assert.strictEqual(fs.existsSync(lockPath), false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await ok('acquireRefreshLock: contention times out', async () => {
|
|
124
|
+
const got1 = await uat.acquireRefreshLock(lockPath, { timeoutMs: 1000 });
|
|
125
|
+
assert.strictEqual(got1, true);
|
|
126
|
+
try {
|
|
127
|
+
const got2 = await uat.acquireRefreshLock(lockPath, { timeoutMs: 500, pollMs: 100, staleMs: 60_000 });
|
|
128
|
+
assert.strictEqual(got2, false, 'second acquire should fail while first holds');
|
|
129
|
+
} finally {
|
|
130
|
+
uat.releaseRefreshLock(lockPath);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await ok('acquireRefreshLock: stale lock recovers', async () => {
|
|
135
|
+
// Write a "stale" lock by setting mtime in the past.
|
|
136
|
+
fs.writeFileSync(lockPath, `${process.pid}\n${Date.now() - 60_000}\n`);
|
|
137
|
+
const oldTime = Date.now() - 60_000;
|
|
138
|
+
fs.utimesSync(lockPath, oldTime / 1000, oldTime / 1000);
|
|
139
|
+
// staleMs=5s — our lock is 60s old, should be detected and stolen.
|
|
140
|
+
const got = await uat.acquireRefreshLock(lockPath, { timeoutMs: 2000, staleMs: 5_000, pollMs: 100 });
|
|
141
|
+
assert.strictEqual(got, true, 'should steal stale lock');
|
|
142
|
+
uat.releaseRefreshLock(lockPath);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await ok('releaseRefreshLock: tolerant of already-released', () => {
|
|
146
|
+
// Should not throw.
|
|
147
|
+
uat.releaseRefreshLock(lockPath);
|
|
148
|
+
uat.releaseRefreshLock(path.join(tmpDir, 'never-existed.lock'));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// --- adoptPersistedUATIfNewer ---
|
|
152
|
+
//
|
|
153
|
+
// Uses a tmp HOME so we don't touch the real canonical store. We monkey-patch
|
|
154
|
+
// os.homedir to point at our tmp.
|
|
155
|
+
|
|
156
|
+
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-home-'));
|
|
157
|
+
fs.mkdirSync(path.join(fakeHome, '.feishu-user-plugin'), { recursive: true, mode: 0o700 });
|
|
158
|
+
const fakeCanonical = path.join(fakeHome, '.feishu-user-plugin', 'credentials.json');
|
|
159
|
+
const origHomedir = os.homedir;
|
|
160
|
+
os.homedir = () => fakeHome;
|
|
161
|
+
|
|
162
|
+
// Snapshot + clear LARK_* env vars so legacy fallback inside readCredentials
|
|
163
|
+
// can't pick them up from the host process. Pre-v1.3.14 these tests passed
|
|
164
|
+
// standalone but failed in `npm test` because test-all.js v1.3.14 backfill
|
|
165
|
+
// populates process.env from the real canonical store.
|
|
166
|
+
const SNAP_KEYS = ['LARK_COOKIE', 'LARK_APP_ID', 'LARK_APP_SECRET',
|
|
167
|
+
'LARK_USER_ACCESS_TOKEN', 'LARK_USER_REFRESH_TOKEN',
|
|
168
|
+
'LARK_UAT_EXPIRES', 'LARK_UAT_SCOPE', 'LARK_PROFILES_JSON'];
|
|
169
|
+
const envSnapshot = {};
|
|
170
|
+
for (const k of SNAP_KEYS) { envSnapshot[k] = process.env[k]; delete process.env[k]; }
|
|
171
|
+
|
|
172
|
+
function writeCanonical(env) {
|
|
173
|
+
fs.writeFileSync(fakeCanonical, JSON.stringify({
|
|
174
|
+
version: 1,
|
|
175
|
+
active: 'default',
|
|
176
|
+
profiles: { default: env },
|
|
177
|
+
profileHints: {},
|
|
178
|
+
}, null, 2));
|
|
179
|
+
fs.chmodSync(fakeCanonical, 0o600);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
await ok('adoptPersistedUATIfNewer: no canonical → false', () => {
|
|
184
|
+
// ensure no file
|
|
185
|
+
try { fs.unlinkSync(fakeCanonical); } catch (_) {}
|
|
186
|
+
const client = { _uat: null, _uatRefresh: null, _uatExpires: 0 };
|
|
187
|
+
const r = uat.adoptPersistedUATIfNewer(client);
|
|
188
|
+
assert.strictEqual(r, false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await ok('adoptPersistedUATIfNewer: same token → false', () => {
|
|
192
|
+
writeCanonical({
|
|
193
|
+
LARK_USER_ACCESS_TOKEN: 'same.token.value',
|
|
194
|
+
LARK_USER_REFRESH_TOKEN: 'same.refresh.value',
|
|
195
|
+
LARK_UAT_EXPIRES: 5000,
|
|
196
|
+
});
|
|
197
|
+
const client = { _uat: 'same.token.value', _uatRefresh: 'same.refresh.value', _uatExpires: 5000 };
|
|
198
|
+
const r = uat.adoptPersistedUATIfNewer(client);
|
|
199
|
+
assert.strictEqual(r, false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await ok('adoptPersistedUATIfNewer: newer access token → adopts', () => {
|
|
203
|
+
writeCanonical({
|
|
204
|
+
LARK_USER_ACCESS_TOKEN: 'new.access.token',
|
|
205
|
+
LARK_USER_REFRESH_TOKEN: 'old.refresh',
|
|
206
|
+
LARK_UAT_EXPIRES: 9999,
|
|
207
|
+
});
|
|
208
|
+
const client = { _uat: 'old.access', _uatRefresh: 'old.refresh', _uatExpires: 5000 };
|
|
209
|
+
const r = uat.adoptPersistedUATIfNewer(client);
|
|
210
|
+
assert.strictEqual(r, true);
|
|
211
|
+
assert.strictEqual(client._uat, 'new.access.token');
|
|
212
|
+
assert.strictEqual(client._uatExpires, 9999);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await ok('adoptPersistedUATIfNewer: rotated refresh_token → adopts', () => {
|
|
216
|
+
writeCanonical({
|
|
217
|
+
LARK_USER_ACCESS_TOKEN: 'same.access',
|
|
218
|
+
LARK_USER_REFRESH_TOKEN: 'rotated.refresh',
|
|
219
|
+
LARK_UAT_EXPIRES: 5000,
|
|
220
|
+
});
|
|
221
|
+
const client = { _uat: 'same.access', _uatRefresh: 'old.refresh', _uatExpires: 5000 };
|
|
222
|
+
const r = uat.adoptPersistedUATIfNewer(client);
|
|
223
|
+
assert.strictEqual(r, true);
|
|
224
|
+
assert.strictEqual(client._uatRefresh, 'rotated.refresh');
|
|
225
|
+
});
|
|
226
|
+
} finally {
|
|
227
|
+
os.homedir = origHomedir;
|
|
228
|
+
// Restore the LARK_* env vars we cleared for this test.
|
|
229
|
+
for (const k of SNAP_KEYS) {
|
|
230
|
+
if (envSnapshot[k] === undefined) delete process.env[k];
|
|
231
|
+
else process.env[k] = envSnapshot[k];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// --- refreshUAT: invalid_grant → err.uatRevoked = true ---
|
|
236
|
+
//
|
|
237
|
+
// Monkey-patch global.fetch to simulate Feishu refresh responses without
|
|
238
|
+
// touching the network. fetchWithTimeout in utils.js delegates to global.fetch.
|
|
239
|
+
|
|
240
|
+
await ok('refreshUAT: invalid_grant throws err.uatRevoked=true', async () => {
|
|
241
|
+
os.homedir = () => fakeHome;
|
|
242
|
+
writeCanonical({
|
|
243
|
+
LARK_USER_ACCESS_TOKEN: 'expired.token',
|
|
244
|
+
LARK_USER_REFRESH_TOKEN: 'dead.refresh',
|
|
245
|
+
LARK_UAT_EXPIRES: Math.floor(Date.now() / 1000) - 3600, // 1h ago
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const origFetch = global.fetch;
|
|
249
|
+
global.fetch = async () => ({
|
|
250
|
+
json: async () => ({ error: 'invalid_grant', error_description: 'refresh_token expired' }),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const client = {
|
|
255
|
+
appId: 'test', appSecret: 'test',
|
|
256
|
+
_uat: 'expired.token',
|
|
257
|
+
_uatRefresh: 'dead.refresh',
|
|
258
|
+
_uatExpires: Math.floor(Date.now() / 1000) - 3600,
|
|
259
|
+
};
|
|
260
|
+
let thrown = null;
|
|
261
|
+
try {
|
|
262
|
+
await uat.refreshUAT(client);
|
|
263
|
+
} catch (e) {
|
|
264
|
+
thrown = e;
|
|
265
|
+
}
|
|
266
|
+
assert.ok(thrown, 'refreshUAT should throw');
|
|
267
|
+
assert.strictEqual(thrown.uatRevoked, true, 'err.uatRevoked must be true');
|
|
268
|
+
assert.ok(thrown.message.includes('invalid_grant') || thrown.message.includes('refresh_token'),
|
|
269
|
+
`error message should reference invalid_grant: ${thrown.message}`);
|
|
270
|
+
// Critically: error message must NOT contain the raw response body /
|
|
271
|
+
// refresh_token bytes. v1.3.14 redact regression guard.
|
|
272
|
+
assert.ok(!thrown.message.includes('dead.refresh'), 'error message must not echo refresh_token');
|
|
273
|
+
} finally {
|
|
274
|
+
global.fetch = origFetch;
|
|
275
|
+
os.homedir = origHomedir;
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// --- refreshUAT: benign rotation race must self-heal, not false-revoke ---
|
|
280
|
+
//
|
|
281
|
+
// A peer process wins the refresh_token rotation and persists a fresh, VALID
|
|
282
|
+
// token to disk DURING our refresh round-trip; our now-stale refresh_token
|
|
283
|
+
// then comes back invalid_grant. refreshUAT must adopt the peer's on-disk
|
|
284
|
+
// token and recover, instead of throwing uatRevoked (which would push the
|
|
285
|
+
// user through a needless `npx feishu-user-plugin oauth` re-consent — the
|
|
286
|
+
// root cause of the "授权操作通知 没撑过一晚上" reports).
|
|
287
|
+
await ok('refreshUAT: invalid_grant + peer rotated fresh token to disk → adopts & recovers (no false revoke)', async () => {
|
|
288
|
+
os.homedir = () => fakeHome;
|
|
289
|
+
const now = Math.floor(Date.now() / 1000);
|
|
290
|
+
// Disk starts equal to our stale in-memory token so the pre-fetch adopt
|
|
291
|
+
// checks find nothing newer and we proceed into the refresh fetch.
|
|
292
|
+
writeCanonical({
|
|
293
|
+
LARK_USER_ACCESS_TOKEN: 'stale.access',
|
|
294
|
+
LARK_USER_REFRESH_TOKEN: 'stale.refresh',
|
|
295
|
+
LARK_UAT_EXPIRES: String(now - 3600),
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const origFetch = global.fetch;
|
|
299
|
+
global.fetch = async () => {
|
|
300
|
+
// The winning peer finishes mid-round-trip: persists a fresh VALID token.
|
|
301
|
+
writeCanonical({
|
|
302
|
+
LARK_USER_ACCESS_TOKEN: 'winner.access',
|
|
303
|
+
LARK_USER_REFRESH_TOKEN: 'winner.refresh',
|
|
304
|
+
LARK_UAT_EXPIRES: String(now + 7200),
|
|
305
|
+
});
|
|
306
|
+
// Our stale refresh_token was rotated away on the Feishu side.
|
|
307
|
+
return { json: async () => ({ error: 'invalid_grant', error_description: 'refresh_token expired' }) };
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const client = {
|
|
312
|
+
appId: 'test', appSecret: 'test',
|
|
313
|
+
_uat: 'stale.access',
|
|
314
|
+
_uatRefresh: 'stale.refresh',
|
|
315
|
+
_uatExpires: now - 3600,
|
|
316
|
+
};
|
|
317
|
+
let thrown = null, ret = null;
|
|
318
|
+
try { ret = await uat.refreshUAT(client); } catch (e) { thrown = e; }
|
|
319
|
+
assert.ok(!thrown, `should recover, not throw; got: ${thrown && thrown.message}`);
|
|
320
|
+
assert.strictEqual(ret, 'winner.access', 'should return the peer-rotated access token');
|
|
321
|
+
assert.strictEqual(client._uatRefresh, 'winner.refresh', 'should adopt the peer-rotated refresh_token');
|
|
322
|
+
} finally {
|
|
323
|
+
global.fetch = origFetch;
|
|
324
|
+
os.homedir = origHomedir;
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Codex review (PR #111) edge: a peer in-process refresh / credentials-monitor
|
|
329
|
+
// hot-reloads THIS client in memory (and persists to disk) WHILE our refresh
|
|
330
|
+
// request — carrying the now-stale token — is still in flight. Because we
|
|
331
|
+
// snapshot the sent token before awaiting and gate recovery on client state
|
|
332
|
+
// (not adoptPersistedUATIfNewer's return value), we must still recover instead
|
|
333
|
+
// of false-revoking. With the pre-fix code (post-await capture) this throws.
|
|
334
|
+
await ok('refreshUAT: invalid_grant after mid-flight hot-reload to a fresh token → recovers (no false revoke)', async () => {
|
|
335
|
+
os.homedir = () => fakeHome;
|
|
336
|
+
const now = Math.floor(Date.now() / 1000);
|
|
337
|
+
// Disk starts stale so the pre-fetch adopt checks proceed into the fetch.
|
|
338
|
+
writeCanonical({
|
|
339
|
+
LARK_USER_ACCESS_TOKEN: 'stale.access',
|
|
340
|
+
LARK_USER_REFRESH_TOKEN: 'stale.refresh',
|
|
341
|
+
LARK_UAT_EXPIRES: String(now - 3600),
|
|
342
|
+
});
|
|
343
|
+
const client = {
|
|
344
|
+
appId: 'test', appSecret: 'test',
|
|
345
|
+
_uat: 'stale.access', _uatRefresh: 'stale.refresh', _uatExpires: now - 3600,
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const origFetch = global.fetch;
|
|
349
|
+
global.fetch = async () => {
|
|
350
|
+
// Concurrent winner finishes mid-flight: persists rotated token to disk
|
|
351
|
+
// AND a hot-reload updates this very client in memory.
|
|
352
|
+
writeCanonical({
|
|
353
|
+
LARK_USER_ACCESS_TOKEN: 'winner.access',
|
|
354
|
+
LARK_USER_REFRESH_TOKEN: 'winner.refresh',
|
|
355
|
+
LARK_UAT_EXPIRES: String(now + 7200),
|
|
356
|
+
});
|
|
357
|
+
client._uat = 'winner.access';
|
|
358
|
+
client._uatRefresh = 'winner.refresh';
|
|
359
|
+
client._uatExpires = now + 7200;
|
|
360
|
+
return { json: async () => ({ error: 'invalid_grant', error_description: 'refresh_token expired' }) };
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
let thrown = null, ret = null;
|
|
365
|
+
try { ret = await uat.refreshUAT(client); } catch (e) { thrown = e; }
|
|
366
|
+
assert.ok(!thrown, `should recover, not throw; got: ${thrown && thrown.message}`);
|
|
367
|
+
assert.strictEqual(ret, 'winner.access', 'should recover the winner token despite mid-flight hot-reload');
|
|
368
|
+
} finally {
|
|
369
|
+
global.fetch = origFetch;
|
|
370
|
+
os.homedir = origHomedir;
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// identity-state.js redact-regex regression guard. Exercises the actual
|
|
375
|
+
// regex on `_classifyUatFailure` path that the previous "no dead.refresh"
|
|
376
|
+
// assertion in test #14 did NOT cover (the invalid_grant message is
|
|
377
|
+
// hard-coded with no interpolation, so it's trivially redacted).
|
|
378
|
+
await ok('identity-state _classifyUatFailure: redact regex strips long token-like strings from uatError.message', () => {
|
|
379
|
+
const { _classifyUatFailure } = require('./auth/identity-state');
|
|
380
|
+
// A realistic-looking JWT-like base64-ish string > 40 chars.
|
|
381
|
+
const longToken = 'eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3Nzk0MDAwMDB9.abc123def456ghi789jklmnoXYZabcdefgXXXX';
|
|
382
|
+
assert.ok(longToken.length >= 40, 'fixture token should be >= 40 chars to trigger redact');
|
|
383
|
+
const fakeErr = new Error(`UAT request failed: server returned: ${longToken}`);
|
|
384
|
+
const cls = _classifyUatFailure(null, fakeErr);
|
|
385
|
+
assert.ok(cls, 'should classify a uatError');
|
|
386
|
+
assert.ok(!cls.viaReason.includes(longToken), `viaReason must not contain raw long token; got: ${cls.viaReason}`);
|
|
387
|
+
assert.ok(cls.viaReason.includes('<redacted>'), `viaReason must contain '<redacted>' marker; got: ${cls.viaReason}`);
|
|
388
|
+
assert.strictEqual(cls.state, null, 'non-uatRevoked errors should not refine identity from this path');
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
await ok('identity-state _classifyUatFailure: uatRevoked flag short-circuits to UAT_REVOKED state', () => {
|
|
392
|
+
const { _classifyUatFailure, IdentityState } = require('./auth/identity-state');
|
|
393
|
+
const err = new Error('UAT refresh_token rejected by Feishu (invalid_grant). The 7-day refresh chain is broken. Run: npx feishu-user-plugin oauth to re-authorize.');
|
|
394
|
+
err.uatRevoked = true;
|
|
395
|
+
const cls = _classifyUatFailure(null, err);
|
|
396
|
+
assert.strictEqual(cls.state, IdentityState.UAT_REVOKED, 'uatRevoked flag must refine state to UAT_REVOKED');
|
|
397
|
+
assert.ok(cls.viaReason.includes('invalid_grant') || cls.viaReason.includes('rejected'),
|
|
398
|
+
`viaReason must mention rejection: ${cls.viaReason}`);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await ok('refreshUAT: non-invalid_grant error does NOT set uatRevoked', async () => {
|
|
402
|
+
os.homedir = () => fakeHome;
|
|
403
|
+
writeCanonical({
|
|
404
|
+
LARK_USER_ACCESS_TOKEN: 'expired.token',
|
|
405
|
+
LARK_USER_REFRESH_TOKEN: 'live.refresh',
|
|
406
|
+
LARK_UAT_EXPIRES: Math.floor(Date.now() / 1000) - 3600,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const origFetch = global.fetch;
|
|
410
|
+
global.fetch = async () => ({
|
|
411
|
+
json: async () => ({ code: 99991663, msg: 'token transient error' }),
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
const client = {
|
|
416
|
+
appId: 'test', appSecret: 'test',
|
|
417
|
+
_uat: 'expired.token',
|
|
418
|
+
_uatRefresh: 'live.refresh',
|
|
419
|
+
_uatExpires: Math.floor(Date.now() / 1000) - 3600,
|
|
420
|
+
};
|
|
421
|
+
let thrown = null;
|
|
422
|
+
try {
|
|
423
|
+
await uat.refreshUAT(client);
|
|
424
|
+
} catch (e) {
|
|
425
|
+
thrown = e;
|
|
426
|
+
}
|
|
427
|
+
assert.ok(thrown, 'should throw on non-success');
|
|
428
|
+
assert.ok(!thrown.uatRevoked, 'transient error should not set uatRevoked');
|
|
429
|
+
assert.ok(thrown.message.includes('99991663') || thrown.message.includes('transient'),
|
|
430
|
+
`should mention specific error: ${thrown.message}`);
|
|
431
|
+
} finally {
|
|
432
|
+
global.fetch = origFetch;
|
|
433
|
+
os.homedir = origHomedir;
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// --- Cleanup ---
|
|
438
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
|
439
|
+
try { fs.rmSync(fakeHome, { recursive: true, force: true }); } catch (_) {}
|
|
440
|
+
|
|
441
|
+
console.log(`\n=== test-uat-lifecycle: ${pass} passed, ${fail} failed ===`);
|
|
442
|
+
if (fail > 0) process.exit(1);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (require.main === module) {
|
|
446
|
+
run().catch((e) => { console.error('test-uat-lifecycle harness error:', e); process.exit(1); });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
module.exports = { run };
|
package/src/tools/docs.js
CHANGED
|
@@ -52,7 +52,7 @@ const schemas = [
|
|
|
52
52
|
},
|
|
53
53
|
{
|
|
54
54
|
name: 'manage_doc_block',
|
|
55
|
-
description: '[Official API] Manage content blocks in a document. Single tool replaces v1.3.6 create_doc_block / update_doc_block / delete_doc_blocks.\n action=create —
|
|
55
|
+
description: '[Official API] Manage content blocks in a document. Single tool replaces v1.3.6 create_doc_block / update_doc_block / delete_doc_blocks.\n action=create — six modes (pass exactly ONE):\n (A) Generic — pass `children` array (e.g. [{block_type:2, text:{...}}]).\n (B) Image from local file — pass `image_path`; plugin uploads and patches.\n (C) Image from token — pass `image_token` (already uploaded).\n (D) File attachment from local file — pass `file_path`; plugin handles VIEW-wrap + replace_file.\n (E) File from token — pass `file_token`.\n (F) Table — pass `table={rows,columns,cells?}`; plugin creates a block_type=31 table (Feishu auto-makes the block_type=32 cells) and fills each provided cell. USE THIS for tables — do NOT hand-build table blocks via `children` (the table block_type is 31, NOT 40; getting it wrong returns invalid_param).\n action=update — generic (pass `update_body`), image-replace (pass `image_token`), or file-replace (pass `file_token`).\n action=delete — pass `parent_block_id` + `start_index` + `end_index` (range delete).\n`document_id` accepts native ID, wiki node token, or Feishu URL.',
|
|
56
56
|
inputSchema: {
|
|
57
57
|
type: 'object',
|
|
58
58
|
properties: {
|
|
@@ -69,6 +69,7 @@ const schemas = [
|
|
|
69
69
|
file_path: { type: 'string', description: 'Local file path — create mode D (mutually exclusive with other create modes).' },
|
|
70
70
|
file_token: { type: 'string', description: 'Pre-uploaded docx file token — create mode E, or update file-replace.' },
|
|
71
71
|
update_body: { type: 'object', description: 'Generic update payload for action=update. E.g. {update_text_elements:{elements:[{text_run:{content:"new text"}}]}}.' },
|
|
72
|
+
table: { type: 'object', description: 'Create a table — create mode F (mutually exclusive with other create modes). Shape: {rows:int>=1, columns:int>=1, cells?:string[][] (row-major plain text; omit/empty-string to leave a cell blank), column_width?:int[] (px, length=columns), header_row?:bool, header_column?:bool}. The plugin creates a block_type=31 table, lets Feishu auto-create the cells, and fills each provided cell by updating its text — you never specify block types. Returns {tableBlockId, cells:[[cellId,...]] (row-major grid), filled}. Example: {"rows":2,"columns":2,"cells":[["Name","Role"],["Ann","PM"]]}.' },
|
|
72
73
|
},
|
|
73
74
|
required: ['action', 'document_id'],
|
|
74
75
|
},
|
|
@@ -121,8 +122,16 @@ const handlers = {
|
|
|
121
122
|
switch (args.action) {
|
|
122
123
|
case 'create': {
|
|
123
124
|
need(args.parent_block_id, 'parent_block_id', 'create');
|
|
124
|
-
const modes = [args.children, args.image_path, args.image_token, args.file_path, args.file_token].filter(Boolean);
|
|
125
|
-
if (modes.length > 1) return text('manage_doc_block(create): pass exactly ONE of children / image_path / image_token / file_path / file_token.');
|
|
125
|
+
const modes = [args.children, args.image_path, args.image_token, args.file_path, args.file_token, args.table].filter(Boolean);
|
|
126
|
+
if (modes.length > 1) return text('manage_doc_block(create): pass exactly ONE of children / image_path / image_token / file_path / file_token / table.');
|
|
127
|
+
if (args.table) {
|
|
128
|
+
const t = args.table;
|
|
129
|
+
return json(await official.createDocTable(docId, args.parent_block_id, {
|
|
130
|
+
rows: t.rows, columns: t.columns, cells: t.cells,
|
|
131
|
+
columnWidth: t.column_width, headerRow: t.header_row, headerColumn: t.header_column,
|
|
132
|
+
index: args.index,
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
126
135
|
if (args.image_path || args.image_token) {
|
|
127
136
|
const r = await official.createDocBlockWithImage(docId, args.parent_block_id, {
|
|
128
137
|
imagePath: args.image_path,
|
|
@@ -139,7 +148,7 @@ const handlers = {
|
|
|
139
148
|
});
|
|
140
149
|
return json(r);
|
|
141
150
|
}
|
|
142
|
-
if (!args.children) return text('manage_doc_block(create): children, image_path, image_token, file_path, or
|
|
151
|
+
if (!args.children) return text('manage_doc_block(create): children, image_path, image_token, file_path, file_token, or table is required.');
|
|
143
152
|
return json(await official.createDocBlock(docId, args.parent_block_id, args.children, args.index));
|
|
144
153
|
}
|
|
145
154
|
case 'update': {
|
package/src/oauth-auto.js
DELETED
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// DEV ONLY: Automated OAuth using local Playwright (not used in production).
|
|
3
|
-
// Uses .env directly; not migrated to config module.
|
|
4
|
-
// Requires: npm install playwright (not in package.json dependencies)
|
|
5
|
-
const http = require('http');
|
|
6
|
-
const { chromium } = require('playwright');
|
|
7
|
-
const fs = require('fs');
|
|
8
|
-
const path = require('path');
|
|
9
|
-
const dotenv = require('dotenv');
|
|
10
|
-
|
|
11
|
-
dotenv.config({ path: path.join(__dirname, '..', '.env') });
|
|
12
|
-
|
|
13
|
-
const APP_ID = process.env.LARK_APP_ID;
|
|
14
|
-
const APP_SECRET = process.env.LARK_APP_SECRET;
|
|
15
|
-
const COOKIE_STR = process.env.LARK_COOKIE;
|
|
16
|
-
const PORT = 9997;
|
|
17
|
-
const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`;
|
|
18
|
-
const SCOPES = 'offline_access im:message im:message:readonly im:chat:readonly contact:user.base:readonly';
|
|
19
|
-
|
|
20
|
-
function parseCookies(cookieStr) {
|
|
21
|
-
return cookieStr.split(';').map(c => {
|
|
22
|
-
const [name, ...rest] = c.trim().split('=');
|
|
23
|
-
return { name: name.trim(), value: rest.join('=').trim(), domain: '.feishu.cn', path: '/' };
|
|
24
|
-
}).filter(c => c.name && c.value);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function saveToken(tokenData) {
|
|
28
|
-
const envPath = path.join(__dirname, '..', '.env');
|
|
29
|
-
let envContent = '';
|
|
30
|
-
try { envContent = fs.readFileSync(envPath, 'utf8'); } catch {}
|
|
31
|
-
const updates = {
|
|
32
|
-
LARK_USER_ACCESS_TOKEN: tokenData.access_token,
|
|
33
|
-
LARK_USER_REFRESH_TOKEN: tokenData.refresh_token || '',
|
|
34
|
-
LARK_UAT_SCOPE: tokenData.scope || '',
|
|
35
|
-
LARK_UAT_EXPIRES: String(Math.floor(Date.now() / 1000 + (typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200))),
|
|
36
|
-
};
|
|
37
|
-
for (const [key, val] of Object.entries(updates)) {
|
|
38
|
-
const regex = new RegExp(`^${key}=.*$`, 'm');
|
|
39
|
-
if (regex.test(envContent)) {
|
|
40
|
-
envContent = envContent.replace(regex, `${key}=${val}`);
|
|
41
|
-
} else {
|
|
42
|
-
envContent += `\n${key}=${val}`;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
fs.writeFileSync(envPath, envContent.trim() + '\n');
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
async function exchangeCode(code) {
|
|
49
|
-
console.log('[token] Exchanging code via v2...');
|
|
50
|
-
const res = await fetch('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
|
|
51
|
-
method: 'POST',
|
|
52
|
-
headers: { 'content-type': 'application/json' },
|
|
53
|
-
body: JSON.stringify({ grant_type: 'authorization_code', client_id: APP_ID, client_secret: APP_SECRET, code, redirect_uri: REDIRECT_URI }),
|
|
54
|
-
});
|
|
55
|
-
const raw = await res.text();
|
|
56
|
-
console.log('[token] Response:', raw.slice(0, 300));
|
|
57
|
-
const data = JSON.parse(raw);
|
|
58
|
-
if (data.access_token) return data;
|
|
59
|
-
if (data.data?.access_token) return data.data;
|
|
60
|
-
throw new Error(`Token exchange failed: ${raw.slice(0, 200)}`);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function run() {
|
|
64
|
-
// Start callback server to capture the code
|
|
65
|
-
let resolveCode;
|
|
66
|
-
const codePromise = new Promise(resolve => { resolveCode = resolve; });
|
|
67
|
-
|
|
68
|
-
const server = http.createServer((req, res) => {
|
|
69
|
-
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
|
70
|
-
if (url.pathname === '/callback') {
|
|
71
|
-
const code = url.searchParams.get('code');
|
|
72
|
-
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
73
|
-
res.end(code ? '<h2>OK</h2>' : '<h2>No code</h2>');
|
|
74
|
-
if (code) resolveCode(code);
|
|
75
|
-
} else {
|
|
76
|
-
res.writeHead(404); res.end();
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
server.listen(PORT, '127.0.0.1');
|
|
80
|
-
console.log(`[server] Listening on port ${PORT}`);
|
|
81
|
-
|
|
82
|
-
// Launch browser — use the first page directly (no extra about:blank)
|
|
83
|
-
const browser = await chromium.launch({ headless: false });
|
|
84
|
-
const context = await browser.newContext({ viewport: { width: 1200, height: 800 } });
|
|
85
|
-
await context.addCookies(parseCookies(COOKIE_STR));
|
|
86
|
-
|
|
87
|
-
// Listen for any new page (popup) that might open
|
|
88
|
-
context.on('page', async newPage => {
|
|
89
|
-
const url = newPage.url();
|
|
90
|
-
console.log('[context] New page opened:', url.slice(0, 200));
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
// Get the default page instead of creating a new one
|
|
94
|
-
const pages = context.pages();
|
|
95
|
-
const page = pages.length > 0 ? pages[0] : await context.newPage();
|
|
96
|
-
page.setDefaultTimeout(30000);
|
|
97
|
-
|
|
98
|
-
try {
|
|
99
|
-
const authUrl = `https://accounts.feishu.cn/open-apis/authen/v1/authorize?client_id=${APP_ID}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&response_type=code&scope=${encodeURIComponent(SCOPES)}`;
|
|
100
|
-
console.log('\n[auth] Opening authorize URL...');
|
|
101
|
-
|
|
102
|
-
// Use route interception to capture the callback redirect directly
|
|
103
|
-
let codeFromRoute = null;
|
|
104
|
-
await context.route('**/callback**', async route => {
|
|
105
|
-
const url = route.request().url();
|
|
106
|
-
console.log('[route] Intercepted callback:', url.slice(0, 200));
|
|
107
|
-
const parsed = new URL(url);
|
|
108
|
-
const code = parsed.searchParams.get('code');
|
|
109
|
-
if (code) {
|
|
110
|
-
codeFromRoute = code;
|
|
111
|
-
resolveCode(code);
|
|
112
|
-
}
|
|
113
|
-
await route.continue();
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
await page.goto(authUrl, { waitUntil: 'load' });
|
|
117
|
-
await page.waitForTimeout(2000);
|
|
118
|
-
|
|
119
|
-
const currentUrl = page.url();
|
|
120
|
-
console.log('[auth] Current URL:', currentUrl.slice(0, 200));
|
|
121
|
-
|
|
122
|
-
if (currentUrl.includes('callback?code=')) {
|
|
123
|
-
console.log('[auth] Auto-authorized!');
|
|
124
|
-
} else {
|
|
125
|
-
// Find and click authorize button
|
|
126
|
-
const authorizeBtn = page.locator('button:has-text("授权")').first();
|
|
127
|
-
if (await authorizeBtn.isVisible().catch(() => false)) {
|
|
128
|
-
console.log('[auth] Found 授权 button, clicking...');
|
|
129
|
-
await authorizeBtn.click();
|
|
130
|
-
console.log('[auth] Clicked, waiting for redirect...');
|
|
131
|
-
await page.waitForTimeout(5000);
|
|
132
|
-
console.log('[auth] After click URL:', page.url().slice(0, 200));
|
|
133
|
-
|
|
134
|
-
// Check all pages in context
|
|
135
|
-
const allPages = context.pages();
|
|
136
|
-
console.log(`[auth] Total pages: ${allPages.length}`);
|
|
137
|
-
for (let i = 0; i < allPages.length; i++) {
|
|
138
|
-
const pUrl = allPages[i].url();
|
|
139
|
-
console.log(` [${i}] ${pUrl.slice(0, 200)}`);
|
|
140
|
-
if (pUrl.includes('callback?code=')) {
|
|
141
|
-
const parsed = new URL(pUrl);
|
|
142
|
-
resolveCode(parsed.searchParams.get('code'));
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
} else {
|
|
146
|
-
console.log('[auth] No authorize button found!');
|
|
147
|
-
await page.screenshot({ path: '/tmp/feishu-oauth-nobutton.png' });
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Wait for the code
|
|
152
|
-
console.log('\n[token] Waiting for code...');
|
|
153
|
-
const code = await Promise.race([
|
|
154
|
-
codePromise,
|
|
155
|
-
new Promise((_, rej) => setTimeout(() => rej(new Error('Timeout (30s)')), 30000)),
|
|
156
|
-
]);
|
|
157
|
-
console.log('[token] Got code:', code.slice(0, 20) + '...');
|
|
158
|
-
|
|
159
|
-
const tokenData = await exchangeCode(code);
|
|
160
|
-
saveToken(tokenData);
|
|
161
|
-
console.log('\n=== SUCCESS ===');
|
|
162
|
-
console.log('access_token:', tokenData.access_token?.slice(0, 30) + '...');
|
|
163
|
-
console.log('scope:', tokenData.scope);
|
|
164
|
-
console.log('expires_in:', tokenData.expires_in, 's');
|
|
165
|
-
|
|
166
|
-
} catch (e) {
|
|
167
|
-
console.error('\nError:', e.message);
|
|
168
|
-
await page.screenshot({ path: '/tmp/feishu-oauth-error.png' }).catch(() => {});
|
|
169
|
-
} finally {
|
|
170
|
-
await browser.close();
|
|
171
|
-
server.close();
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
run();
|