blob: dbbee4317ddfbb0660d477630b4fc143d4783a57 [file]
/*
* 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 { createFeishuSource } from '../src/adapter.js';
test('Feishu pages, thread expansion, permission reads and stable versions are read-only', async () => {
const root = {
message_id: 'om_root',
chat_id: 'oc_chat',
thread_id: 'omt_t',
msg_type: 'text',
create_time: '2000',
body: { content: '采购进展' },
};
const reply = {
...root,
message_id: 'om_reply',
parent_id: 'om_root',
create_time: '3000',
body: { content: '周五交付' },
};
const requests: any[] = [];
const source = createFeishuSource(
{
instanceId: 'test',
containers: [{ type: 'chat', id: 'oc_chat' }],
startTime: 1,
endTime: 10,
},
async () => 'test-token',
async (url, init) => {
requests.push({ url: String(url), method: init?.method });
assert.equal(init?.headers?.['Authorization'], 'Bearer test-token');
const u = new URL(String(url));
let data: any;
if (u.pathname.endsWith('/om_reply')) data = { items: [reply] };
else if (u.searchParams.get('container_id_type') === 'thread')
data = { items: [reply], has_more: false };
else data = { items: [root], has_more: false };
return new Response(JSON.stringify({ code: 0, data }), { status: 200 });
},
);
const caller = { invocation: { abortSignal: new AbortController().signal } };
const first = await source.enumerate(undefined, caller);
assert.equal(first.items.length, 1);
assert.ok(first.next);
const second = await source.enumerate(first.next, caller);
assert.equal(second.items[0].id, 'om_reply');
assert.equal(second.next, undefined);
assert.deepEqual(await source.authorize(second.items, caller), ['om_reply']);
assert.equal(requests.length, 2);
const read = await source.read(second.items[0], { invocation: {} });
assert.equal(read.object.revision, second.items[0].revision);
assert.equal(requests.length, 3);
assert.ok(requests.every((r) => r.method === 'GET'));
await assert.rejects(source.enumerate(first.next + 'tampered', caller), /cursor/);
});
test('empty filtered page retains cursor; API errors and unconfigured containers fail closed', async () => {
assert.throws(
() => createFeishuSource({ instanceId: 'x', containers: [], startTime: 1 }, async () => ''),
/containers/,
);
const source = createFeishuSource(
{ instanceId: 'x', containers: [{ type: 'chat', id: 'oc_a' }], startTime: 1 },
async () => 'test',
async () =>
new Response(
JSON.stringify({ code: 0, data: { items: [], has_more: true, page_token: 'next' } }),
),
);
const page = await source.query({ text: 'missing' }, { invocation: {} });
assert.equal(page.items.length, 0);
assert.ok(page.next);
const denied = createFeishuSource(
{ instanceId: 'x', containers: [{ type: 'chat', id: 'oc_a' }], startTime: 1 },
async () => 'test',
async () => new Response(JSON.stringify({ code: 99991672, msg: 'secret should not leak' })),
);
await assert.rejects(denied.enumerate(undefined, { invocation: {} }), /99991672/);
});
test('recent replies on old roots remain discoverable within the requested time scope', async () => {
const source = createFeishuSource(
{
instanceId: 'old-root',
containers: [{ type: 'chat', id: 'oc_chat' }],
startTime: 10,
endTime: 30,
},
async () => 'test',
async (url) => {
const u = new URL(String(url));
const thread = u.searchParams.get('container_id_type') === 'thread';
const m = {
message_id: thread ? 'om_reply' : 'om_old',
chat_id: 'oc_chat',
thread_id: 'omt_t',
create_time: thread ? '20000' : '1000',
msg_type: 'text',
body: { content: 'content' },
};
return new Response(JSON.stringify({ code: 0, data: { items: [m], has_more: false } }));
},
);
const caller = { invocation: {} };
const first = await source.enumerate(undefined, caller);
assert.equal(first.items.length, 0);
assert.ok(first.next);
const next = await source.enumerate(first.next, caller);
assert.equal(next.items[0].id, 'om_reply');
});
test('all chats discovery paginates, includes private chats and notices newly joined chats on fresh scans', async () => {
let added = false;
const queried: string[] = [];
const message = (chat: string) => ({
message_id: 'om_' + chat,
chat_id: chat,
msg_type: 'text',
create_time: '2000',
body: { content: 'hello' },
});
const source = createFeishuSource(
{ instanceId: 'all', containers: [], startTime: 0 },
async () => '',
fetch,
async (_path, q) => {
queried.push(q.container_id);
return { items: [message(q.container_id)], has_more: false };
},
{
chats: async (cursor) =>
cursor === 'page2'
? { items: [{ type: 'chat', id: added ? 'oc_new' : 'oc_private' }] }
: { items: [{ type: 'chat', id: 'oc_group' }], next: 'page2' },
search: async () => ({ items: [] }),
},
);
const caller = { invocation: {} };
const a = await source.enumerate(undefined, caller);
assert.ok(a.next);
const b = await source.enumerate(a.next, caller);
assert.equal(b.next, undefined);
assert.deepEqual(queried, ['oc_group', 'oc_private']);
assert.equal(source.scope.containers, 'all-accessible');
added = true;
const c = await source.enumerate(undefined, caller);
const d = await source.enumerate(c.next, caller);
assert.equal(d.items[0].locator.chatId, 'oc_new');
assert.deepEqual(await source.authorize(d.items, { invocation: {} }), ['om_oc_new']);
});
test('empty discovery pages retain cursors; chat/time/type filters and pagination identity are enforced', async () => {
const calls: any[] = [];
const source = createFeishuSource(
{ instanceId: 'all', containers: [], startTime: 0 },
async () => '',
fetch,
async (_path, q) => {
calls.push(q);
return {
items: [
{
message_id: 'om_a',
chat_id: q.container_id,
msg_type: 'text',
create_time: '2000',
},
],
has_more: !q.page_token,
page_token: 'm2',
};
},
{
chats: async (cursor) =>
cursor ? { items: [{ type: 'chat', id: 'oc_a' }] } : { items: [], next: 'c2' },
search: async (q) => ({
items: [{ message_id: 'om_a', chat_id: q.chatId, msg_type: 'text', create_time: '2000' }],
...(q.cursor ? {} : { next: 'filtered2' }),
}),
},
);
const c = { invocation: {} };
const empty = await source.enumerate(undefined, c);
assert.equal(empty.items.length, 0);
assert.ok(empty.next);
const page = await source.enumerate(empty.next, c);
assert.equal(page.items.length, 1);
const q = { chatId: 'oc_other', startTime: 3, endTime: 10, types: ['text'], limit: 1 };
const filtered = await source.query(q, c);
assert.equal(filtered.items.length, 0);
assert.ok(filtered.next);
assert.equal(calls.at(-1).page_size, '50');
await assert.rejects(
source.query({ ...q, chatId: 'oc_changed', cursor: filtered.next }, c),
/query changed/,
);
await assert.rejects(source.query({ startTime: 10, endTime: 2 }, c), /time range/);
});
test('discovery failures cannot silently complete a scan and explicit containers remain a hard filter', async () => {
const c = { invocation: {} };
const discovery = {
chats: async () => {
throw Error('permission denied');
},
search: async () => {
throw Error('must not search outside explicit scope');
},
};
const all = createFeishuSource(
{ instanceId: 'all', containers: [], startTime: 0 },
async () => '',
fetch,
async () => ({}),
discovery,
);
await assert.rejects(all.enumerate(undefined, c), /permission denied/);
const selected = createFeishuSource(
{ instanceId: 'selected', containers: [{ type: 'chat', id: 'oc_one' }], startTime: 0 },
async () => '',
fetch,
async () => ({
items: [
{
message_id: 'om_a',
chat_id: 'oc_one',
msg_type: 'text',
create_time: '2000',
body: { content: 'not matched' },
},
],
has_more: false,
}),
discovery,
);
assert.equal((await selected.query({ text: 'missing' }, c)).items.length, 0);
await assert.rejects(selected.query({ chatId: 'oc_two' }, c), /outside configured/);
assert.deepEqual(
await selected.authorize([{ id: 'x', locator: { chatId: 'oc_two', createdAt: 2000 } }], c),
[],
);
});