feishu-user-plugin 1.3.13 → 1.3.14
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 +74 -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/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 +86 -7
- package/src/cli.js +4 -3
- package/src/clients/official/base.js +12 -7
- 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 +11 -0
- package/src/test-comprehensive.js +7 -2
- package/src/test-cookie-heartbeat.js +132 -0
- package/src/test-uat-lifecycle.js +354 -0
- package/src/oauth-auto.js +0 -175
package/src/auth/uat.js
CHANGED
|
@@ -28,13 +28,36 @@ const path = require('path');
|
|
|
28
28
|
const os = require('os');
|
|
29
29
|
const { fetchWithTimeout } = require('../utils');
|
|
30
30
|
|
|
31
|
+
// One-warning-per-malformed-token tracker. Without this, a persistently bad
|
|
32
|
+
// JWT would flood stderr every tool call (getValidUAT calls decode whenever
|
|
33
|
+
// _uatExpires falsy, which it stays at 0 if decode returns 0). 1024-entry cap
|
|
34
|
+
// is conservative — real tokens are rotated rarely; cap prevents OOM in the
|
|
35
|
+
// unlikely event of a malformed-token spray.
|
|
36
|
+
const _warnedMalformedTokens = new Set();
|
|
37
|
+
function _markWarnedMalformedToken(token) {
|
|
38
|
+
if (typeof token !== 'string' || token.length === 0) return false;
|
|
39
|
+
const key = require('crypto').createHash('sha256').update(token, 'utf8').digest('hex').slice(0, 16);
|
|
40
|
+
if (_warnedMalformedTokens.has(key)) return false;
|
|
41
|
+
if (_warnedMalformedTokens.size >= 1024) _warnedMalformedTokens.clear();
|
|
42
|
+
_warnedMalformedTokens.add(key);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
function decodeTokenExpiry(token) {
|
|
32
47
|
try {
|
|
33
48
|
const payload = token?.split('.')?.[1];
|
|
34
49
|
if (!payload) return 0;
|
|
35
50
|
const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
36
51
|
return typeof data.exp === 'number' ? data.exp : 0;
|
|
37
|
-
} catch (
|
|
52
|
+
} catch (e) {
|
|
53
|
+
// Log breadcrumb so silently-malformed UATs are observable, but only once
|
|
54
|
+
// per distinct bad token (hashed). We still return 0 (caller treats 0 as
|
|
55
|
+
// "never decoded — let refresh path decide") instead of throwing, because
|
|
56
|
+
// a bad JWT shouldn't crash tool dispatch — the next 99991663/99991668
|
|
57
|
+
// response will trigger refresh anyway.
|
|
58
|
+
if (_markWarnedMalformedToken(token)) {
|
|
59
|
+
console.error(`[feishu-user-plugin] decodeTokenExpiry: malformed JWT payload (${e.message}); will rely on Feishu rejection for refresh trigger`);
|
|
60
|
+
}
|
|
38
61
|
return 0;
|
|
39
62
|
}
|
|
40
63
|
}
|
|
@@ -73,7 +96,12 @@ function adoptPersistedUATIfNewer(client) {
|
|
|
73
96
|
}
|
|
74
97
|
|
|
75
98
|
function uatLockPath() {
|
|
76
|
-
|
|
99
|
+
// v1.3.14: moved from ~/.claude/feishu-uat-refresh.lock to canonical
|
|
100
|
+
// ~/.feishu-user-plugin/ so Codex-only / non-Claude-Code users (whose
|
|
101
|
+
// ~/.claude/ may not exist) still get cross-process mutual exclusion.
|
|
102
|
+
// Mixed-version concern is N/A — running two different versions of this
|
|
103
|
+
// plugin in parallel is not a supported configuration.
|
|
104
|
+
return path.join(os.homedir(), '.feishu-user-plugin', 'uat-refresh.lock');
|
|
77
105
|
}
|
|
78
106
|
|
|
79
107
|
async function acquireRefreshLock(lockPath, { staleMs = 30000, pollMs = 200, timeoutMs = 20000 } = {}) {
|
|
@@ -105,13 +133,32 @@ function releaseRefreshLock(lockPath) {
|
|
|
105
133
|
}
|
|
106
134
|
|
|
107
135
|
async function refreshUAT(client) {
|
|
136
|
+
// v1.3.14 — Pre-lock cheap path: maybe a peer process already refreshed and
|
|
137
|
+
// persisted a valid token. Adopt it before paying for the file lock. This
|
|
138
|
+
// dramatically reduces lock contention in deployments with 10+ concurrent
|
|
139
|
+
// MCP server processes (one per Claude Code session).
|
|
140
|
+
let now = Math.floor(Date.now() / 1000);
|
|
141
|
+
if (adoptPersistedUATIfNewer(client) && client._uatExpires > now + 300) {
|
|
142
|
+
return client._uat;
|
|
143
|
+
}
|
|
144
|
+
|
|
108
145
|
const lockPath = uatLockPath();
|
|
109
146
|
const acquired = await acquireRefreshLock(lockPath);
|
|
110
147
|
if (!acquired) {
|
|
111
|
-
|
|
148
|
+
// Lock timed out (>20s of contention). Before falling through to an
|
|
149
|
+
// un-coordinated refresh — which can burn the refresh_token chain on the
|
|
150
|
+
// Feishu side — give disk one more chance: a peer may have written a
|
|
151
|
+
// fresh token between our pre-check and now.
|
|
152
|
+
now = Math.floor(Date.now() / 1000);
|
|
153
|
+
if (adoptPersistedUATIfNewer(client) && client._uatExpires > now + 300) {
|
|
154
|
+
return client._uat;
|
|
155
|
+
}
|
|
156
|
+
console.error('[feishu-user-plugin] UAT refresh lock timed out; proceeding without mutual exclusion (this may burn refresh_token chain — investigate if it happens often)');
|
|
112
157
|
}
|
|
113
158
|
try {
|
|
114
|
-
|
|
159
|
+
// Inside the lock: re-check disk one more time. Between acquireRefreshLock
|
|
160
|
+
// returning and this point, another holder may have released after writing.
|
|
161
|
+
now = Math.floor(Date.now() / 1000);
|
|
115
162
|
if (adoptPersistedUATIfNewer(client) && client._uatExpires > now + 300) {
|
|
116
163
|
return client._uat;
|
|
117
164
|
}
|
|
@@ -128,7 +175,28 @@ async function refreshUAT(client) {
|
|
|
128
175
|
});
|
|
129
176
|
const data = await res.json();
|
|
130
177
|
const tokenData = data.access_token ? data : data.data;
|
|
131
|
-
if (!tokenData?.access_token)
|
|
178
|
+
if (!tokenData?.access_token) {
|
|
179
|
+
// v1.3.14 — never dump the raw response body. Some Feishu error paths
|
|
180
|
+
// echo back portions of the request including refresh_token bytes, and
|
|
181
|
+
// this message bubbles up to Error.message → MCP content[0].text →
|
|
182
|
+
// LLM transcript. Surface only the structured error code/msg.
|
|
183
|
+
const errCode = data?.error ?? data?.code ?? 'unknown';
|
|
184
|
+
const errMsg = data?.error_description ?? data?.msg ?? '(no error message from Feishu)';
|
|
185
|
+
// Distinguish refresh_token rejection (must re-oauth) from transient
|
|
186
|
+
// server-side errors so the identity state machine can flip to
|
|
187
|
+
// UAT_REVOKED, and withIdentityFallback can give the LLM clear guidance.
|
|
188
|
+
const isInvalidGrant = errCode === 'invalid_grant' || errCode === 20064;
|
|
189
|
+
if (isInvalidGrant) {
|
|
190
|
+
try {
|
|
191
|
+
const { _refineIdentity, IdentityState } = require('./identity-state');
|
|
192
|
+
_refineIdentity(client, IdentityState.UAT_REVOKED);
|
|
193
|
+
} catch (_) { /* identity-state may not be loaded in CLI subcommands; non-fatal */ }
|
|
194
|
+
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.');
|
|
195
|
+
err.uatRevoked = true;
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
198
|
+
throw new Error(`UAT refresh failed (code=${errCode}: ${errMsg}). If persistent, run: npx feishu-user-plugin oauth.`);
|
|
199
|
+
}
|
|
132
200
|
client._uat = tokenData.access_token;
|
|
133
201
|
client._uatRefresh = tokenData.refresh_token || client._uatRefresh;
|
|
134
202
|
const expiresIn = typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200;
|
|
@@ -164,9 +232,15 @@ async function withUAT(client, fn) {
|
|
|
164
232
|
} catch (err) {
|
|
165
233
|
const cls = classifyError(err);
|
|
166
234
|
if (cls.action === 'retry') {
|
|
167
|
-
|
|
235
|
+
// v1.3.14 — fall through into the auth-code check below instead of
|
|
236
|
+
// returning the retry result raw. A token rotated between our first
|
|
237
|
+
// attempt and the retry (peer process refreshed) can manifest as a
|
|
238
|
+
// 99991663/99991668 in the retry response, and we want refreshUAT to
|
|
239
|
+
// run before bubbling that up as a hard failure.
|
|
240
|
+
data = await fn(uat);
|
|
241
|
+
} else {
|
|
242
|
+
throw err;
|
|
168
243
|
}
|
|
169
|
-
throw err;
|
|
170
244
|
}
|
|
171
245
|
|
|
172
246
|
if (data.code === 99991668 || data.code === 99991663 || data.code === 99991677) {
|
|
@@ -242,4 +316,9 @@ module.exports = {
|
|
|
242
316
|
asUserOrApp,
|
|
243
317
|
persistUAT,
|
|
244
318
|
adoptPersistedUATIfNewer,
|
|
319
|
+
// v1.3.14 — exposed for testing (lifecycle + race tests). Not part of the
|
|
320
|
+
// stable API; do not use outside src/test-* or scripts/test-* harnesses.
|
|
321
|
+
uatLockPath,
|
|
322
|
+
acquireRefreshLock,
|
|
323
|
+
releaseRefreshLock,
|
|
245
324
|
};
|
package/src/cli.js
CHANGED
|
@@ -248,9 +248,10 @@ async function keepalive() {
|
|
|
248
248
|
const needSwitch = all && prevActive !== profileName;
|
|
249
249
|
try {
|
|
250
250
|
if (needSwitch) cred.setActiveProfile(profileName);
|
|
251
|
-
//
|
|
252
|
-
process.env
|
|
253
|
-
process.env
|
|
251
|
+
// v1.3.14 — direct field assignment is the source of truth; do NOT
|
|
252
|
+
// also set process.env (previous comment claimed LarkOfficialClient
|
|
253
|
+
// would read process.env, but loadUAT() is dead code and process.env
|
|
254
|
+
// pollution leaked between iterations of the --all loop).
|
|
254
255
|
const official = new LarkOfficialClient(env.LARK_APP_ID, env.LARK_APP_SECRET);
|
|
255
256
|
official._uat = env.LARK_USER_ACCESS_TOKEN;
|
|
256
257
|
official._uatRefresh = env.LARK_USER_REFRESH_TOKEN;
|
|
@@ -21,16 +21,21 @@ class LarkOfficialClient {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
// --- UAT (User Access Token) Management ---
|
|
24
|
+
//
|
|
25
|
+
// v1.3.14 — preferred entry is src/server.js::loadUATFromEnv(client, env),
|
|
26
|
+
// which reads from a specific env block (credentials.json profile or harness
|
|
27
|
+
// env) rather than from process.env. `loadUAT()` below is kept for backward
|
|
28
|
+
// compat with `src/test-all.js` and any external callers, but new code in
|
|
29
|
+
// this repo should NOT use it — it can't see credentials.json profiles and
|
|
30
|
+
// doesn't participate in the profile-switch hot-reload path.
|
|
24
31
|
|
|
32
|
+
/** @deprecated v1.3.14 — use server.loadUATFromEnv(client, env) instead. */
|
|
25
33
|
loadUAT() {
|
|
26
34
|
const token = process.env.LARK_USER_ACCESS_TOKEN;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
this._uatRefresh = refresh || null;
|
|
32
|
-
this._uatExpires = expires || this._decodeTokenExpiry(token);
|
|
33
|
-
}
|
|
35
|
+
if (!token) return;
|
|
36
|
+
this._uat = token;
|
|
37
|
+
this._uatRefresh = process.env.LARK_USER_REFRESH_TOKEN || null;
|
|
38
|
+
this._uatExpires = parseInt(process.env.LARK_UAT_EXPIRES || '0') || this._decodeTokenExpiry(token);
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
get hasUAT() {
|
package/src/error-codes.js
CHANGED
|
@@ -27,7 +27,8 @@ const FAILURE_MAP = {
|
|
|
27
27
|
19001: { action: 'uat', reason: 'bot_chat_not_found' },
|
|
28
28
|
|
|
29
29
|
// UAT revoked — refresh_token explicitly invalid_grant (user revoked OAuth
|
|
30
|
-
// or
|
|
30
|
+
// or the 7-day refresh_token window elapsed without any successful refresh
|
|
31
|
+
// to roll it forward). The live trigger for this code lives in
|
|
31
32
|
// identity-state.js::_classifyUatFailure (UAT REST throws / returns 20064);
|
|
32
33
|
// this entry exists for *symmetry* — should a bot-side surface ever return
|
|
33
34
|
// 20064 (it shouldn't, bot uses app_access_token not refresh_token), the
|
package/src/oauth.js
CHANGED
|
@@ -16,6 +16,7 @@ const http = require('http');
|
|
|
16
16
|
const { execSync } = require('child_process');
|
|
17
17
|
const credentialsModule = require('./auth/credentials');
|
|
18
18
|
const legacyConfig = require('./config');
|
|
19
|
+
const { fetchWithTimeout } = require('./utils');
|
|
19
20
|
|
|
20
21
|
// v1.3.9: profile-aware. Accepts `--profile <name>` (defaults to credentials.json::active);
|
|
21
22
|
// reads APP_ID/SECRET from that profile, persists UAT back to that profile.
|
|
@@ -105,10 +106,11 @@ if (!APP_ID || !APP_SECRET) {
|
|
|
105
106
|
async function getAppInfo() {
|
|
106
107
|
try {
|
|
107
108
|
// Get app_access_token to query app details
|
|
108
|
-
const tokenRes = await
|
|
109
|
+
const tokenRes = await fetchWithTimeout('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', {
|
|
109
110
|
method: 'POST',
|
|
110
111
|
headers: { 'content-type': 'application/json' },
|
|
111
112
|
body: JSON.stringify({ app_id: APP_ID, app_secret: APP_SECRET }),
|
|
113
|
+
timeoutMs: 10000,
|
|
112
114
|
});
|
|
113
115
|
const tokenData = await tokenRes.json();
|
|
114
116
|
if (!tokenData.app_access_token) {
|
|
@@ -118,8 +120,9 @@ async function getAppInfo() {
|
|
|
118
120
|
|
|
119
121
|
// Get app info — try the direct app query first, fall back to underauditlist
|
|
120
122
|
let appName = null;
|
|
121
|
-
const directRes = await
|
|
123
|
+
const directRes = await fetchWithTimeout(`https://open.feishu.cn/open-apis/application/v6/applications/${APP_ID}?lang=zh_cn`, {
|
|
122
124
|
headers: { 'Authorization': `Bearer ${tokenData.app_access_token}` },
|
|
125
|
+
timeoutMs: 10000,
|
|
123
126
|
});
|
|
124
127
|
const directData = await directRes.json();
|
|
125
128
|
appName = directData?.data?.app?.app_name;
|
|
@@ -134,8 +137,9 @@ async function getAppInfo() {
|
|
|
134
137
|
} else if (directData?.code && directData.code !== 0) {
|
|
135
138
|
console.error(`[oauth] App name resolve failed: code=${directData.code} msg=${directData.msg}`);
|
|
136
139
|
}
|
|
137
|
-
const listRes = await
|
|
140
|
+
const listRes = await fetchWithTimeout('https://open.feishu.cn/open-apis/application/v6/applications/underauditlist?lang=zh_cn&page_size=1', {
|
|
138
141
|
headers: { 'Authorization': `Bearer ${tokenData.app_access_token}` },
|
|
142
|
+
timeoutMs: 10000,
|
|
139
143
|
});
|
|
140
144
|
const listData = await listRes.json();
|
|
141
145
|
appName = listData?.data?.items?.[0]?.app_name;
|
|
@@ -156,11 +160,15 @@ async function exchangeCode(code) {
|
|
|
156
160
|
code,
|
|
157
161
|
redirect_uri: REDIRECT_URI,
|
|
158
162
|
};
|
|
159
|
-
|
|
160
|
-
|
|
163
|
+
// v1.3.14 — redact `code` too: the authorization code is short-lived but
|
|
164
|
+
// it IS an exchangeable credential while it's still valid (~60s pre-exchange).
|
|
165
|
+
// Logging it to stdout means transcripts retain a usable credential window.
|
|
166
|
+
console.log('Token exchange request:', JSON.stringify({ ...body, client_secret: '***', code: '***' }));
|
|
167
|
+
const tokenRes = await fetchWithTimeout('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
|
|
161
168
|
method: 'POST',
|
|
162
169
|
headers: { 'content-type': 'application/json' },
|
|
163
170
|
body: JSON.stringify(body),
|
|
171
|
+
timeoutMs: 15000,
|
|
164
172
|
});
|
|
165
173
|
const raw = await tokenRes.text();
|
|
166
174
|
// v1.3.13 security followup: don't log the full raw body — it contains the
|
|
@@ -236,11 +244,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
236
244
|
|
|
237
245
|
const hasRefresh = !!tokenData.refresh_token;
|
|
238
246
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
247
|
+
// v1.3.14 — do not display any token bytes in the browser callback.
|
|
248
|
+
// Browser history / screenshots / OS screen recordings would otherwise
|
|
249
|
+
// retain the first 20 chars of the access token. Replaced with a length
|
|
250
|
+
// attestation so the user still sees the flow succeeded.
|
|
239
251
|
res.end(`<h2>✅ 授权成功!</h2>
|
|
240
|
-
<p>access_token:
|
|
252
|
+
<p>access_token: ✅ 已获取(${tokenData.access_token.length} chars)</p>
|
|
241
253
|
<p>scope: ${tokenData.scope}</p>
|
|
242
254
|
<p>expires_in: ${tokenData.expires_in}s</p>
|
|
243
|
-
<p>refresh_token: ${hasRefresh ? '✅ 已获取(
|
|
255
|
+
<p>refresh_token: ${hasRefresh ? '✅ 已获取(7天有效,支持自动续期;每次 refresh 滚动续 7 天)' : '❌ 未返回(token 将在 2 小时后过期,需重新授权)'}</p>
|
|
244
256
|
<p>已保存到 MCP 配置文件,可以关闭此页面。</p>`);
|
|
245
257
|
|
|
246
258
|
console.log('\n=== OAuth 授权成功 ===');
|
package/src/server.js
CHANGED
|
@@ -83,8 +83,15 @@ let nonOwnerPollTimer = null;
|
|
|
83
83
|
let _ownerStartCallbacks = [];
|
|
84
84
|
|
|
85
85
|
// Lark Desktop reactor state (v1.3.11 §A) — owned by the heartbeat callback.
|
|
86
|
+
// v1.3.14 — `_lastSwitchAt = 0` was previously misinterpreted as "no recent
|
|
87
|
+
// switch" by the lark-desktop reactor's debounce, so a cold-start owner that
|
|
88
|
+
// hadn't observed any switch would still fire-on-first-tick if the snapshot
|
|
89
|
+
// looked stale. We rebaseline `_lastSwitchAt` to the owner-claim wallclock on
|
|
90
|
+
// first reactor invocation so the cold start window has the same debounce
|
|
91
|
+
// budget as a long-running owner. See `_runLarkDesktopReactor` below.
|
|
86
92
|
let _lastHashMtimes = {};
|
|
87
93
|
let _lastSwitchAt = 0;
|
|
94
|
+
let _reactorFirstTickDone = false;
|
|
88
95
|
const _seenUnboundHashes = new Set();
|
|
89
96
|
|
|
90
97
|
function _onBecomeOwner(cb) { _ownerStartCallbacks.push(cb); }
|
|
@@ -157,11 +164,14 @@ function getOfficialClient() {
|
|
|
157
164
|
return officialClient;
|
|
158
165
|
}
|
|
159
166
|
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
167
|
+
// UAT loader sourced from a specific env block (credentials.json profile or
|
|
168
|
+
// harness env). Used both at startup and as the hot-reload entry point from
|
|
169
|
+
// credMonitor.onUatChange. When `env` has no UAT (user nuked the token via
|
|
170
|
+
// `oauth` --revoke or manual edit), clears the in-memory copy instead of
|
|
164
171
|
// silently leaving the stale token in place.
|
|
172
|
+
//
|
|
173
|
+
// v1.3.14 — replaced the dead `LarkOfficialClient.loadUAT()` helper that
|
|
174
|
+
// read from process.env. All UAT loading now goes through this function.
|
|
165
175
|
function loadUATFromEnv(client, env) {
|
|
166
176
|
const token = env?.LARK_USER_ACCESS_TOKEN || null;
|
|
167
177
|
const refresh = env?.LARK_USER_REFRESH_TOKEN || null;
|
|
@@ -274,6 +284,14 @@ function _credMtime() {
|
|
|
274
284
|
// the WS client with the new profile's events list).
|
|
275
285
|
function _runLarkDesktopReactor() {
|
|
276
286
|
const ld = require('./auth/lark-desktop');
|
|
287
|
+
// v1.3.14 — cold-start debounce: prevent the first reactor tick from
|
|
288
|
+
// treating a long-pre-existing snapshot as a "recent switch". By stamping
|
|
289
|
+
// _lastSwitchAt at first tick we give the debounce the same baseline as a
|
|
290
|
+
// long-running owner that just hot-took-over.
|
|
291
|
+
if (!_reactorFirstTickDone) {
|
|
292
|
+
_reactorFirstTickDone = true;
|
|
293
|
+
if (_lastSwitchAt === 0) _lastSwitchAt = Date.now();
|
|
294
|
+
}
|
|
277
295
|
const out = ld.detectSwitch({
|
|
278
296
|
prevSnapshot: _lastHashMtimes,
|
|
279
297
|
lastSwitchAt: _lastSwitchAt,
|
package/src/setup.js
CHANGED
|
@@ -102,10 +102,12 @@ async function main() {
|
|
|
102
102
|
// Validate app credentials
|
|
103
103
|
console.log('\nValidating app credentials...');
|
|
104
104
|
try {
|
|
105
|
-
const
|
|
105
|
+
const { fetchWithTimeout } = require('./utils');
|
|
106
|
+
const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', {
|
|
106
107
|
method: 'POST',
|
|
107
108
|
headers: { 'content-type': 'application/json' },
|
|
108
109
|
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
|
110
|
+
timeoutMs: 10000,
|
|
109
111
|
});
|
|
110
112
|
const data = await res.json();
|
|
111
113
|
if (data.app_access_token) {
|
package/src/test-all.js
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
* Sends test messages to "飞书plugin测试群".
|
|
5
5
|
*/
|
|
6
6
|
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
|
7
|
+
|
|
8
|
+
// v1.3.14 — backfill process.env from canonical credentials store so
|
|
9
|
+
// `npm test` works for users who moved creds to ~/.feishu-user-plugin/.
|
|
10
|
+
// See src/auth/env-backfill.js for full rationale.
|
|
11
|
+
require('./auth/env-backfill').backfillFromCanonical();
|
|
12
|
+
|
|
7
13
|
const { LarkUserClient } = require('./clients/user');
|
|
8
14
|
const { LarkOfficialClient } = require('./clients/official');
|
|
9
15
|
|
|
@@ -333,6 +339,11 @@ main().catch(console.error).finally(() => {
|
|
|
333
339
|
console.error('with-uat-retry: FAIL', e);
|
|
334
340
|
process.exitCode = 1;
|
|
335
341
|
});
|
|
342
|
+
require('./test-uat-lifecycle').run().catch(e => {
|
|
343
|
+
console.error('uat-lifecycle: FAIL', e);
|
|
344
|
+
process.exitCode = 1;
|
|
345
|
+
});
|
|
346
|
+
require('./test-cookie-heartbeat').run();
|
|
336
347
|
require('./test-populate-sender-names').run().catch(e => {
|
|
337
348
|
console.error('populate-sender-names: FAIL', e);
|
|
338
349
|
process.exitCode = 1;
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* Comprehensive test: exercises every tool category in feishu-user-plugin.
|
|
4
|
-
* Reads credentials from .env,
|
|
4
|
+
* Reads credentials from .env, then backfills from canonical store. Tests
|
|
5
|
+
* each layer independently.
|
|
5
6
|
*/
|
|
6
7
|
const path = require('path');
|
|
7
8
|
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
|
9
|
+
// v1.3.14 — let users on canonical store run this without exporting env vars
|
|
10
|
+
// or maintaining a stale .env. .env still wins via dotenv when present.
|
|
11
|
+
require('./auth/env-backfill').backfillFromCanonical();
|
|
8
12
|
|
|
9
13
|
const { LarkUserClient } = require('./clients/user');
|
|
10
14
|
const { LarkOfficialClient } = require('./clients/official');
|
|
@@ -274,7 +278,8 @@ async function testUAT() {
|
|
|
274
278
|
}
|
|
275
279
|
|
|
276
280
|
async function main() {
|
|
277
|
-
|
|
281
|
+
const pkgVersion = require('../package.json').version;
|
|
282
|
+
console.log(`=== feishu-user-plugin v${pkgVersion} — Comprehensive Test ===\n`);
|
|
278
283
|
|
|
279
284
|
await testUserIdentity();
|
|
280
285
|
console.log('');
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for src/auth/cookie.js owner-gated heartbeat (v1.3.14).
|
|
3
|
+
//
|
|
4
|
+
// Covers:
|
|
5
|
+
// - _isHeartbeatRunner: returns true when this process IS the ws-owner
|
|
6
|
+
// - _isHeartbeatRunner: returns false when another pid owns ws-owner.lock
|
|
7
|
+
// - _isHeartbeatRunner: returns true when ws-owner.lock is missing (fallback)
|
|
8
|
+
// - _isHeartbeatRunner: returns true when lock body is malformed (fallback)
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const assert = require('assert');
|
|
16
|
+
|
|
17
|
+
let pass = 0;
|
|
18
|
+
let fail = 0;
|
|
19
|
+
|
|
20
|
+
function ok(name, fn) {
|
|
21
|
+
try {
|
|
22
|
+
fn();
|
|
23
|
+
console.log(` OK ${name}`);
|
|
24
|
+
pass++;
|
|
25
|
+
} catch (e) {
|
|
26
|
+
console.log(` FAIL ${name}: ${e.message}`);
|
|
27
|
+
fail++;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function run() {
|
|
32
|
+
console.log('=== test-cookie-heartbeat ===');
|
|
33
|
+
|
|
34
|
+
const { _isHeartbeatRunner } = require('./auth/cookie');
|
|
35
|
+
|
|
36
|
+
// Use a tmpdir + override lockPath/pid for hermetic testing
|
|
37
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-cookie-hb-'));
|
|
38
|
+
const fakeLock = path.join(tmpDir, 'ws-owner.lock');
|
|
39
|
+
|
|
40
|
+
ok('returns true when this pid IS the lock owner', () => {
|
|
41
|
+
fs.writeFileSync(fakeLock, JSON.stringify({
|
|
42
|
+
version: 1, pid: 12345, start_time: Date.now() / 1000, role: 'ws_owner',
|
|
43
|
+
}));
|
|
44
|
+
const r = _isHeartbeatRunner(fakeLock, 12345);
|
|
45
|
+
assert.strictEqual(r, true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
ok('returns false when another pid owns the lock', () => {
|
|
49
|
+
fs.writeFileSync(fakeLock, JSON.stringify({
|
|
50
|
+
version: 1, pid: 99999, start_time: Date.now() / 1000, role: 'ws_owner',
|
|
51
|
+
}));
|
|
52
|
+
const r = _isHeartbeatRunner(fakeLock, 12345);
|
|
53
|
+
assert.strictEqual(r, false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
ok('returns true (fallback) when lock file missing', () => {
|
|
57
|
+
try { fs.unlinkSync(fakeLock); } catch (_) {}
|
|
58
|
+
const r = _isHeartbeatRunner(fakeLock, 12345);
|
|
59
|
+
assert.strictEqual(r, true, 'no owner claimed → every process runs heartbeat');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
ok('returns true (fallback) when lock body malformed', () => {
|
|
63
|
+
fs.writeFileSync(fakeLock, 'not-valid-json');
|
|
64
|
+
const r = _isHeartbeatRunner(fakeLock, 12345);
|
|
65
|
+
assert.strictEqual(r, true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
ok('returns true (fallback) when lock body has no pid field', () => {
|
|
69
|
+
fs.writeFileSync(fakeLock, JSON.stringify({ version: 1, start_time: 1, role: 'ws_owner' }));
|
|
70
|
+
const r = _isHeartbeatRunner(fakeLock, 12345);
|
|
71
|
+
assert.strictEqual(r, true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
ok('returns true (fallback) when lock body pid is a string', () => {
|
|
75
|
+
fs.writeFileSync(fakeLock, JSON.stringify({ version: 1, pid: '12345', start_time: 1 }));
|
|
76
|
+
const r = _isHeartbeatRunner(fakeLock, 12345);
|
|
77
|
+
assert.strictEqual(r, true, 'malformed pid type → fall back to running');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// --- _heartbeatTick: the tick path itself ---
|
|
81
|
+
|
|
82
|
+
const { _heartbeatTick } = require('./auth/cookie');
|
|
83
|
+
|
|
84
|
+
// Helper to assert tick behavior with injectable deps.
|
|
85
|
+
async function tickWith({ isOwner, expectGetCsrf, expectPersist, expectReturn, throwCsrf = false }) {
|
|
86
|
+
let getCsrfCalled = false;
|
|
87
|
+
let persistCalled = false;
|
|
88
|
+
let persistArg = null;
|
|
89
|
+
const client = {
|
|
90
|
+
cookieStr: 'session=abc; sl_session=def',
|
|
91
|
+
_getCsrfToken: async () => {
|
|
92
|
+
getCsrfCalled = true;
|
|
93
|
+
if (throwCsrf) throw new Error('network down');
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
const result = await _heartbeatTick(client, {
|
|
97
|
+
isHeartbeatRunner: () => isOwner,
|
|
98
|
+
persistToConfig: (updates) => { persistCalled = true; persistArg = updates; },
|
|
99
|
+
});
|
|
100
|
+
assert.strictEqual(result, expectReturn, `expected return value ${expectReturn}, got ${result}`);
|
|
101
|
+
assert.strictEqual(getCsrfCalled, expectGetCsrf, `_getCsrfToken called=${getCsrfCalled} expected ${expectGetCsrf}`);
|
|
102
|
+
assert.strictEqual(persistCalled, expectPersist, `persistToConfig called=${persistCalled} expected ${expectPersist}`);
|
|
103
|
+
if (expectPersist) {
|
|
104
|
+
assert.deepStrictEqual(persistArg, { LARK_COOKIE: 'session=abc; sl_session=def' },
|
|
105
|
+
`persist called with wrong payload: ${JSON.stringify(persistArg)}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
ok('_heartbeatTick: non-owner skips network call AND persist', async () => {
|
|
110
|
+
await tickWith({ isOwner: false, expectGetCsrf: false, expectPersist: false, expectReturn: 'skip' });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
ok('_heartbeatTick: owner calls _getCsrfToken + persists refreshed cookie', async () => {
|
|
114
|
+
await tickWith({ isOwner: true, expectGetCsrf: true, expectPersist: true, expectReturn: 'refreshed' });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
ok('_heartbeatTick: owner with _getCsrfToken throw → returns error WITHOUT persist', async () => {
|
|
118
|
+
await tickWith({ isOwner: true, expectGetCsrf: true, expectPersist: false, expectReturn: 'error', throwCsrf: true });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Cleanup
|
|
122
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
|
123
|
+
|
|
124
|
+
console.log(`\n=== test-cookie-heartbeat: ${pass} passed, ${fail} failed ===`);
|
|
125
|
+
if (fail > 0) process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (require.main === module) {
|
|
129
|
+
try { run(); } catch (e) { console.error('test-cookie-heartbeat harness error:', e); process.exit(1); }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { run };
|