blob: b47141e7537fa752a663ab32f9a38f46b3b9254a [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 { createHash, createHmac } from 'node:crypto';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { MessageCache } from '../src/message-cache.js';
// Local-format fixture, deliberately independent of MessageCache.hash/encode/decode.
const savedKey = Buffer.from(Array.from({ length: 32 }, (_, i) => i)).toString('base64url');
function localCursor(scope: any, shape: any, highWater: number, offset: number) {
const hash = createHash('sha256').update(JSON.stringify({ scope, shape })).digest('hex');
const state = { hash, highWater, offset };
const encoded = Buffer.from(JSON.stringify(state)).toString('base64url');
const signature = createHmac('sha256', Buffer.from(savedKey, 'base64url'))
.update(encoded)
.digest('hex');
return { state, cursor: encoded + '.' + signature };
}
const caller = () => ({ invocation: {} });
const raw = (id: string, chat = 'allowed', text = 'common original') => ({
message_id: id,
chat_id: chat,
create_time: '2000',
msg_type: 'text',
body: { content: text },
});
for (const bounds of [{}, { startTime: 1, endTime: 10 }]) {
test(`independent local cursor interoperates before/after restart with frozen publication and scoped reads: ${JSON.stringify(bounds)}`, async (t) => {
const root = mkdtempSync(join(tmpdir(), 'feishu-cursor-alignment-'));
const config = {
accountId: 'user',
appId: 'app',
instanceId: 'test',
containers: [{ type: 'chat', id: 'allowed' }],
...bounds,
};
const scope = {
accountId: 'user',
appId: 'app',
instanceId: 'test',
mode: 'local-originals',
startTime: 'startTime' in bounds ? bounds.startTime : 0,
endTime: 'endTime' in bounds ? bounds.endTime : null,
};
let cache = new MessageCache(root, config, savedKey);
t.after(() => {
cache.close();
rmSync(root, { recursive: true, force: true });
});
const initial = cache.begin(1, 10);
cache.page(initial, [raw('a'), raw('b'), raw('c'), raw('x', 'other')]);
cache.publish(initial);
const highWater = cache.boundary();
const shape = { limit: 1, text: 'common' };
const local = localCursor(scope, shape, highWater, 1);
let source = cache.source('test', async () => {});
assert.deepEqual(
source.scope.containers,
config.containers,
'source scope still includes chats',
);
const first = await source.query({ ...shape, cursor: local.cursor }, caller());
assert.deepEqual(
first.items.map((o) => o.id),
['b'],
);
assert.equal(
first.next,
localCursor(scope, shape, highWater, 2).cursor,
'emitted signature and fingerprint match the independent local encoder',
);
const b = first.items[0];
const update = cache.begin(1, 10, true);
cache.page(update, [raw('aa'), raw('b', 'allowed', 'common updated')]);
cache.publish(update);
assert.ok(cache.boundary() > highWater);
const frozen = await source.query({ ...shape, cursor: local.cursor }, caller());
assert.equal(frozen.items[0].id, 'b', 'new earlier row cannot shift the frozen offset');
assert.equal(
frozen.items[0].revision,
b.revision,
'new version cannot replace the frozen version',
);
cache.close();
cache = new MessageCache(root, config, savedKey);
source = cache.source('test', async () => {});
const resumed = await source.query({ ...shape, cursor: first.next }, caller());
assert.deepEqual(
resumed.items.map((o) => o.id),
['c'],
);
assert.equal(resumed.next, undefined);
assert.equal(((await source.read(b, caller())) as any).content.body.content, 'common original');
assert.equal(
(await source.query({ ...shape, cursor: local.cursor }, caller())).next,
first.next,
);
const tampered = local.cursor.slice(0, -1) + (local.cursor.endsWith('0') ? '1' : '0');
await assert.rejects(
source.query({ ...shape, cursor: tampered }, caller()),
/Invalid source cursor/,
);
await assert.rejects(
source.query({ ...shape, text: 'changed', cursor: local.cursor }, caller()),
/scope or query changed/,
);
const changed = new MessageCache(
root,
{ ...config, containers: [{ type: 'chat', id: 'other' }] },
savedKey,
);
try {
const restricted = changed.source('test', async () => {});
assert.notDeepEqual(
restricted.scope,
source.scope,
'scan scope changes even though cursor fingerprint excludes chats',
);
assert.deepEqual(
(await restricted.query(shape, caller())).items.map((o) => o.id),
['x'],
);
assert.deepEqual(await restricted.authorize([b], caller()), []);
assert.equal((await restricted.read(b, caller())).status, 'unavailable');
assert.deepEqual(
(await restricted.query({ ...shape, cursor: local.cursor }, caller())).items,
[],
'current chat filtering applies even to a correctly signed old cursor',
);
} finally {
changed.close();
}
});
}
test('invalid stored signing key fails without regenerating or replacing it', async (t) => {
const { default: plugin } = await import('../src/host.js');
const root = mkdtempSync(join(tmpdir(), 'feishu-invalid-key-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
for (const length of [0, 31, 33]) {
assert.throws(
() => new MessageCache(root, {}, Buffer.alloc(length).toString('base64url')),
/decode to 32 bytes/,
);
}
for (const length of [0, 31, 33]) {
const storedKey = Buffer.alloc(length).toString('base64url');
const values = new Map([
['message-cache-directory', root],
['cursor-signing-key', storedKey],
]);
await assert.rejects(
plugin.host.apply(
{
maka: { rootId: 'profile' },
storage: {
get: async (name: string) => ({ value: values.get(name) }),
set: async () => assert.fail('must not replace the existing key'),
},
},
{ instanceId: 'test', accountId: 'user', appId: 'app', cliPath: '/bin/false' },
),
/decode to 32 bytes/,
);
assert.equal(values.get('cursor-signing-key'), storedKey);
}
});