feishu-user-plugin 1.3.10 → 1.3.12
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 +2 -2
- package/.cursor-plugin/plugin.json +27 -0
- package/.mcpb/manifest.json +91 -0
- package/CHANGELOG.md +118 -0
- package/PRIVACY.md +105 -0
- package/README.en.md +130 -413
- package/README.md +88 -258
- package/package.json +5 -3
- package/scripts/build-mcpb.js +119 -0
- package/scripts/check-description-drift.js +73 -0
- package/scripts/check-docs-sync.js +7 -16
- package/scripts/check-mcp-registry-version.js +43 -0
- package/scripts/check-mcpb-version.js +33 -0
- package/scripts/check-scopes.js +99 -0
- package/scripts/check-tool-count.js +4 -3
- package/scripts/check-version.js +5 -0
- package/scripts/sync-claude-md.sh +3 -4
- package/scripts/sync-team-skills.sh +72 -57
- package/scripts/verify-app-name.js +64 -0
- package/skills/feishu-user-plugin/SKILL.md +3 -3
- package/skills/feishu-user-plugin/references/search.md +3 -3
- package/src/auth/credentials-monitor.js +185 -0
- package/src/auth/credentials.js +49 -0
- package/src/auth/identity-state.js +204 -0
- package/src/auth/lark-desktop.js +135 -0
- package/src/auth/uat.js +49 -35
- package/src/cli.js +87 -0
- package/src/clients/official/base.js +145 -14
- package/src/clients/official/calendar.js +3 -1
- package/src/clients/official/im.js +76 -2
- package/src/clients/official/okr.js +2 -1
- package/src/error-codes.js +40 -0
- package/src/events/lockfile.js +40 -4
- package/src/events/owner.js +11 -2
- package/src/index.js +1 -1
- package/src/logger.js +11 -5
- package/src/oauth.js +46 -10
- package/src/server.js +102 -37
- package/src/setup.js +44 -0
- package/src/test-all.js +40 -0
- package/src/test-cli-tool.js +87 -0
- package/src/test-credentials-monitor.js +124 -0
- package/src/test-display-label.js +88 -0
- package/src/test-error-codes.js +85 -0
- package/src/test-identity-state.js +172 -0
- package/src/test-lark-desktop.js +300 -0
- package/src/test-lockfile-pid.js +90 -0
- package/src/test-lru-cache.js +145 -0
- package/src/test-negative-cache.js +85 -0
- package/src/test-populate-sender-names.js +98 -0
- package/src/test-search-messages.js +101 -0
- package/src/test-send-shape.js +115 -0
- package/src/test-via-user.js +94 -0
- package/src/test-with-uat-retry.js +135 -0
- package/src/tools/_registry.js +24 -1
- package/src/tools/calendar.js +5 -5
- package/src/tools/im-read.js +52 -4
- package/src/tools/messaging-user.js +1 -1
- package/src/utils.js +83 -0
- package/scripts/generate-og-image.js +0 -39
- package/skills/feishu-user-plugin/references/CLAUDE.md +0 -523
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// src/test-credentials-monitor.js — unit test for src/auth/credentials-monitor.js.
|
|
2
|
+
//
|
|
3
|
+
// Pre-v1.3.12 hot-reload was partial: server.js stat-ed credentials.json mtime
|
|
4
|
+
// and dispatched setActiveProfile() on change, but the UAT in-memory token,
|
|
5
|
+
// _userNameCache, and lockfile heartbeat never observed the change. Users had
|
|
6
|
+
// to restart Claude Code after `npx oauth` for the new UAT to take effect.
|
|
7
|
+
//
|
|
8
|
+
// CredentialsMonitor unifies the mtime + content-hash diff into a single
|
|
9
|
+
// poll triggered per tool call. Owners register hooks for the parts they
|
|
10
|
+
// care about: onUatChange / onCookieChange / onProfileSwitch / onCacheInvalidate.
|
|
11
|
+
//
|
|
12
|
+
// We test against a temporary credentials.json in a tmpdir so the test is
|
|
13
|
+
// isolated from any real ~/.feishu-user-plugin state.
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const assert = require('node:assert/strict');
|
|
21
|
+
const { createCredentialsMonitor } = require('./auth/credentials-monitor');
|
|
22
|
+
|
|
23
|
+
function writeCreds(dir, obj) {
|
|
24
|
+
const p = path.join(dir, 'credentials.json');
|
|
25
|
+
fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n', { mode: 0o600 });
|
|
26
|
+
return p;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function run() {
|
|
30
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fish-monitor-'));
|
|
31
|
+
const credPath = path.join(dir, 'credentials.json');
|
|
32
|
+
|
|
33
|
+
const baseCreds = {
|
|
34
|
+
version: 1,
|
|
35
|
+
active: 'default',
|
|
36
|
+
profiles: {
|
|
37
|
+
default: {
|
|
38
|
+
LARK_APP_ID: 'cli_a',
|
|
39
|
+
LARK_USER_ACCESS_TOKEN: 'uat_v1',
|
|
40
|
+
LARK_USER_REFRESH_TOKEN: 'ref_v1',
|
|
41
|
+
LARK_COOKIE: 'cookie_v1',
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
writeCreds(dir, baseCreds);
|
|
46
|
+
|
|
47
|
+
// Inject path so monitor doesn't read real ~/.feishu-user-plugin
|
|
48
|
+
const monitor = createCredentialsMonitor({ path: credPath });
|
|
49
|
+
|
|
50
|
+
// --- 1. First sync establishes baseline; no hooks fire ---
|
|
51
|
+
let uatFired = 0, cookieFired = 0, profileFired = 0, cacheFired = 0;
|
|
52
|
+
monitor.onUatChange(() => uatFired++);
|
|
53
|
+
monitor.onCookieChange(() => cookieFired++);
|
|
54
|
+
monitor.onProfileSwitch(() => profileFired++);
|
|
55
|
+
monitor.onCacheInvalidate(() => cacheFired++);
|
|
56
|
+
|
|
57
|
+
monitor.sync();
|
|
58
|
+
assert.equal(uatFired, 0, 'baseline sync should not fire hooks');
|
|
59
|
+
assert.equal(cookieFired, 0);
|
|
60
|
+
assert.equal(profileFired, 0);
|
|
61
|
+
assert.equal(cacheFired, 0);
|
|
62
|
+
|
|
63
|
+
// --- 2. Change UAT field → onUatChange fires, others don't ---
|
|
64
|
+
// We must advance the mtime AFTER content change; on fast filesystems writing
|
|
65
|
+
// the same path repeatedly within the same ms gives identical mtime. Set
|
|
66
|
+
// explicit mtime so behaviour doesn't depend on FS clock granularity.
|
|
67
|
+
const after1 = { ...baseCreds, profiles: { default: { ...baseCreds.profiles.default, LARK_USER_ACCESS_TOKEN: 'uat_v2', LARK_USER_REFRESH_TOKEN: 'ref_v2' } } };
|
|
68
|
+
writeCreds(dir, after1);
|
|
69
|
+
fs.utimesSync(credPath, new Date(Date.now() + 1000), new Date(Date.now() + 1000));
|
|
70
|
+
monitor.sync();
|
|
71
|
+
assert.equal(uatFired, 1, 'onUatChange should fire on UAT diff');
|
|
72
|
+
assert.equal(cookieFired, 0, 'unchanged cookie → no cookie hook');
|
|
73
|
+
assert.equal(profileFired, 0, 'unchanged active → no profile hook');
|
|
74
|
+
assert.equal(cacheFired, 1, 'any change should fire onCacheInvalidate once');
|
|
75
|
+
|
|
76
|
+
// --- 3. Same content, just touch mtime → no hook fires (content hash guards) ---
|
|
77
|
+
fs.utimesSync(credPath, new Date(Date.now() + 2000), new Date(Date.now() + 2000));
|
|
78
|
+
monitor.sync();
|
|
79
|
+
assert.equal(uatFired, 1, 'touch (no content change) should not fire UAT');
|
|
80
|
+
assert.equal(cacheFired, 1);
|
|
81
|
+
|
|
82
|
+
// --- 4. Change cookie → onCookieChange fires ---
|
|
83
|
+
const after2 = { ...after1, profiles: { default: { ...after1.profiles.default, LARK_COOKIE: 'cookie_v2' } } };
|
|
84
|
+
writeCreds(dir, after2);
|
|
85
|
+
fs.utimesSync(credPath, new Date(Date.now() + 3000), new Date(Date.now() + 3000));
|
|
86
|
+
monitor.sync();
|
|
87
|
+
assert.equal(cookieFired, 1);
|
|
88
|
+
assert.equal(profileFired, 0);
|
|
89
|
+
|
|
90
|
+
// --- 5. Change active profile → onProfileSwitch fires (legacy parity) ---
|
|
91
|
+
const after3 = { ...after2, active: 'work', profiles: { default: after2.profiles.default, work: { LARK_APP_ID: 'cli_b' } } };
|
|
92
|
+
writeCreds(dir, after3);
|
|
93
|
+
fs.utimesSync(credPath, new Date(Date.now() + 4000), new Date(Date.now() + 4000));
|
|
94
|
+
monitor.sync();
|
|
95
|
+
assert.equal(profileFired, 1, 'active flip → onProfileSwitch fires');
|
|
96
|
+
|
|
97
|
+
// --- 6. Hook receives the new credentials snapshot as argument ---
|
|
98
|
+
let receivedToken = null;
|
|
99
|
+
monitor.onUatChange((snap) => { receivedToken = snap?.LARK_USER_ACCESS_TOKEN; });
|
|
100
|
+
const after4 = { ...after3, profiles: { ...after3.profiles, work: { LARK_APP_ID: 'cli_b', LARK_USER_ACCESS_TOKEN: 'uat_work_v1', LARK_USER_REFRESH_TOKEN: 'ref_work_v1' } } };
|
|
101
|
+
writeCreds(dir, after4);
|
|
102
|
+
fs.utimesSync(credPath, new Date(Date.now() + 5000), new Date(Date.now() + 5000));
|
|
103
|
+
monitor.sync();
|
|
104
|
+
assert.equal(receivedToken, 'uat_work_v1', 'UAT hook should receive the active profile env block');
|
|
105
|
+
|
|
106
|
+
// --- 7. Monitor handles missing file gracefully (no throw) ---
|
|
107
|
+
fs.unlinkSync(credPath);
|
|
108
|
+
monitor.sync(); // should not throw
|
|
109
|
+
|
|
110
|
+
// --- 8. File reappears later → next sync detects + fires ---
|
|
111
|
+
writeCreds(dir, baseCreds);
|
|
112
|
+
fs.utimesSync(credPath, new Date(Date.now() + 6000), new Date(Date.now() + 6000));
|
|
113
|
+
monitor.sync();
|
|
114
|
+
// baseCreds.active='default' diff from previous 'work' → profile change
|
|
115
|
+
// baseCreds.UAT='uat_v1' diff from previous 'uat_work_v1' → uat change
|
|
116
|
+
assert.ok(profileFired >= 2);
|
|
117
|
+
assert.ok(uatFired >= 2);
|
|
118
|
+
|
|
119
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
120
|
+
console.log('credentials-monitor.js: PASS');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (require.main === module) run().catch(e => { console.error(e); process.exit(1); });
|
|
124
|
+
module.exports = { run };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// src/test-display-label.js — unit test for LarkOfficialClient._computeDisplayLabel
|
|
3
|
+
//
|
|
4
|
+
// _computeDisplayLabel maps a formatted message (with senderId/senderType/senderName/
|
|
5
|
+
// isRecalled fields) to a human-friendly string for LLM consumption. This covers the
|
|
6
|
+
// 6 sender shapes Feishu actually produces:
|
|
7
|
+
//
|
|
8
|
+
// 1. user with resolved name → name
|
|
9
|
+
// 2. user with senderName=null → "(open_id)" fallback
|
|
10
|
+
// 3. app (bot) with name in cache → "[Bot] AppName"
|
|
11
|
+
// 4. app (bot) without name → "[Bot] (cli_xxx)"
|
|
12
|
+
// 5. anonymous sender → "[匿名]"
|
|
13
|
+
// 6. system message (no sender) → "[系统]"
|
|
14
|
+
// 7. recalled-message prefix → "[已撤回] " + base label
|
|
15
|
+
//
|
|
16
|
+
// Without the implementation this script fails (which is what we want pre-fix).
|
|
17
|
+
|
|
18
|
+
const { LarkOfficialClient } = require('./clients/official/base');
|
|
19
|
+
|
|
20
|
+
const client = new LarkOfficialClient('cli_dummy', 'dummy_secret');
|
|
21
|
+
client._appNameCache.set('cli_named_bot', 'Claude聊天助手');
|
|
22
|
+
|
|
23
|
+
const tests = [
|
|
24
|
+
{
|
|
25
|
+
name: 'user with resolved name',
|
|
26
|
+
input: { senderType: 'user', senderId: 'ou_x', senderName: '周宇' },
|
|
27
|
+
expected: '周宇',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'user with null senderName',
|
|
31
|
+
input: { senderType: 'user', senderId: 'ou_abc123', senderName: null },
|
|
32
|
+
expected: '(ou_abc123)',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'app with name in cache',
|
|
36
|
+
input: { senderType: 'app', senderId: 'cli_named_bot' },
|
|
37
|
+
expected: '[Bot] Claude聊天助手',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'app without name',
|
|
41
|
+
input: { senderType: 'app', senderId: 'cli_unknown' },
|
|
42
|
+
expected: '[Bot] (cli_unknown)',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'anonymous',
|
|
46
|
+
input: { senderType: 'anonymous', senderId: 'ou_x' },
|
|
47
|
+
expected: '[匿名]',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'system (no senderId)',
|
|
51
|
+
input: { senderId: undefined },
|
|
52
|
+
expected: '[系统]',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'recalled user message',
|
|
56
|
+
input: { senderType: 'user', senderId: 'ou_x', senderName: '怪兽', isRecalled: true },
|
|
57
|
+
expected: '[已撤回] 怪兽',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'recalled with null senderName',
|
|
61
|
+
input: { senderType: 'user', senderId: 'ou_y', senderName: null, isRecalled: true },
|
|
62
|
+
expected: '[已撤回] (ou_y)',
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
let failures = 0;
|
|
67
|
+
for (const t of tests) {
|
|
68
|
+
let actual;
|
|
69
|
+
try {
|
|
70
|
+
actual = client._computeDisplayLabel(t.input);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
console.error(`FAIL ${t.name}: threw ${e.message}`);
|
|
73
|
+
failures++;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (actual !== t.expected) {
|
|
77
|
+
console.error(`FAIL ${t.name}: expected ${JSON.stringify(t.expected)}, got ${JSON.stringify(actual)}`);
|
|
78
|
+
failures++;
|
|
79
|
+
} else {
|
|
80
|
+
console.log(`OK ${t.name}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (failures) {
|
|
85
|
+
console.error(`\n${failures} test(s) failed`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
console.log(`\nAll ${tests.length} display-label tests passed`);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// src/test-error-codes.js — unit test for src/error-codes.js classifyError.
|
|
2
|
+
//
|
|
3
|
+
// Covers the v1.3.12 widening:
|
|
4
|
+
// - 20064 (invalid_grant — UAT revoked, permanent)
|
|
5
|
+
// - 91403 (cross-tenant bot — bot can never access, route to UAT)
|
|
6
|
+
// - 1254xxx upload errors (transient — retry once)
|
|
7
|
+
// - res.json() parse failure messages → 'retry' (transient)
|
|
8
|
+
//
|
|
9
|
+
// Each case maps an error code (or Error object) to { action, reason } the
|
|
10
|
+
// classifier should output. Run via `node src/test-error-codes.js` or as part
|
|
11
|
+
// of `npm test` (imported from src/test-all.js).
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const assert = require('node:assert/strict');
|
|
16
|
+
const { classifyError, FAILURE_MAP, TRANSIENT_PATTERNS } = require('./error-codes');
|
|
17
|
+
|
|
18
|
+
function run() {
|
|
19
|
+
// --- Existing classifications (regression guard) ---
|
|
20
|
+
assert.deepEqual(
|
|
21
|
+
classifyError(240001),
|
|
22
|
+
{ action: 'uat', reason: 'bot_external_tenant', code: 240001 },
|
|
23
|
+
'240001 → bot_external_tenant',
|
|
24
|
+
);
|
|
25
|
+
assert.deepEqual(
|
|
26
|
+
classifyError(70009),
|
|
27
|
+
{ action: 'uat', reason: 'bot_no_permission', code: 70009 },
|
|
28
|
+
);
|
|
29
|
+
assert.deepEqual(
|
|
30
|
+
classifyError(42101),
|
|
31
|
+
{ action: 'retry', reason: 'bot_rate_limited', code: 42101 },
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// --- New v1.3.12 entries ---
|
|
35
|
+
const m20064 = classifyError(20064);
|
|
36
|
+
assert.equal(m20064.action, 'uat', '20064 should escalate to UAT path (revoked permanent)');
|
|
37
|
+
assert.equal(m20064.reason, 'uat_revoked', '20064 reason should be uat_revoked');
|
|
38
|
+
|
|
39
|
+
const m91403 = classifyError(91403);
|
|
40
|
+
assert.equal(m91403.action, 'uat', '91403 cross-tenant bot');
|
|
41
|
+
assert.equal(m91403.reason, 'bot_cross_tenant');
|
|
42
|
+
|
|
43
|
+
// 1254xxx — a sample of upload failures observed in production
|
|
44
|
+
for (const code of [1254000, 1254001, 1254301, 1254400]) {
|
|
45
|
+
const c = classifyError(code);
|
|
46
|
+
assert.equal(c.action, 'retry', `${code} should be transient (retry)`);
|
|
47
|
+
assert.equal(c.reason, 'upload_transient', `${code} reason`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- res.json() parse failures should retry once ---
|
|
51
|
+
// Real-world: feishu's gateway occasionally returns truncated bodies that
|
|
52
|
+
// make response.json() throw SyntaxError; one retry usually clears it.
|
|
53
|
+
const parseErr = new Error('Unexpected end of JSON input');
|
|
54
|
+
parseErr.name = 'SyntaxError';
|
|
55
|
+
const parseClass = classifyError(parseErr);
|
|
56
|
+
assert.equal(parseClass.action, 'retry', 'JSON parse error → retry');
|
|
57
|
+
assert.equal(parseClass.reason, 'response_parse_error');
|
|
58
|
+
|
|
59
|
+
// --- Existing transient patterns still work ---
|
|
60
|
+
const networkErr = new Error('fetch timeout after 10000ms');
|
|
61
|
+
assert.equal(classifyError(networkErr).action, 'retry');
|
|
62
|
+
|
|
63
|
+
const httpFive = new Error('readMessages failed (HTTP 503, code=99991400): rate limited');
|
|
64
|
+
// Note: code=99991400 hits FAILURE_MAP before the pattern. Either way action=retry.
|
|
65
|
+
assert.equal(classifyError(httpFive).action, 'retry');
|
|
66
|
+
|
|
67
|
+
// --- Unknown codes preserve fallback behavior ---
|
|
68
|
+
assert.deepEqual(
|
|
69
|
+
classifyError(99999),
|
|
70
|
+
{ action: 'unknown', reason: 'bot_unknown_error', code: 99999 },
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// --- TRANSIENT_PATTERNS still exported ---
|
|
74
|
+
assert.ok(Array.isArray(TRANSIENT_PATTERNS) && TRANSIENT_PATTERNS.length >= 5);
|
|
75
|
+
|
|
76
|
+
// --- FAILURE_MAP coverage: all new codes are registered ---
|
|
77
|
+
assert.ok(FAILURE_MAP[20064], 'FAILURE_MAP must register 20064');
|
|
78
|
+
assert.ok(FAILURE_MAP[91403], 'FAILURE_MAP must register 91403');
|
|
79
|
+
assert.ok(FAILURE_MAP[1254000], 'FAILURE_MAP must register 1254000');
|
|
80
|
+
|
|
81
|
+
console.log('error-codes.js: PASS');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (require.main === module) run();
|
|
85
|
+
module.exports = { run };
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// src/test-identity-state.js — unit test for src/auth/identity-state.js.
|
|
2
|
+
//
|
|
3
|
+
// resolveIdentity + withIdentityFallback are the v1.3.12 replacement for
|
|
4
|
+
// asUserOrApp's silent fallback. Behaviour we test:
|
|
5
|
+
//
|
|
6
|
+
// 1. resolveIdentity reads in-memory client state (no API call by default)
|
|
7
|
+
// and returns one of: VALID_USER / UAT_EXPIRED / BOT_ONLY / NO_CREDENTIALS.
|
|
8
|
+
// 2. withIdentityFallback wraps a UAT-first / bot-fallback flow, attaches
|
|
9
|
+
// via / via_reason / identity to the response, refines the cached
|
|
10
|
+
// identity on UAT failure (e.g. 20064 → UAT_REVOKED), and returns a
|
|
11
|
+
// well-formed combined error when both sides fail.
|
|
12
|
+
// 3. invalidateIdentity clears the 30s cache so a new probe is taken next
|
|
13
|
+
// time (CredentialsMonitor will call this on UAT change).
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const assert = require('node:assert/strict');
|
|
18
|
+
const {
|
|
19
|
+
IdentityState,
|
|
20
|
+
resolveIdentity,
|
|
21
|
+
withIdentityFallback,
|
|
22
|
+
invalidateIdentity,
|
|
23
|
+
} = require('./auth/identity-state');
|
|
24
|
+
|
|
25
|
+
// Minimal fake client. Real LarkOfficialClient exposes hasUAT (getter), appId,
|
|
26
|
+
// _uat, _uatExpires; tests only need those.
|
|
27
|
+
function fakeClient({ hasUAT = false, appId = 'cli_test', expires = 0 } = {}) {
|
|
28
|
+
return {
|
|
29
|
+
appId,
|
|
30
|
+
appSecret: 'secret',
|
|
31
|
+
_uat: hasUAT ? 'token' : null,
|
|
32
|
+
_uatRefresh: hasUAT ? 'refresh' : null,
|
|
33
|
+
_uatExpires: expires,
|
|
34
|
+
get hasUAT() { return !!this._uat; },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function run() {
|
|
39
|
+
// --- 1. enum values are exported ---
|
|
40
|
+
for (const k of ['VALID_USER', 'UAT_EXPIRED', 'UAT_REVOKED', 'UAT_MISSING_SCOPE', 'BOT_ONLY', 'NO_CREDENTIALS']) {
|
|
41
|
+
assert.ok(IdentityState[k], `IdentityState.${k} should be defined`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- 2. resolveIdentity, no UAT, has app ---
|
|
45
|
+
const c1 = fakeClient({ hasUAT: false, appId: 'cli_x' });
|
|
46
|
+
invalidateIdentity(c1);
|
|
47
|
+
assert.equal(await resolveIdentity(c1), IdentityState.BOT_ONLY);
|
|
48
|
+
|
|
49
|
+
// --- 3. resolveIdentity, no UAT, no app ---
|
|
50
|
+
const c2 = fakeClient({ hasUAT: false, appId: null });
|
|
51
|
+
invalidateIdentity(c2);
|
|
52
|
+
assert.equal(await resolveIdentity(c2), IdentityState.NO_CREDENTIALS);
|
|
53
|
+
|
|
54
|
+
// --- 4. resolveIdentity, valid UAT (future expiry) ---
|
|
55
|
+
const c3 = fakeClient({ hasUAT: true, expires: Math.floor(Date.now() / 1000) + 3600 });
|
|
56
|
+
invalidateIdentity(c3);
|
|
57
|
+
assert.equal(await resolveIdentity(c3), IdentityState.VALID_USER);
|
|
58
|
+
|
|
59
|
+
// --- 5. resolveIdentity, expired UAT (past expiry) ---
|
|
60
|
+
const c4 = fakeClient({ hasUAT: true, expires: Math.floor(Date.now() / 1000) - 100 });
|
|
61
|
+
invalidateIdentity(c4);
|
|
62
|
+
assert.equal(await resolveIdentity(c4), IdentityState.UAT_EXPIRED);
|
|
63
|
+
|
|
64
|
+
// --- 6. resolveIdentity cache: change underlying state, get cached value ---
|
|
65
|
+
invalidateIdentity(c1);
|
|
66
|
+
assert.equal(await resolveIdentity(c1), IdentityState.BOT_ONLY);
|
|
67
|
+
c1._uat = 'token'; // simulate adopt-persisted-uat happened mid-flight
|
|
68
|
+
c1._uatExpires = Math.floor(Date.now() / 1000) + 3600;
|
|
69
|
+
// Still cached — 30s window not elapsed.
|
|
70
|
+
assert.equal(await resolveIdentity(c1), IdentityState.BOT_ONLY, 'cache should hold within 30s');
|
|
71
|
+
invalidateIdentity(c1);
|
|
72
|
+
assert.equal(await resolveIdentity(c1), IdentityState.VALID_USER, 'invalidate reads fresh state');
|
|
73
|
+
|
|
74
|
+
// --- 7. withIdentityFallback: UAT path succeeds ---
|
|
75
|
+
const cOk = fakeClient({ hasUAT: true, expires: Math.floor(Date.now() / 1000) + 3600 });
|
|
76
|
+
invalidateIdentity(cOk);
|
|
77
|
+
const r1 = await withIdentityFallback({
|
|
78
|
+
client: cOk,
|
|
79
|
+
uatFn: async () => ({ code: 0, data: { ok: true } }),
|
|
80
|
+
botFn: async () => { throw new Error('should not run'); },
|
|
81
|
+
label: 'test_op',
|
|
82
|
+
});
|
|
83
|
+
assert.equal(r1.via, 'uat');
|
|
84
|
+
assert.equal(r1.identity, IdentityState.VALID_USER);
|
|
85
|
+
assert.equal(r1.data.code, 0);
|
|
86
|
+
assert.equal(r1.data.ok, undefined, 'should pass through fields, not double-wrap');
|
|
87
|
+
assert.equal(r1.data.data.ok, true);
|
|
88
|
+
assert.equal(r1.viaReason, undefined, 'no fallback → no via_reason');
|
|
89
|
+
|
|
90
|
+
// --- 8. withIdentityFallback: UAT returns 20064 → bot fallback, identity refined ---
|
|
91
|
+
let botRan = false;
|
|
92
|
+
const cRevoked = fakeClient({ hasUAT: true, expires: Math.floor(Date.now() / 1000) + 3600 });
|
|
93
|
+
invalidateIdentity(cRevoked);
|
|
94
|
+
const r2 = await withIdentityFallback({
|
|
95
|
+
client: cRevoked,
|
|
96
|
+
uatFn: async () => ({ code: 20064, msg: 'invalid_grant' }),
|
|
97
|
+
botFn: async () => { botRan = true; return { code: 0, data: { from: 'bot' } }; },
|
|
98
|
+
label: 'test_revoked',
|
|
99
|
+
});
|
|
100
|
+
assert.equal(botRan, true);
|
|
101
|
+
assert.equal(r2.via, 'bot');
|
|
102
|
+
assert.equal(r2.identity, IdentityState.UAT_REVOKED, 'identity refined on 20064');
|
|
103
|
+
assert.ok(typeof r2.viaReason === 'string' && r2.viaReason.includes('20064'));
|
|
104
|
+
assert.ok(r2.fallbackWarning && r2.fallbackWarning.includes('UAT'));
|
|
105
|
+
|
|
106
|
+
// Subsequent resolveIdentity sees the refined cached state without re-probe.
|
|
107
|
+
assert.equal(await resolveIdentity(cRevoked), IdentityState.UAT_REVOKED);
|
|
108
|
+
|
|
109
|
+
// --- 9. withIdentityFallback: UAT 99991668 → UAT_MISSING_SCOPE ---
|
|
110
|
+
const cScope = fakeClient({ hasUAT: true, expires: Math.floor(Date.now() / 1000) + 3600 });
|
|
111
|
+
invalidateIdentity(cScope);
|
|
112
|
+
const r3 = await withIdentityFallback({
|
|
113
|
+
client: cScope,
|
|
114
|
+
uatFn: async () => ({ code: 99991668, msg: 'scope not granted' }),
|
|
115
|
+
botFn: async () => ({ code: 0, data: { ok: true } }),
|
|
116
|
+
label: 'test_scope',
|
|
117
|
+
});
|
|
118
|
+
assert.equal(r3.identity, IdentityState.UAT_MISSING_SCOPE);
|
|
119
|
+
assert.equal(r3.via, 'bot');
|
|
120
|
+
|
|
121
|
+
// --- 10. withIdentityFallback: UAT throws → bot fallback ---
|
|
122
|
+
const cThrow = fakeClient({ hasUAT: true, expires: Math.floor(Date.now() / 1000) + 3600 });
|
|
123
|
+
invalidateIdentity(cThrow);
|
|
124
|
+
const r4 = await withIdentityFallback({
|
|
125
|
+
client: cThrow,
|
|
126
|
+
uatFn: async () => { throw new Error('network blew up'); },
|
|
127
|
+
botFn: async () => ({ code: 0, data: { ok: 'bot' } }),
|
|
128
|
+
label: 'test_throw',
|
|
129
|
+
});
|
|
130
|
+
assert.equal(r4.via, 'bot');
|
|
131
|
+
assert.ok(r4.viaReason.includes('network blew up'));
|
|
132
|
+
|
|
133
|
+
// --- 11. withIdentityFallback: BOT_ONLY → no uat attempted, informational warning ---
|
|
134
|
+
const cBotOnly = fakeClient({ hasUAT: false, appId: 'cli_x' });
|
|
135
|
+
invalidateIdentity(cBotOnly);
|
|
136
|
+
let uatRan = false;
|
|
137
|
+
const r5 = await withIdentityFallback({
|
|
138
|
+
client: cBotOnly,
|
|
139
|
+
uatFn: async () => { uatRan = true; return { code: 0 }; },
|
|
140
|
+
botFn: async () => ({ code: 0, data: { ok: 'bot' } }),
|
|
141
|
+
label: 'test_botonly',
|
|
142
|
+
});
|
|
143
|
+
assert.equal(uatRan, false, 'BOT_ONLY must not invoke uatFn');
|
|
144
|
+
assert.equal(r5.via, 'bot');
|
|
145
|
+
assert.equal(r5.identity, IdentityState.BOT_ONLY);
|
|
146
|
+
// BOT_ONLY still attaches the legacy "未配置 UAT" informational warning so
|
|
147
|
+
// users notice that resources will be owned by the shared bot.
|
|
148
|
+
assert.ok(r5.fallbackWarning && r5.fallbackWarning.includes('未配置 UAT'));
|
|
149
|
+
|
|
150
|
+
// --- 12. withIdentityFallback: both sides fail → throws combined error ---
|
|
151
|
+
const cBoth = fakeClient({ hasUAT: true, expires: Math.floor(Date.now() / 1000) + 3600 });
|
|
152
|
+
invalidateIdentity(cBoth);
|
|
153
|
+
let caught;
|
|
154
|
+
try {
|
|
155
|
+
await withIdentityFallback({
|
|
156
|
+
client: cBoth,
|
|
157
|
+
uatFn: async () => ({ code: 99991668, msg: 'no scope' }),
|
|
158
|
+
botFn: async () => { throw new Error('bot not in chat'); },
|
|
159
|
+
label: 'test_both_fail',
|
|
160
|
+
});
|
|
161
|
+
} catch (e) { caught = e; }
|
|
162
|
+
assert.ok(caught, 'both-fail should throw');
|
|
163
|
+
assert.ok(caught.message.includes('test_both_fail'));
|
|
164
|
+
assert.ok(caught.message.includes('bot not in chat'));
|
|
165
|
+
assert.ok(caught.uatSummary, 'combined error carries uatSummary');
|
|
166
|
+
assert.ok(caught.botError, 'combined error carries botError');
|
|
167
|
+
|
|
168
|
+
console.log('identity-state.js: PASS');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (require.main === module) run().catch(e => { console.error(e); process.exit(1); });
|
|
172
|
+
module.exports = { run };
|