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/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
- return path.join(os.homedir(), '.claude', 'feishu-uat-refresh.lock');
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
- console.error('[feishu-user-plugin] UAT refresh lock timed out; proceeding without mutual exclusion');
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
- const now = Math.floor(Date.now() / 1000);
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) throw new Error(`UAT refresh failed: ${JSON.stringify(data)}. Run: npx feishu-user-plugin oauth`);
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
- return fn(uat);
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
- // Set process.env so LarkOfficialClient.loadUAT() picks the right tokens
252
- process.env.LARK_USER_ACCESS_TOKEN = env.LARK_USER_ACCESS_TOKEN;
253
- process.env.LARK_USER_REFRESH_TOKEN = env.LARK_USER_REFRESH_TOKEN;
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
- const refresh = process.env.LARK_USER_REFRESH_TOKEN;
28
- const expires = parseInt(process.env.LARK_UAT_EXPIRES || '0');
29
- if (token) {
30
- this._uat = token;
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() {
@@ -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-day window elapsed). The live trigger for this code lives in
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 fetch('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', {
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 fetch(`https://open.feishu.cn/open-apis/application/v6/applications/${APP_ID}?lang=zh_cn`, {
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 fetch('https://open.feishu.cn/open-apis/application/v6/applications/underauditlist?lang=zh_cn&page_size=1', {
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
- console.log('Token exchange request:', JSON.stringify({ ...body, client_secret: '***' }));
160
- const tokenRes = await fetch('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
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: ${tokenData.access_token.slice(0, 20)}...</p>
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 ? '✅ 已获取(30天有效,支持自动续期)' : '❌ 未返回(token 将在 2 小时后过期,需重新授权)'}</p>
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
- // Mirror of LarkOfficialClient.loadUAT() but sourced from a specific env block
161
- // instead of process.env, so credentials.json profiles work uniformly. Also
162
- // the hot-reload entry point used by credMonitor.onUatChange: when `env` has
163
- // no UAT (user nuked the token), clear the in-memory copy instead of
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 res = await fetch('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', {
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, tests each layer independently.
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
- console.log('=== feishu-user-plugin v1.1.3 — Comprehensive Test ===\n');
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 };