Add central notification channels, rules, queue and delivery logs, fleet organization groups and tags, device discovery, the device action catalog, instance module reads, the log centre, connection settings and system maintenance as native /api/v1 routes backed by the existing secret store, audit trail and pinned upstream transport.
216 lines
6.8 KiB
TypeScript
216 lines
6.8 KiB
TypeScript
import { createServer, type Server, type Socket } from 'node:net';
|
||
import { afterEach, describe, expect, it } from 'vitest';
|
||
|
||
import type { ChannelDeliveryInput } from './channel-delivery.js';
|
||
import { sendEmailNotification } from './smtp-sender.js';
|
||
|
||
interface FakePeer {
|
||
readonly port: number;
|
||
readonly lines: string[];
|
||
stop(): Promise<void>;
|
||
}
|
||
|
||
const peers: FakePeer[] = [];
|
||
|
||
afterEach(async () => {
|
||
for (const peer of peers.splice(0)) await peer.stop();
|
||
});
|
||
|
||
/**
|
||
* Scripted SMTP peer. It answers one reply per command and stays quiet while a
|
||
* message body streams in, which is how a real server behaves after `354`.
|
||
*/
|
||
function fakeServer(script: readonly string[]): Promise<FakePeer> {
|
||
return new Promise((resolve) => {
|
||
const lines: string[] = [];
|
||
const sockets = new Set<Socket>();
|
||
const server: Server = createServer((socket) => {
|
||
sockets.add(socket);
|
||
socket.once('close', () => sockets.delete(socket));
|
||
let index = 0;
|
||
let buffered = '';
|
||
let inData = false;
|
||
socket.write(`${script[index++] ?? '221 bye'}\r\n`);
|
||
socket.on('data', (chunk) => {
|
||
buffered += chunk.toString('utf8');
|
||
let boundary = buffered.indexOf('\r\n');
|
||
while (boundary >= 0) {
|
||
const line = buffered.slice(0, boundary);
|
||
buffered = buffered.slice(boundary + 2);
|
||
boundary = buffered.indexOf('\r\n');
|
||
lines.push(line);
|
||
if (inData) {
|
||
if (line === '.') inData = false;
|
||
else continue;
|
||
}
|
||
if (line === 'DATA') inData = true;
|
||
// A silent peer is deliberate: it lets a test assert the client timeout.
|
||
const reply = script[index++];
|
||
if (reply !== undefined) socket.write(`${reply}\r\n`);
|
||
}
|
||
});
|
||
});
|
||
server.listen(0, '127.0.0.1', () => {
|
||
const address = server.address();
|
||
const peer: FakePeer = {
|
||
port: typeof address === 'object' && address ? address.port : 0,
|
||
lines,
|
||
stop: () =>
|
||
new Promise<void>((done) => {
|
||
for (const socket of sockets) socket.destroy();
|
||
server.close(() => done());
|
||
}),
|
||
};
|
||
peers.push(peer);
|
||
resolve(peer);
|
||
});
|
||
});
|
||
}
|
||
|
||
function input(config: ChannelDeliveryInput['config']): ChannelDeliveryInput {
|
||
return {
|
||
type: 'email',
|
||
config,
|
||
title: '设备离线',
|
||
body: 'SIM 卡所在设备已断开',
|
||
eventType: 'device',
|
||
occurredAt: '2026-09-03T08:00:00.000Z',
|
||
};
|
||
}
|
||
|
||
// One reply per command: greeting, EHLO, AUTH LOGIN, user, password, MAIL FROM,
|
||
// two RCPT TO, DATA, the "." terminator, then QUIT.
|
||
const AUTH_SCRIPT = [
|
||
'220 smtp ready',
|
||
'250 ehlo',
|
||
'334 ' + Buffer.from('Username:').toString('base64'),
|
||
'334 ' + Buffer.from('Password:').toString('base64'),
|
||
'235 authenticated',
|
||
'250 sender ok',
|
||
'250 recipient ok',
|
||
'250 recipient ok',
|
||
'354 send data',
|
||
'250 message accepted',
|
||
'221 bye',
|
||
];
|
||
|
||
const OPEN_SCRIPT = [
|
||
'220 smtp ready',
|
||
'250 ehlo',
|
||
'250 sender ok',
|
||
'250 recipient ok',
|
||
'354 send data',
|
||
'250 message accepted',
|
||
'221 bye',
|
||
];
|
||
|
||
function messageLines(lines: readonly string[]): string[] {
|
||
const start = lines.indexOf('DATA');
|
||
return start < 0 ? [] : lines.slice(start + 1);
|
||
}
|
||
|
||
describe('sendEmailNotification', () => {
|
||
it('runs AUTH LOGIN and submits one UTF-8 message per recipient', async () => {
|
||
const peer = await fakeServer(AUTH_SCRIPT);
|
||
const result = await sendEmailNotification(
|
||
input({
|
||
smtp_host: '127.0.0.1',
|
||
smtp_port: peer.port,
|
||
smtp_security: 'none',
|
||
username: 'notify@example.test',
|
||
password: 'p@ss',
|
||
sender_address: 'notify@example.test',
|
||
sender_name: 'SimAdmin 控制台',
|
||
receiver_addresses: 'ops@example.test, admin@example.test',
|
||
message_format: 'plain',
|
||
}),
|
||
2_000,
|
||
);
|
||
|
||
expect(result).toEqual({ ok: true });
|
||
expect(peer.lines[0]).toMatch(/^EHLO /u);
|
||
expect(peer.lines[1]).toBe('AUTH LOGIN');
|
||
expect(peer.lines[2]).toBe(Buffer.from('notify@example.test').toString('base64'));
|
||
expect(peer.lines[3]).toBe(Buffer.from('p@ss').toString('base64'));
|
||
expect(peer.lines[4]).toBe('MAIL FROM:<notify@example.test>');
|
||
expect(peer.lines.filter((line) => line.startsWith('RCPT TO:<'))).toEqual([
|
||
'RCPT TO:<ops@example.test>',
|
||
'RCPT TO:<admin@example.test>',
|
||
]);
|
||
|
||
const message = messageLines(peer.lines);
|
||
expect(message.at(-1)).toBe('.');
|
||
const headers = message.slice(0, message.indexOf(''));
|
||
expect(headers.join('\n')).toContain('Subject: =?UTF-8?B?');
|
||
expect(headers.join('\n')).toContain('Content-Type: text/plain; charset=UTF-8');
|
||
const payload = Buffer.from(
|
||
message.slice(message.indexOf('') + 1, message.length - 1).join(''),
|
||
'base64',
|
||
).toString('utf8');
|
||
expect(payload).toBe('设备离线\n\nSIM 卡所在设备已断开');
|
||
});
|
||
|
||
it('switches the MIME subtype when the channel asks for HTML', async () => {
|
||
const peer = await fakeServer(OPEN_SCRIPT);
|
||
const result = await sendEmailNotification(
|
||
input({
|
||
smtp_host: '127.0.0.1',
|
||
smtp_port: peer.port,
|
||
smtp_security: 'none',
|
||
sender_address: 'a@example.test',
|
||
receiver_addresses: 'b@example.test',
|
||
message_format: 'html',
|
||
}),
|
||
2_000,
|
||
);
|
||
expect(result).toEqual({ ok: true });
|
||
expect(messageLines(peer.lines).join('\n')).toContain('Content-Type: text/html');
|
||
});
|
||
|
||
it('refuses to dial out when no receiver address is usable', async () => {
|
||
await expect(
|
||
sendEmailNotification(
|
||
input({
|
||
smtp_host: '127.0.0.1',
|
||
smtp_port: 1,
|
||
sender_address: 'notify@example.test',
|
||
receiver_addresses: 'not-an-address',
|
||
}),
|
||
500,
|
||
),
|
||
).resolves.toEqual({ ok: false, detail: '缺少有效的收件地址' });
|
||
});
|
||
|
||
it('reports a rejected handshake instead of throwing', async () => {
|
||
const peer = await fakeServer(['220 smtp ready', '421 service denied']);
|
||
await expect(
|
||
sendEmailNotification(
|
||
input({
|
||
smtp_host: '127.0.0.1',
|
||
smtp_port: peer.port,
|
||
smtp_security: 'none',
|
||
sender_address: 'a@example.test',
|
||
receiver_addresses: 'b@example.test',
|
||
}),
|
||
2_000,
|
||
),
|
||
).resolves.toMatchObject({ ok: false, detail: 'SMTP 返回 421:421 service denied' });
|
||
});
|
||
|
||
it('gives up with a readable detail when the server stops answering', async () => {
|
||
const peer = await fakeServer(['220 smtp ready']);
|
||
await expect(
|
||
sendEmailNotification(
|
||
input({
|
||
smtp_host: '127.0.0.1',
|
||
smtp_port: peer.port,
|
||
smtp_security: 'none',
|
||
sender_address: 'a@example.test',
|
||
receiver_addresses: 'b@example.test',
|
||
}),
|
||
300,
|
||
),
|
||
).resolves.toMatchObject({ ok: false, detail: 'SMTP 响应超时' });
|
||
});
|
||
});
|