Add LINE channel adapter with webhook ingress and gating
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import { createHmac } from 'crypto';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
import { LineAdapter } from './adapter.js';
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
function mockReq(body: string, secret: string): IncomingMessage {
|
||||
const signature = createHmac('sha256', secret).update(body).digest('base64');
|
||||
const req = {
|
||||
headers: { 'x-line-signature': signature },
|
||||
on(event: string, handler: (...args: unknown[]) => void) {
|
||||
if (event === 'data') {
|
||||
handler(Buffer.from(body, 'utf8'));
|
||||
}
|
||||
if (event === 'end') {
|
||||
handler();
|
||||
}
|
||||
return this;
|
||||
},
|
||||
off: () => req,
|
||||
destroy: () => undefined,
|
||||
} as unknown as IncomingMessage;
|
||||
return req;
|
||||
}
|
||||
|
||||
function mockRes() {
|
||||
const state = {
|
||||
statusCode: 0,
|
||||
body: '',
|
||||
};
|
||||
const res = {
|
||||
writeHead: (code: number) => {
|
||||
state.statusCode = code;
|
||||
},
|
||||
end: (chunk?: string) => {
|
||||
state.body = chunk ?? '';
|
||||
},
|
||||
} as unknown as ServerResponse;
|
||||
return { res, state };
|
||||
}
|
||||
|
||||
describe('LineAdapter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
it('has name line and starts disconnected', () => {
|
||||
const adapter = new LineAdapter({
|
||||
channelAccessToken: 'token',
|
||||
channelSecret: 'secret',
|
||||
});
|
||||
expect(adapter.name).toBe('line');
|
||||
expect(adapter.status).toBe('disconnected');
|
||||
});
|
||||
|
||||
it('send posts LINE push API', async () => {
|
||||
const adapter = new LineAdapter({
|
||||
channelAccessToken: 'token',
|
||||
channelSecret: 'secret',
|
||||
});
|
||||
await adapter.connect();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => '',
|
||||
} as Response);
|
||||
|
||||
await adapter.send('U123', { text: 'hello line' });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe('https://api.line.me/v2/bot/message/push');
|
||||
});
|
||||
|
||||
it('handleRequest validates signature and dispatches text event', async () => {
|
||||
const adapter = new LineAdapter({
|
||||
channelAccessToken: 'token',
|
||||
channelSecret: 'secret',
|
||||
requireMention: false,
|
||||
});
|
||||
const inbound: Array<{ channel: string; text: string; senderId: string }> = [];
|
||||
adapter.onMessage((msg) => inbound.push({ channel: msg.channel, text: msg.text, senderId: msg.senderId }));
|
||||
|
||||
const body = JSON.stringify({
|
||||
events: [{
|
||||
type: 'message',
|
||||
timestamp: 1,
|
||||
source: { type: 'user', userId: 'U123' },
|
||||
message: { id: 'm1', type: 'text', text: 'ping' },
|
||||
}],
|
||||
});
|
||||
const req = mockReq(body, 'secret');
|
||||
const { res, state } = mockRes();
|
||||
|
||||
await adapter.handleRequest(req, res);
|
||||
expect(state.statusCode).toBe(200);
|
||||
expect(inbound).toEqual([{ channel: 'line', text: 'ping', senderId: 'U123' }]);
|
||||
});
|
||||
|
||||
it('drops group messages without mention when require_mention=true', async () => {
|
||||
const adapter = new LineAdapter({
|
||||
channelAccessToken: 'token',
|
||||
channelSecret: 'secret',
|
||||
requireMention: true,
|
||||
mentionName: 'flynn',
|
||||
});
|
||||
const handler = vi.fn();
|
||||
adapter.onMessage(handler);
|
||||
|
||||
await adapter.handleEvent({
|
||||
type: 'message',
|
||||
source: { type: 'group', groupId: 'G123', userId: 'U123' },
|
||||
message: { id: 'm1', type: 'text', text: 'hello there' },
|
||||
});
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects invalid signature', async () => {
|
||||
const adapter = new LineAdapter({
|
||||
channelAccessToken: 'token',
|
||||
channelSecret: 'secret',
|
||||
});
|
||||
const body = JSON.stringify({ events: [] });
|
||||
const req = {
|
||||
headers: { 'x-line-signature': 'invalid' },
|
||||
on(event: string, handler: (...args: unknown[]) => void) {
|
||||
if (event === 'data') {
|
||||
handler(Buffer.from(body, 'utf8'));
|
||||
}
|
||||
if (event === 'end') {
|
||||
handler();
|
||||
}
|
||||
return this;
|
||||
},
|
||||
off: () => req,
|
||||
destroy: () => undefined,
|
||||
} as unknown as IncomingMessage;
|
||||
const { res, state } = mockRes();
|
||||
|
||||
await adapter.handleRequest(req, res);
|
||||
expect(state.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user