| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you under the Apache License, Version 2.0 (the | |
| * "License"); you may not use this file except in compliance | |
| * with the License. You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, | |
| * software distributed under the License is distributed on an | |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| * KIND, either express or implied. See the License for the | |
| * specific language governing permissions and limitations | |
| * under the License. | |
| */ | |
| import { test } from 'node:test'; | |
| import assert from 'node:assert/strict'; | |
| import { createCli } from '../src/cli.js'; | |
| import { createCliSources } from '../src/cli-sources.js'; | |
| const config = { | |
| cliPath: '/bin/lark-cli', | |
| appId: 'app', | |
| accountId: 'user', | |
| instanceId: 'test', | |
| kinds: ['documents'] as any, | |
| }; | |
| const caller = () => ({ | |
| invocation: { abortSignal: new AbortController().signal }, | |
| }); | |
| const status = { | |
| appId: 'app', | |
| identities: { user: { openId: 'user', status: 'ready' } }, | |
| }; | |
| test('CLI pins account, enforces user identity and rejects mutation commands', async () => { | |
| let switched = false; | |
| let commands: string[][] = []; | |
| const c = createCli(config, async (args) => { | |
| commands.push(args); | |
| return args[0] === 'auth' | |
| ? { ...status, appId: switched ? 'other' : 'app' } | |
| : { ok: true, identity: 'user', data: {} }; | |
| }); | |
| await c.call(['task', 'tasks', 'get', '--task-guid', 'task'], caller()); | |
| assert.deepEqual(commands[1].slice(-4), ['--as', 'user', '--format', 'json']); | |
| await assert.rejects(c.call(['task', 'tasks', 'delete'], caller()), /allowlist/); | |
| switched = true; | |
| await assert.rejects(c.call(['task', 'tasks', 'get'], caller()), /account changed/); | |
| const bot = createCli(config, async (args) => | |
| args[0] === 'auth' ? status : { ok: true, identity: 'bot', data: {} }, | |
| ); | |
| await assert.rejects(bot.call(['task', 'tasks', 'get'], caller()), /user read/); | |
| }); | |
| test('doc discovery uses metadata only, wiki resolves to native ID, changed revisions stay visible', async () => { | |
| let revision = 1; | |
| const calls: string[][] = []; | |
| const run = async (args: string[]) => { | |
| calls.push(args); | |
| if (args[0] === 'auth') return status; | |
| let data: any; | |
| if (args[1] === '+search') | |
| data = { | |
| results: [{ result_meta: { url: 'https://example.feishu.cn/wiki/node' } }], | |
| has_more: false, | |
| }; | |
| else if (args[1] === '+inspect') data = { type: 'docx', token: 'doc' }; | |
| else if (args[0] === 'api') | |
| data = { | |
| document: { | |
| document_id: 'doc', | |
| revision_id: revision, | |
| title: 'Document', | |
| }, | |
| }; | |
| else if (args[1] === '+fetch') | |
| data = { | |
| document: { | |
| document_id: 'doc', | |
| revision_id: revision, | |
| content: 'Body', | |
| }, | |
| }; | |
| return { ok: true, identity: 'user', data }; | |
| }; | |
| const [source] = createCliSources(config, run); | |
| const page = await source.enumerate(undefined, caller()); | |
| assert.equal(page.items[0].id, 'doc'); | |
| assert.equal( | |
| calls.some((a) => a[1] === '+fetch'), | |
| false, | |
| ); | |
| assert.equal(JSON.stringify(page).includes('Body'), false); | |
| revision = 2; | |
| const read = await source.read(page.items[0], caller()); | |
| assert.equal(read.object.revision, '2'); | |
| assert.equal(read.status, 'version_unavailable'); | |
| assert.equal(read.content, undefined); | |
| const latest = await source.read(read.object, caller()); | |
| assert.equal(latest.content.content, 'Body'); | |
| await assert.rejects( | |
| source.read({ ...page.items[0], locator: { nativeId: 'different' } }, caller()), | |
| /locator/, | |
| ); | |
| }); | |
| test('empty provider pages retain signed cursors bound to query and source instance', async () => { | |
| const run = async (args: string[]) => | |
| args[0] === 'auth' | |
| ? status | |
| : { | |
| ok: true, | |
| identity: 'user', | |
| data: { | |
| results: [], | |
| has_more: !args.includes('--page-token'), | |
| page_token: 'next', | |
| }, | |
| }; | |
| const [source] = createCliSources(config, run); | |
| const first = await source.query({ text: 'hello', limit: 3 }, caller()); | |
| assert.equal(first.items.length, 0); | |
| assert.ok(first.next); | |
| assert.equal( | |
| (await source.query({ text: 'hello', limit: 3, cursor: first.next }, caller())).next, | |
| undefined, | |
| ); | |
| await assert.rejects( | |
| source.query({ text: 'changed', limit: 3, cursor: first.next }, caller()), | |
| /query changed/, | |
| ); | |
| const [other] = createCliSources(config, run); | |
| await assert.rejects( | |
| other.query({ text: 'hello', limit: 3, cursor: first.next }, caller()), | |
| /Invalid source cursor/, | |
| ); | |
| }); | |
| test('tasks preserve native fields and changed content produces a different revision', async () => { | |
| let completed = '0'; | |
| const run = async (args: string[]) => | |
| args[0] === 'auth' | |
| ? status | |
| : { | |
| ok: true, | |
| identity: 'user', | |
| data: | |
| args[2] === 'list' | |
| ? { items: [{ guid: 't' }] } | |
| : { | |
| task: { | |
| guid: 't', | |
| summary: 'A', | |
| completed_at: completed, | |
| updated_at: '100', | |
| }, | |
| }, | |
| }; | |
| const [source] = createCliSources({ ...config, kinds: ['tasks'] }, run); | |
| const old = (await source.query({}, caller())).items[0]; | |
| completed = '123'; | |
| const latest = await source.read(old, caller()); | |
| assert.notEqual(latest.object.revision, old.revision); | |
| assert.equal(latest.content.completed_at, '123'); | |
| }); | |
| test('calendar has explicit time bounds, reads actual event details and exposes cancellation', async () => { | |
| const calls: string[][] = []; | |
| let cancelled = false; | |
| const [source] = createCliSources( | |
| { | |
| ...config, | |
| kinds: ['calendar'], | |
| startTime: 1700000000, | |
| endTime: 1700100000, | |
| }, | |
| async (args) => { | |
| calls.push(args); | |
| if (args[0] === 'auth') return status; | |
| return { | |
| ok: true, | |
| identity: 'user', | |
| data: | |
| args[1] === '+agenda' | |
| ? [{ event_id: 'event_0' }] | |
| : { | |
| event: { | |
| event_id: 'event_0', | |
| summary: 'Meeting', | |
| status: cancelled ? 'cancelled' : 'confirmed', | |
| start_time: { timestamp: '1700001000' }, | |
| end_time: { timestamp: '1700002000' }, | |
| }, | |
| }, | |
| }; | |
| }, | |
| ); | |
| const o = (await source.enumerate(undefined, caller())).items[0]; | |
| assert.equal(o.kind, 'calendar'); | |
| assert.ok(calls.some((a) => a.includes('2023-11-14T22:13:20.000Z'))); | |
| assert.equal((await source.read(o, caller())).content.start_time.timestamp, '1700001000'); | |
| cancelled = true; | |
| assert.equal((await source.read(o, caller())).status, 'deleted'); | |
| assert.throws(() => createCliSources({ ...config, kinds: ['calendar'] }), /explicit/); | |
| }); | |
| test('cancelled callers never launch CLI and raw writes are rejected', async () => { | |
| const abort = new AbortController(); | |
| abort.abort(new Error('test cancelled')); | |
| let launched = 0; | |
| const cli = createCli(config, async () => { | |
| launched++; | |
| return status; | |
| }); | |
| await assert.rejects( | |
| cli.call(['task', 'tasks', 'get'], { invocation: { abortSignal: abort.signal } }), | |
| /test cancelled/, | |
| ); | |
| await assert.rejects( | |
| cli.call(['api', 'DELETE', '/open-apis/im/v1/messages/om_1'], caller()), | |
| /allowlist/, | |
| ); | |
| assert.equal(launched, 0); | |
| }); | |
| test('provider permission failure cannot become a successful empty scan', async () => { | |
| const [source] = createCliSources(config, async (args) => | |
| args[0] === 'auth' ? status : { ok: false, identity: 'user', error: { code: 99991672 } }, | |
| ); | |
| await assert.rejects(source.enumerate(undefined, caller()), /read failed/); | |
| }); | |
| test('explicit CLI profile applies to both identity validation and data reads', async () => { | |
| const calls: string[][] = []; | |
| const cli = createCli({ ...config, cliProfile: 'personal' }, async (args) => { | |
| calls.push(args); | |
| return args[0] === 'auth' ? status : { ok: true, identity: 'user', data: {} }; | |
| }); | |
| await cli.call(['task', 'tasks', 'get', '--task-guid', 't'], caller()); | |
| assert.ok(calls.every((a) => a.slice(-2).join(' ') === '--profile personal')); | |
| }); | |
| test('rate limits back off and retry reads, while authorization errors fail immediately', async () => { | |
| let reads = 0; | |
| const waits: number[] = []; | |
| const cli = createCli( | |
| config, | |
| async (args) => { | |
| if (args[0] === 'auth') return status; | |
| reads++; | |
| return reads < 3 | |
| ? { ok: false, identity: 'user', error: { code: 99991400 } } | |
| : { ok: true, identity: 'user', data: { ok: true } }; | |
| }, | |
| async (ms) => { | |
| waits.push(ms); | |
| }, | |
| ); | |
| assert.deepEqual(await cli.call(['task', 'tasks', 'get'], caller()), { ok: true }); | |
| assert.equal(reads, 3); | |
| assert.ok(waits.includes(1000)); | |
| assert.ok(waits.includes(2000)); | |
| let denied = 0; | |
| const bad = createCli( | |
| config, | |
| async (args) => | |
| args[0] === 'auth' | |
| ? status | |
| : (denied++, { ok: false, identity: 'user', error: { code: 99991672 } }), | |
| async () => {}, | |
| ); | |
| await assert.rejects(bad.call(['task', 'tasks', 'get'], caller()), /99991672/); | |
| assert.equal(denied, 1); | |
| }); | |
| test('message sources are injected local caches; other CLI sources retain their behavior', () => { | |
| const local = { id: 'feishu.test.messages', query: () => ({ items: [] }) }; | |
| const sources = createCliSources({ ...config, kinds: [], messageSource: local }); | |
| assert.equal(sources[0], local); | |
| assert.deepEqual(createCliSources({ ...config, kinds: [] }), []); | |
| }); | |
| test('refreshable pinned login can read; switched identities and unusable login states cannot', async () => { | |
| for (const [state, appId, openId, allowed] of [ | |
| ['needs_refresh', 'app', 'user', true], | |
| ['needs_refresh', 'other', 'user', false], | |
| ['needs_refresh', 'app', 'other', false], | |
| ['expired', 'app', 'user', false], | |
| ['not_logged_in', 'app', 'user', false], | |
| ['unknown', 'app', 'user', false], | |
| ] as const) { | |
| let reads = 0; | |
| const cli = createCli(config, async (args) => { | |
| if (args[0] === 'auth') return { appId, identities: { user: { openId, status: state } } }; | |
| reads++; | |
| return { ok: true, identity: 'user', data: { refreshed: true } }; | |
| }); | |
| const request = cli.call(['task', 'tasks', 'get'], caller()); | |
| if (allowed) assert.deepEqual(await request, { refreshed: true }); | |
| else await assert.rejects(request, /account changed/); | |
| assert.equal(reads, allowed ? 1 : 0); | |
| } | |
| }); | |
| for (const code of ['invalid_response', 'command_timeout', 'EAGAIN', 'EMFILE', 'ENFILE']) { | |
| test(`transient ${code} retries auth and reads twice, then same invocation can recover`, async () => { | |
| let authCalls = 0, | |
| reads = 0, | |
| fail = true; | |
| const waits: number[] = []; | |
| const { FeishuCliError } = await import('../src/cli.js'); | |
| const cli = createCli( | |
| config, | |
| async (args) => { | |
| if (args[0] === 'auth') { | |
| authCalls++; | |
| if (fail) throw new FeishuCliError(code); | |
| return status; | |
| } | |
| reads++; | |
| if (reads < 3) throw new FeishuCliError(code); | |
| return { ok: true, identity: 'user', data: { recovered: true } }; | |
| }, | |
| async (ms) => { | |
| waits.push(ms); | |
| }, | |
| ); | |
| const c = caller(); | |
| await assert.rejects(cli.verify(c), new RegExp(code)); | |
| assert.equal(authCalls, 3); | |
| assert.deepEqual(waits, [250, 500]); | |
| fail = false; | |
| assert.deepEqual(await cli.call(['task', 'tasks', 'get'], c), { recovered: true }); | |
| assert.equal(authCalls, 4); | |
| assert.equal(reads, 3); | |
| assert.ok(waits.filter((x) => x === 250).length >= 2); | |
| assert.ok(waits.filter((x) => x === 500).length >= 2); | |
| await cli.verify(c); | |
| assert.equal(authCalls, 4, 'successful identity remains cached'); | |
| }); | |
| } | |
| test('command timeout is classified, read retry is bounded, auth denial and account mismatch never retry', async () => { | |
| const waits: number[] = []; | |
| let calls = 0; | |
| const timeout = createCli( | |
| config, | |
| async (args) => { | |
| if (args[0] === 'auth') return status; | |
| calls++; | |
| throw Object.assign(Error('private stderr must not escape'), { | |
| killed: true, | |
| signal: 'SIGTERM', | |
| }); | |
| }, | |
| async (ms) => { | |
| waits.push(ms); | |
| }, | |
| ); | |
| await assert.rejects(timeout.call(['task', 'tasks', 'get'], caller()), /command_timeout/); | |
| assert.equal(calls, 3); | |
| for (const auth of [{ error: { code: 99991672 } }, { ...status, appId: 'wrong' }]) { | |
| let attempts = 0; | |
| const denied = createCli( | |
| config, | |
| async () => { | |
| attempts++; | |
| return auth; | |
| }, | |
| async () => { | |
| assert.fail('must not retry permission/account failure'); | |
| }, | |
| ); | |
| await assert.rejects(denied.verify(caller()), /99991672|account changed/); | |
| assert.equal(attempts, 1); | |
| } | |
| }); | |
| test('abort during transient backoff stops retry and clears failed verification', async () => { | |
| const { FeishuCliError } = await import('../src/cli.js'); | |
| const abort = new AbortController(); | |
| let calls = 0; | |
| const cli = createCli( | |
| config, | |
| async () => { | |
| calls++; | |
| throw new FeishuCliError('EAGAIN'); | |
| }, | |
| async () => { | |
| abort.abort(Error('cancel retry')); | |
| }, | |
| ); | |
| await assert.rejects(cli.verify({ invocation: { abortSignal: abort.signal } }), /cancel retry/); | |
| assert.equal(calls, 1); | |
| }); |