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,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 };
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Unit tests for createDocTable (manage_doc_block create mode F — tables).
|
|
3
|
+
//
|
|
4
|
+
// Guards the payload contract (block_type=31 table, row_size/column_size), the
|
|
5
|
+
// cell-fill behaviour (UPDATE an existing auto-created text block — no stray
|
|
6
|
+
// empty blocks — else CREATE one), and the fail-loud behaviour when the table's
|
|
7
|
+
// cells cannot be resolved (so large docs never silently drop content). Pure
|
|
8
|
+
// unit: the client methods (_asUserOrApp / getBlockChildren / updateDocBlock /
|
|
9
|
+
// createDocBlock) are stubbed, so no network. End-to-end behaviour is verified
|
|
10
|
+
// separately against live Feishu (create doc → table → read back → delete).
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const assert = require('assert');
|
|
14
|
+
const docs = require('./clients/official/docs');
|
|
15
|
+
|
|
16
|
+
let pass = 0, fail = 0;
|
|
17
|
+
async function ok(name, fn) {
|
|
18
|
+
try { await fn(); console.log(` OK ${name}`); pass++; }
|
|
19
|
+
catch (e) { console.log(` FAIL ${name}: ${e.message}`); fail++; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Build a stubbed `this` for createDocTable.
|
|
23
|
+
// cellIds — the table's cells (row-major)
|
|
24
|
+
// cellsInCreate — true: create response carries cell ids; false: forces a
|
|
25
|
+
// scoped getBlockChildren(table) lookup
|
|
26
|
+
// cellHasText — true: each cell already has an auto text block (UPDATE);
|
|
27
|
+
// false: empty cell (CREATE)
|
|
28
|
+
// resolvableCells — cell ids that getBlockChildren(table) will return (defaults
|
|
29
|
+
// to cellIds); set shorter to exercise fail-loud
|
|
30
|
+
function stub({ cellIds, cellsInCreate = true, cellHasText = true, resolvableCells } = {}) {
|
|
31
|
+
const calls = { createBody: null, updates: [], creates: [], childFetches: [] };
|
|
32
|
+
const self = {
|
|
33
|
+
async _asUserOrApp({ body }) {
|
|
34
|
+
calls.createBody = body;
|
|
35
|
+
const tbl = { block_id: 'tbl1' };
|
|
36
|
+
if (cellsInCreate) tbl.children = cellIds;
|
|
37
|
+
return { data: { children: [tbl] }, _viaUser: true, _fallbackWarning: null };
|
|
38
|
+
},
|
|
39
|
+
async getBlockChildren(documentId, blockId) {
|
|
40
|
+
calls.childFetches.push(blockId);
|
|
41
|
+
if (blockId === 'tbl1') {
|
|
42
|
+
const ids = resolvableCells || cellIds;
|
|
43
|
+
return { items: ids.map(id => ({ block_id: id, block_type: 32 })) };
|
|
44
|
+
}
|
|
45
|
+
// a cell → its auto text block (or none)
|
|
46
|
+
return { items: cellHasText ? [{ block_id: 't-' + blockId, block_type: 2 }] : [] };
|
|
47
|
+
},
|
|
48
|
+
async updateDocBlock(documentId, blockId, body) { calls.updates.push({ blockId, body }); return { block: {} }; },
|
|
49
|
+
async createDocBlock(documentId, parent, children) { calls.creates.push({ parent, children }); return { blocks: [] }; },
|
|
50
|
+
};
|
|
51
|
+
return { self, calls };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function run() {
|
|
55
|
+
console.log('=== test-doc-table ===');
|
|
56
|
+
|
|
57
|
+
await ok('builds block_type=31 payload + fills by UPDATEing each cell\'s existing text (no stray blocks)', async () => {
|
|
58
|
+
const { self, calls } = stub({ cellIds: ['c00', 'c01', 'c10', 'c11'], cellsInCreate: true, cellHasText: true });
|
|
59
|
+
const r = await docs.createDocTable.call(self, 'docX', 'docX', { rows: 2, columns: 2, cells: [['A', 'B'], ['C', 'D']] });
|
|
60
|
+
const tableBody = calls.createBody.children[0];
|
|
61
|
+
assert.strictEqual(tableBody.block_type, 31, 'table block_type must be 31 (not 40)');
|
|
62
|
+
assert.strictEqual(tableBody.table.property.row_size, 2);
|
|
63
|
+
assert.strictEqual(tableBody.table.property.column_size, 2);
|
|
64
|
+
assert.deepStrictEqual(r.cells, [['c00', 'c01'], ['c10', 'c11']], 'cells mapped row-major');
|
|
65
|
+
assert.strictEqual(r.filled, 4);
|
|
66
|
+
assert.strictEqual(calls.updates.length, 4, 'should UPDATE 4 existing cell text blocks');
|
|
67
|
+
assert.strictEqual(calls.creates.length, 0, 'should NOT create extra blocks when the cell already has a text block');
|
|
68
|
+
assert.strictEqual(calls.updates[0].body.update_text_elements.elements[0].text_run.content, 'A');
|
|
69
|
+
assert.strictEqual(r.viaUser, true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await ok('CREATEs a text block when a cell has no auto text block', async () => {
|
|
73
|
+
const { self, calls } = stub({ cellIds: ['c0', 'c1'], cellsInCreate: true, cellHasText: false });
|
|
74
|
+
const r = await docs.createDocTable.call(self, 'd', 'd', { rows: 1, columns: 2, cells: [['X', 'Y']] });
|
|
75
|
+
assert.strictEqual(r.filled, 2);
|
|
76
|
+
assert.strictEqual(calls.creates.length, 2, 'should CREATE a text block in each empty cell');
|
|
77
|
+
assert.strictEqual(calls.updates.length, 0);
|
|
78
|
+
assert.strictEqual(calls.creates[0].children[0].block_type, 2, 'created child is a text block');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await ok('resolves cells via scoped getBlockChildren when the create response lacks them', async () => {
|
|
82
|
+
const { self, calls } = stub({ cellIds: ['c0', 'c1'], cellsInCreate: false, cellHasText: true });
|
|
83
|
+
const r = await docs.createDocTable.call(self, 'd', 'd', { rows: 1, columns: 2, cells: [['X', 'Y']] });
|
|
84
|
+
assert.deepStrictEqual(r.cells, [['c0', 'c1']]);
|
|
85
|
+
assert.strictEqual(r.filled, 2);
|
|
86
|
+
assert.ok(calls.childFetches.includes('tbl1'), 'should scope-fetch the table block children when create response lacks cells');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await ok('fails loud (throws) when cells cannot be fully resolved — never silently drops content', async () => {
|
|
90
|
+
// create response lacks cells AND scoped lookup returns too few (e.g. >500-block doc)
|
|
91
|
+
const { self } = stub({ cellIds: ['c0', 'c1'], cellsInCreate: false, resolvableCells: ['c0'] });
|
|
92
|
+
let threw = false, msg = '';
|
|
93
|
+
try { await docs.createDocTable.call(self, 'd', 'd', { rows: 1, columns: 2, cells: [['X', 'Y']] }); }
|
|
94
|
+
catch (e) { threw = true; msg = e.message; }
|
|
95
|
+
assert.ok(threw, 'should throw rather than return a low-filled success');
|
|
96
|
+
assert.ok(/resolved only 1\/2 cells/.test(msg), `error should name the shortfall: ${msg}`);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await ok('leaves omitted/blank cells empty and counts only filled', async () => {
|
|
100
|
+
const { self, calls } = stub({ cellIds: ['c0', 'c1', 'c2', 'c3'], cellsInCreate: true, cellHasText: true });
|
|
101
|
+
const r = await docs.createDocTable.call(self, 'd', 'd', { rows: 2, columns: 2, cells: [['only', ''], [null, 'here']] });
|
|
102
|
+
assert.strictEqual(r.filled, 2, 'blank/null cells are skipped');
|
|
103
|
+
assert.strictEqual(calls.updates.length, 2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await ok('rejects rows/columns < 1', async () => {
|
|
107
|
+
const { self } = stub({ cellIds: [] });
|
|
108
|
+
for (const bad of [{ rows: 0, columns: 2 }, { rows: 2, columns: 0 }, { rows: -1, columns: 1 }]) {
|
|
109
|
+
let threw = false;
|
|
110
|
+
try { await docs.createDocTable.call(self, 'd', 'd', bad); } catch (_) { threw = true; }
|
|
111
|
+
assert.ok(threw, `rows/columns ${JSON.stringify(bad)} should throw`);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
console.log(`\n=== test-doc-table: ${pass} passed, ${fail} failed ===`);
|
|
116
|
+
if (fail > 0) process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (require.main === module) {
|
|
120
|
+
run().catch((e) => { console.error('test-doc-table harness error:', e); process.exit(1); });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { run };
|