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
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:
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());
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user