SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

@@ -0,0 +1,413 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
DEFAULT_WEBHOOK_BODY,
DEFAULT_EMAIL_BODY,
DEFAULT_EMAIL_SUBJECT,
} from '@snowluma/common/notification-templates';
import {
DEFAULT_DEBOUNCE_SECONDS,
defaultNotificationsConfig,
normalizeNotificationsConfig,
renderTemplate,
} from '../src/notifications/config';
describe('renderTemplate — mechanical {key} substitution', () => {
it('replaces present variables', () => {
expect(
renderTemplate('{nickname}({uin}) {event} @ {time}', {
nickname: 'Bob',
uin: '123',
event: 'offline',
time: '12:00',
}),
).toBe('Bob(123) offline @ 12:00');
});
it('leaves missing placeholders untouched (原样)', () => {
expect(renderTemplate('{a}-{b}', { a: 'x' })).toBe('x-{b}');
});
it('replaces repeated occurrences', () => {
expect(renderTemplate('{x}{x}', { x: 'ab' })).toBe('abab');
});
it('leaves text with no placeholders unchanged', () => {
expect(renderTemplate('hello', { x: '1' })).toBe('hello');
});
it('does no logic — an empty value substitutes as empty', () => {
expect(renderTemplate('[{x}]', { x: '' })).toBe('[]');
});
it('is total — a non-string template yields an empty string', () => {
expect(renderTemplate(undefined as unknown as string, {})).toBe('');
});
it('does NOT re-expand a substituted value that itself contains a placeholder', () => {
// Load-bearing invariant: replacement text is never re-scanned. A refactor
// to a recursive/loop replace would break this while passing every other test.
expect(renderTemplate('{a}', { a: '{b}', b: 'NESTED' })).toBe('{b}');
});
it('escapes backslashes and quotes when template is JSON-like', () => {
const tmpl = '{"text": "{val}"}';
expect(renderTemplate(tmpl, { val: 'a"b\\c' })).toBe('{"text": "a\\"b\\\\c"}');
});
it('does NOT escape when template is plain text', () => {
expect(renderTemplate('{val}', { val: 'a"b\\c' })).toBe('a"b\\c');
});
it('preserves control characters as JSON string escapes', () => {
const value = 'line\nnext\r\t\b\f\0"\\';
const rendered = renderTemplate('{"text":"{value}"}', { value });
expect(JSON.parse(rendered)).toEqual({ text: value });
});
});
describe('normalizeNotificationsConfig — total normalize', () => {
it('collapses garbage / non-objects to the full default', () => {
const d = defaultNotificationsConfig();
for (const junk of [undefined, null, 42, 'nope', []]) {
expect(normalizeNotificationsConfig(junk)).toEqual(d);
}
});
it('forces version to 1 regardless of input', () => {
expect(normalizeNotificationsConfig({ version: 99 }).version).toBe(1);
});
it('clamps + truncates debounceSeconds into [0, 3600]', () => {
expect(normalizeNotificationsConfig({ debounceSeconds: -5 }).debounceSeconds).toBe(0);
expect(normalizeNotificationsConfig({ debounceSeconds: 99999 }).debounceSeconds).toBe(3600);
expect(normalizeNotificationsConfig({ debounceSeconds: 30.9 }).debounceSeconds).toBe(30);
expect(normalizeNotificationsConfig({ debounceSeconds: '45' }).debounceSeconds).toBe(45);
expect(normalizeNotificationsConfig({ debounceSeconds: 'x' }).debounceSeconds).toBe(
DEFAULT_DEBOUNCE_SECONDS,
);
});
it('drops a non-array channels value', () => {
expect(normalizeNotificationsConfig({ channels: 'nope' }).channels).toEqual([]);
});
it('keeps a valid channel and strips unknown fields', () => {
const { channels } = normalizeNotificationsConfig({
channels: [
{
id: 'dingtalk',
name: 'DingTalk',
url: 'https://oapi.dingtalk.com/robot/send',
bodyTemplate: '{event}',
enabled: true,
evil: 'x',
extra: 1,
},
],
});
expect(channels).toEqual([
{
id: 'dingtalk',
name: 'DingTalk',
type: 'webhook',
url: 'https://oapi.dingtalk.com/robot/send',
bodyTemplate: '{event}',
enabled: true,
},
]);
});
it('drops channels lacking a valid id or http(s) url', () => {
const { channels } = normalizeNotificationsConfig({
channels: [
{ id: '', url: 'https://a.com' }, // empty id
{ id: 'bad id!', url: 'https://a.com' }, // invalid slug
{ id: 'noUrl' }, // missing url
{ id: 'ftp', url: 'ftp://a.com' }, // non-http scheme
{ id: 'ok', url: 'http://a.com' }, // valid
],
});
expect(channels.map((c) => c.id)).toEqual(['ok']);
});
it('dedupes channels by id (first occurrence wins)', () => {
const { channels } = normalizeNotificationsConfig({
channels: [
{ id: 'dup', url: 'https://a.com', name: 'first' },
{ id: 'dup', url: 'https://b.com', name: 'second' },
],
});
expect(channels).toHaveLength(1);
expect(channels[0].name).toBe('first');
expect(channels[0].url).toBe('https://a.com');
});
it('defaults enabled=true, name→id, type=webhook, and bodyTemplate→default', () => {
const { channels } = normalizeNotificationsConfig({
channels: [{ id: 'c1', url: 'https://a.com' }],
});
expect(channels[0].enabled).toBe(true);
expect(channels[0].name).toBe('c1');
expect(channels[0].type).toBe('webhook');
expect(channels[0].bodyTemplate).toBe(DEFAULT_WEBHOOK_BODY);
expect(channels[0].headers).toBeUndefined();
});
it('keeps valid request headers and drops unsafe or empty ones', () => {
const { channels } = normalizeNotificationsConfig({
channels: [
{
id: 'feishu',
url: 'https://open.feishu.cn/hook',
headers: {
Authorization: 'Bearer secret',
'X-Custom': 'ok',
'Bad Name': 'x',
'': 'x',
Inject: 'line\r\nX-Evil: 1',
Empty: ' ',
NotString: 1,
} as Record<string, unknown>,
},
],
});
expect(channels[0].headers).toEqual({
Authorization: 'Bearer secret',
'X-Custom': 'ok',
});
});
it('omits an empty headers object after normalize', () => {
const { channels } = normalizeNotificationsConfig({
channels: [{ id: 'c1', url: 'https://a.com', headers: { 'Bad Name': 'x' } }],
});
expect(channels[0].headers).toBeUndefined();
});
it('keeps an email channel without a webhook url and fills SMTP defaults', () => {
const { channels } = normalizeNotificationsConfig({
channels: [
{
id: 'ops-mail',
type: 'email',
smtpHost: 'smtp.qq.com',
from: 'SnowLuma <bot@qq.com>',
to: 'a@example.com; b@example.com, a@example.com',
},
],
});
expect(channels).toEqual([
{
id: 'ops-mail',
name: 'ops-mail',
type: 'email',
url: '',
bodyTemplate: DEFAULT_EMAIL_BODY,
enabled: true,
smtpHost: 'smtp.qq.com',
smtpPort: 465,
smtpSecure: true,
from: 'SnowLuma <bot@qq.com>',
to: 'a@example.com, b@example.com',
subjectTemplate: DEFAULT_EMAIL_SUBJECT,
},
]);
});
it('defaults port 587 when smtpSecure is explicitly false', () => {
const { channels } = normalizeNotificationsConfig({
channels: [
{
id: 'relay',
type: 'email',
smtpHost: 'relay.internal',
smtpSecure: false,
from: 'bot@internal',
to: 'ops@internal',
},
],
});
expect(channels[0]).toMatchObject({ smtpPort: 587, smtpSecure: false });
});
it('treats port 587 without smtpSecure as STARTTLS (secure=false)', () => {
const { channels } = normalizeNotificationsConfig({
channels: [
{
id: 'gmail',
type: 'email',
smtpHost: 'smtp.gmail.com',
smtpPort: 587,
from: 'bot@gmail.com',
to: 'ops@gmail.com',
},
],
});
expect(channels[0]).toMatchObject({ smtpPort: 587, smtpSecure: false });
});
it('drops email channels missing host / from / to, and unknown types', () => {
const { channels } = normalizeNotificationsConfig({
channels: [
{ id: 'no-host', type: 'email', from: 'a@b.com', to: 'c@d.com' },
{ id: 'no-from', type: 'email', smtpHost: 'smtp.example.com', to: 'c@d.com' },
{
id: 'bad-to',
type: 'email',
smtpHost: 'smtp.example.com',
from: 'a@b.com',
to: 'not-an-email',
},
{ id: 'sms', type: 'sms', url: 'https://a.com' },
{
id: 'ok',
type: 'email',
smtpHost: 'smtp.example.com',
from: 'a@b.com',
to: 'c@d.com',
smtpUser: 'a@b.com',
smtpPass: 'p',
},
],
});
expect(channels.map((c) => c.id)).toEqual(['ok']);
expect(channels[0]).toMatchObject({ smtpUser: 'a@b.com', smtpPass: 'p' });
});
it('does not keep webhook-only fields on an email channel', () => {
const { channels } = normalizeNotificationsConfig({
channels: [
{
id: 'mail',
type: 'email',
url: 'https://should.be.ignored',
headers: { Authorization: 'Bearer x' },
smtpHost: 'smtp.example.com',
from: 'a@b.com',
to: 'c@d.com',
evil: 'x',
},
],
});
expect(channels[0].url).toBe('');
expect(channels[0].headers).toBeUndefined();
expect(channels[0]).not.toHaveProperty('evil');
});
});
describe('loadNotificationsConfig / saveNotificationsConfig (fs-backed)', () => {
let tmp: string;
let prevCwd: string;
beforeEach(() => {
prevCwd = process.cwd();
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sl-notif-'));
process.chdir(tmp);
vi.resetModules();
});
afterEach(() => {
process.chdir(prevCwd);
fs.rmSync(tmp, { recursive: true, force: true });
});
it('creates a default config file when absent', async () => {
const mod = await import('../src/notifications/config');
const cfg = mod.loadNotificationsConfig();
expect(cfg).toEqual(mod.defaultNotificationsConfig());
const file = path.join('config', 'notifications.json');
expect(fs.existsSync(file)).toBe(true);
if (process.platform !== 'win32') {
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
}
});
it('round-trips a saved config across a fresh module load', async () => {
const mod = await import('../src/notifications/config');
const saved = mod.saveNotificationsConfig({
debounceSeconds: 60,
channels: [
{
id: 'c1',
name: 'C1',
url: 'https://a.com/hook',
bodyTemplate: '{event}',
enabled: false,
},
],
});
expect(saved.debounceSeconds).toBe(60);
expect(saved.channels).toHaveLength(1);
vi.resetModules();
const mod2 = await import('../src/notifications/config');
expect(mod2.loadNotificationsConfig()).toEqual(saved);
});
it('repairs permissive permissions on an existing sensitive config', async () => {
const file = path.join('config', 'notifications.json');
fs.mkdirSync('config', { recursive: true });
fs.writeFileSync(
file,
JSON.stringify({
version: 1,
debounceSeconds: 30,
channels: [{ id: 'ops', url: 'https://example.com/private-hook' }],
}),
{ encoding: 'utf8', mode: 0o644 },
);
if (process.platform !== 'win32') {
fs.chmodSync(file, 0o644);
}
const mod = await import('../src/notifications/config');
expect(mod.loadNotificationsConfig().channels).toHaveLength(1);
if (process.platform !== 'win32') {
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
}
});
it('keeps restrictive permissions after replacing an existing config', async () => {
const file = path.join('config', 'notifications.json');
fs.mkdirSync('config', { recursive: true });
fs.writeFileSync(file, JSON.stringify({ version: 1, debounceSeconds: 30, channels: [] }), {
encoding: 'utf8',
mode: 0o644,
});
if (process.platform !== 'win32') {
fs.chmodSync(file, 0o644);
}
const mod = await import('../src/notifications/config');
mod.saveNotificationsConfig({
channels: [{ id: 'ops', url: 'https://example.com/private-hook' }],
});
if (process.platform !== 'win32') {
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
}
});
it('section-merges partial saves (debounce-only keeps channels)', async () => {
const mod = await import('../src/notifications/config');
mod.saveNotificationsConfig({ channels: [{ id: 'c1', url: 'https://a.com' }] });
const after = mod.saveNotificationsConfig({ debounceSeconds: 120 });
expect(after.debounceSeconds).toBe(120);
expect(after.channels.map((c) => c.id)).toEqual(['c1']);
});
it('self-heals an unreadable file on load', async () => {
fs.mkdirSync('config', { recursive: true });
fs.writeFileSync(path.join('config', 'notifications.json'), '{ not valid json', 'utf8');
const mod = await import('../src/notifications/config');
expect(mod.loadNotificationsConfig()).toEqual(mod.defaultNotificationsConfig());
});
});