feishu-user-plugin 1.3.12 → 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.
@@ -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 };
@@ -86,6 +86,11 @@ async function run() {
86
86
  assert.equal(r1.data.ok, undefined, 'should pass through fields, not double-wrap');
87
87
  assert.equal(r1.data.data.ok, true);
88
88
  assert.equal(r1.viaReason, undefined, 'no fallback → no via_reason');
89
+ // PR #103 Codex P1 followup: UAT success must set the legacy _viaUser=true
90
+ // marker so 15+ _asUserOrApp callsites (calendar/docs/bitable/wiki/okr/tasks
91
+ // /drive) report viaUser:true. Without this flag downstream code thinks the
92
+ // resource was created by the bot.
93
+ assert.equal(r1.data._viaUser, true, 'UAT success path must mark _viaUser=true on response');
89
94
 
90
95
  // --- 8. withIdentityFallback: UAT returns 20064 → bot fallback, identity refined ---
91
96
  let botRan = false;
@@ -298,3 +298,4 @@ function run() {
298
298
  if (require.main === module) {
299
299
  run();
300
300
  }
301
+ module.exports = { run };
@@ -1,4 +1,4 @@
1
- // src/test-lru-cache.js — unit test for src/utils/lru-cache.js.
1
+ // src/test-lru-cache.js — unit test for the LRUCache class exported by src/utils.js.
2
2
  //
3
3
  // Replaces the v1.3.12 `new Map()` _userNameCache / _appNameCache. Pre-fix the
4
4
  // caches grew unboundedly across the server's lifetime (one entry per unique
@@ -0,0 +1,354 @@
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
+ // identity-state.js redact-regex regression guard. Exercises the actual
280
+ // regex on `_classifyUatFailure` path that the previous "no dead.refresh"
281
+ // assertion in test #14 did NOT cover (the invalid_grant message is
282
+ // hard-coded with no interpolation, so it's trivially redacted).
283
+ await ok('identity-state _classifyUatFailure: redact regex strips long token-like strings from uatError.message', () => {
284
+ const { _classifyUatFailure } = require('./auth/identity-state');
285
+ // A realistic-looking JWT-like base64-ish string > 40 chars.
286
+ const longToken = 'eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3Nzk0MDAwMDB9.abc123def456ghi789jklmnoXYZabcdefgXXXX';
287
+ assert.ok(longToken.length >= 40, 'fixture token should be >= 40 chars to trigger redact');
288
+ const fakeErr = new Error(`UAT request failed: server returned: ${longToken}`);
289
+ const cls = _classifyUatFailure(null, fakeErr);
290
+ assert.ok(cls, 'should classify a uatError');
291
+ assert.ok(!cls.viaReason.includes(longToken), `viaReason must not contain raw long token; got: ${cls.viaReason}`);
292
+ assert.ok(cls.viaReason.includes('<redacted>'), `viaReason must contain '<redacted>' marker; got: ${cls.viaReason}`);
293
+ assert.strictEqual(cls.state, null, 'non-uatRevoked errors should not refine identity from this path');
294
+ });
295
+
296
+ await ok('identity-state _classifyUatFailure: uatRevoked flag short-circuits to UAT_REVOKED state', () => {
297
+ const { _classifyUatFailure, IdentityState } = require('./auth/identity-state');
298
+ 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.');
299
+ err.uatRevoked = true;
300
+ const cls = _classifyUatFailure(null, err);
301
+ assert.strictEqual(cls.state, IdentityState.UAT_REVOKED, 'uatRevoked flag must refine state to UAT_REVOKED');
302
+ assert.ok(cls.viaReason.includes('invalid_grant') || cls.viaReason.includes('rejected'),
303
+ `viaReason must mention rejection: ${cls.viaReason}`);
304
+ });
305
+
306
+ await ok('refreshUAT: non-invalid_grant error does NOT set uatRevoked', async () => {
307
+ os.homedir = () => fakeHome;
308
+ writeCanonical({
309
+ LARK_USER_ACCESS_TOKEN: 'expired.token',
310
+ LARK_USER_REFRESH_TOKEN: 'live.refresh',
311
+ LARK_UAT_EXPIRES: Math.floor(Date.now() / 1000) - 3600,
312
+ });
313
+
314
+ const origFetch = global.fetch;
315
+ global.fetch = async () => ({
316
+ json: async () => ({ code: 99991663, msg: 'token transient error' }),
317
+ });
318
+
319
+ try {
320
+ const client = {
321
+ appId: 'test', appSecret: 'test',
322
+ _uat: 'expired.token',
323
+ _uatRefresh: 'live.refresh',
324
+ _uatExpires: Math.floor(Date.now() / 1000) - 3600,
325
+ };
326
+ let thrown = null;
327
+ try {
328
+ await uat.refreshUAT(client);
329
+ } catch (e) {
330
+ thrown = e;
331
+ }
332
+ assert.ok(thrown, 'should throw on non-success');
333
+ assert.ok(!thrown.uatRevoked, 'transient error should not set uatRevoked');
334
+ assert.ok(thrown.message.includes('99991663') || thrown.message.includes('transient'),
335
+ `should mention specific error: ${thrown.message}`);
336
+ } finally {
337
+ global.fetch = origFetch;
338
+ os.homedir = origHomedir;
339
+ }
340
+ });
341
+
342
+ // --- Cleanup ---
343
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
344
+ try { fs.rmSync(fakeHome, { recursive: true, force: true }); } catch (_) {}
345
+
346
+ console.log(`\n=== test-uat-lifecycle: ${pass} passed, ${fail} failed ===`);
347
+ if (fail > 0) process.exit(1);
348
+ }
349
+
350
+ if (require.main === module) {
351
+ run().catch((e) => { console.error('test-uat-lifecycle harness error:', e); process.exit(1); });
352
+ }
353
+
354
+ module.exports = { run };
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();