mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
91095ef96a
* fix(transit): lead arrive-by results with the latest arrival (#1479) MOTIS returns arrive-by itineraries ascending with the deadline-adjacent connection last, so the visible top of the list arrived ~2h before the requested time — misread as a timezone bug. Sort arrive-by results by endTime descending so the connection closest to the requested arrival leads, mirroring depart-by. Claude-Session: https://claude.ai/code/session_01BCrZUoPzHZF6H4C6avMXoz * fix(transit): lead arrive-by results with the latest arrival (#1479) MOTIS returns arrive-by itineraries ascending with the deadline-adjacent connection last, so the visible top of the list arrived ~2h before the requested time — misread as a timezone bug. Sort arrive-by results by endTime descending so the connection closest to the requested arrival leads, mirroring depart-by. * fix(reservations): run AirTrail modal hooks before the isOpen early return (#1602) The sectionItems useMemo added by #1535 sat below 'if (!isOpen) return null'. The modal mounts closed, so the first open render executed one more hook than the previous render and React unmounted the whole tree (error #310), blanking the page. Move the memo above the early return and cover the closed-then-open transition with a regression test. * fix(planner): only draw the check-in-day hotel morning leg when provably at/after check-in (#1597) (#1607) On an accommodation's check-in day the hotel -> first-stop leg was drawn by default for any PLACE first stop, suppressed only when that place was explicitly timed before check-in. An un-timed first place (e.g. "Home" on day 1 of a driving holiday) therefore always produced a phantom hotel -> Home leg, regardless of the check-in time. Flip the default to mirror shouldDrawEveningLeg: the morning leg is now drawn only when the first place is provably timed at/after check-in (you dropped your bags first). The drawn map route, the sidebar hotel connectors, and the Google Maps export all share this helper and inherit the fix. The optimizer anchors (#1321) are unchanged. * fix(notifications): never fall back to the admin ntfy topic for per-user sends (#1608) * chore: correct shields.io url * chore: correct shields.io url * chore: update helm repo link * chore: document new helm chart url * chore: document new helm chart url * fix(atlas): make Kosovo selectable on the Atlas map (#1609) Kosovo's user-assigned ISO code (XK/XKX) was missing from both code tables in the atlas pipeline: the geo builder's A3_TO_A2 map (so the shipped admin0 bundle carried ISO_A2: null) and the client's A2_TO_A3 map. With neither resolvable, onEachFeature attached no hover/click handlers to Kosovo's polygon and the country search dropped it, making Kosovo impossible to select. - add XK<->XKX to the client A2_TO_A3 table and the builder A3_TO_A2 map - stamp ISO_A2: "XK" on the Kosovo feature in the shipped admin0 bundle - guard both with tests (atlasModel resolution + ATLAS-BUNDLE-003) * chore: normalize docker image references to mauriceboe/trek (lowercase) * fix(memories): honor Synology skip-SSL on photo streaming (#1611) The synology_skip_ssl setting was forwarded on the JSON API path (login/browse/test-connection) but not on the image-byte fetches, so a NAS with a self-signed certificate passed Test Connection while /api/photos/:id/thumbnail and /original failed with 500 "Failed to fetch asset" — and the TLS error was silently swallowed. - pipeAsset: accept fetchOptions and forward to safeFetch; log the underlying error on the 500 path (without the URL, which carries _sid) - fetchSynologyThumbnailBytes / streamSynologyAsset: pass rejectUnauthorized derived from synology_skip_ssl; log on failure - i18n: replace hardcoded "Immich" with {provider_name} in 7 memories keys across 14 locales (source of the reported "Failed to connect to Immich" banner on the Synology test), fix translated placeholder names in tr/ca/pl, and add a placeholder-parity spec to prevent regressions - client: gallery provider badge matched 'synology' instead of the real id 'synologyphotos', showing the raw id; label is now "Synology Photos" * chore: make chart.liketrek.com the canonical helm chart url * feat(plugins): prefer the registry's resolved store screenshot (#1613) Browse/detail use the screenshotUrl the aggregate step resolves (cover at the latest commit, or the first resolving README image), falling back to the docs/screenshot.png guess when the field is absent. Fixes blank store cards for plugins without a committed docs/screenshot.png. --------- Co-authored-by: Maurice <61554723+mauriceboe@users.noreply.github.com>
501 lines
21 KiB
TypeScript
501 lines
21 KiB
TypeScript
import { describe, it, expect, vi, afterEach, afterAll, beforeEach } from 'vitest';
|
|
|
|
vi.mock('../../../src/db/database', () => ({
|
|
db: { prepare: () => ({ get: vi.fn(() => undefined), all: vi.fn(() => []) }) },
|
|
}));
|
|
vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
|
decrypt_api_key: vi.fn((v) => v),
|
|
maybe_encrypt_api_key: vi.fn((v) => v),
|
|
}));
|
|
vi.mock('../../../src/services/auditLog', () => ({
|
|
logInfo: vi.fn(),
|
|
logDebug: vi.fn(),
|
|
logError: vi.fn(),
|
|
logWarn: vi.fn(),
|
|
writeAudit: vi.fn(),
|
|
getClientIp: vi.fn(),
|
|
}));
|
|
vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn(() => ({ sendMail: vi.fn() })) } }));
|
|
vi.stubGlobal('fetch', vi.fn());
|
|
|
|
// ssrfGuard is mocked per-test in the SSRF describe block; default passes all
|
|
vi.mock('../../../src/utils/ssrfGuard', () => ({
|
|
checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' })),
|
|
createPinnedDispatcher: vi.fn(() => ({})),
|
|
}));
|
|
|
|
import { getEventText, buildEmailHtml, buildWebhookBody, sendWebhook, sendNtfy, resolveNtfyUrl, resolveAdminNtfyUrl, type NtfyConfig } from '../../../src/services/notifications';
|
|
import { checkSsrf } from '../../../src/utils/ssrfGuard';
|
|
import { logError } from '../../../src/services/auditLog';
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
// ── getEventText ─────────────────────────────────────────────────────────────
|
|
|
|
describe('getEventText', () => {
|
|
const params = {
|
|
trip: 'Tokyo Adventure',
|
|
actor: 'Alice',
|
|
invitee: 'Bob',
|
|
booking: 'Hotel Sakura',
|
|
type: 'hotel',
|
|
count: '5',
|
|
preview: 'See you there!',
|
|
category: 'Clothing',
|
|
};
|
|
|
|
it('returns English title and body for lang=en', () => {
|
|
const result = getEventText('en', 'trip_invite', params);
|
|
expect(result.title).toBeTruthy();
|
|
expect(result.body).toBeTruthy();
|
|
expect(result.title).toContain('Tokyo Adventure');
|
|
expect(result.body).toContain('Alice');
|
|
});
|
|
|
|
it('returns German text for lang=de', () => {
|
|
const result = getEventText('de', 'trip_invite', params);
|
|
expect(result.title).toContain('Tokyo Adventure');
|
|
// German version uses "Einladung"
|
|
expect(result.title).toContain('Einladung');
|
|
});
|
|
|
|
it('falls back to English for unknown language code', () => {
|
|
const en = getEventText('en', 'trip_invite', params);
|
|
const unknown = getEventText('xx', 'trip_invite', params);
|
|
expect(unknown.title).toBe(en.title);
|
|
expect(unknown.body).toBe(en.body);
|
|
});
|
|
|
|
it('interpolates params into trip_invite correctly', () => {
|
|
const result = getEventText('en', 'trip_invite', params);
|
|
expect(result.title).toContain('Tokyo Adventure');
|
|
expect(result.body).toContain('Alice');
|
|
expect(result.body).toContain('Bob');
|
|
});
|
|
|
|
it('all 7 event types produce non-empty title and body in English', () => {
|
|
const events = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'] as const;
|
|
for (const event of events) {
|
|
const result = getEventText('en', event, params);
|
|
expect(result.title, `title for ${event}`).toBeTruthy();
|
|
expect(result.body, `body for ${event}`).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
it('all 7 event types produce non-empty title and body in German', () => {
|
|
const events = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'] as const;
|
|
for (const event of events) {
|
|
const result = getEventText('de', event, params);
|
|
expect(result.title, `de title for ${event}`).toBeTruthy();
|
|
expect(result.body, `de body for ${event}`).toBeTruthy();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── buildWebhookBody ─────────────────────────────────────────────────────────
|
|
|
|
describe('buildWebhookBody', () => {
|
|
const payload = {
|
|
event: 'trip_invite',
|
|
title: 'Trip Invite',
|
|
body: 'Alice invited you',
|
|
tripName: 'Tokyo Adventure',
|
|
};
|
|
|
|
it('Discord URL produces embeds array format', () => {
|
|
const body = JSON.parse(buildWebhookBody('https://discord.com/api/webhooks/123/abc', payload));
|
|
expect(body).toHaveProperty('embeds');
|
|
expect(Array.isArray(body.embeds)).toBe(true);
|
|
expect(body.embeds[0]).toHaveProperty('title');
|
|
expect(body.embeds[0]).toHaveProperty('description', payload.body);
|
|
expect(body.embeds[0]).toHaveProperty('color');
|
|
expect(body.embeds[0]).toHaveProperty('footer');
|
|
expect(body.embeds[0]).toHaveProperty('timestamp');
|
|
});
|
|
|
|
it('Discord embed title is prefixed with compass emoji', () => {
|
|
const body = JSON.parse(buildWebhookBody('https://discord.com/api/webhooks/123/abc', payload));
|
|
expect(body.embeds[0].title).toContain('📍');
|
|
expect(body.embeds[0].title).toContain(payload.title);
|
|
});
|
|
|
|
it('Discord embed footer contains trip name when provided', () => {
|
|
const body = JSON.parse(buildWebhookBody('https://discord.com/api/webhooks/123/abc', payload));
|
|
expect(body.embeds[0].footer.text).toContain('Tokyo Adventure');
|
|
});
|
|
|
|
it('Discord embed footer defaults to TREK when no trip name', () => {
|
|
const noTrip = { ...payload, tripName: undefined };
|
|
const body = JSON.parse(buildWebhookBody('https://discord.com/api/webhooks/123/abc', noTrip));
|
|
expect(body.embeds[0].footer.text).toBe('TREK');
|
|
});
|
|
|
|
it('discordapp.com URL is also detected as Discord', () => {
|
|
const body = JSON.parse(buildWebhookBody('https://discordapp.com/api/webhooks/123/abc', payload));
|
|
expect(body).toHaveProperty('embeds');
|
|
});
|
|
|
|
it('Slack URL produces text field format', () => {
|
|
const body = JSON.parse(buildWebhookBody('https://hooks.slack.com/services/X/Y/Z', payload));
|
|
expect(body).toHaveProperty('text');
|
|
expect(body.text).toContain(payload.title);
|
|
expect(body.text).toContain(payload.body);
|
|
});
|
|
|
|
it('Slack text includes italic trip name when provided', () => {
|
|
const body = JSON.parse(buildWebhookBody('https://hooks.slack.com/services/X/Y/Z', payload));
|
|
expect(body.text).toContain('Tokyo Adventure');
|
|
});
|
|
|
|
it('Slack text omits trip name when not provided', () => {
|
|
const noTrip = { ...payload, tripName: undefined };
|
|
const body = JSON.parse(buildWebhookBody('https://hooks.slack.com/services/X/Y/Z', noTrip));
|
|
// Should not contain the trip name string
|
|
expect(body.text).not.toContain('Tokyo Adventure');
|
|
});
|
|
|
|
it('generic URL produces plain JSON with original fields plus timestamp and source', () => {
|
|
const body = JSON.parse(buildWebhookBody('https://mywebhook.example.com/hook', payload));
|
|
expect(body).toHaveProperty('event', payload.event);
|
|
expect(body).toHaveProperty('title', payload.title);
|
|
expect(body).toHaveProperty('body', payload.body);
|
|
expect(body).toHaveProperty('timestamp');
|
|
expect(body).toHaveProperty('source', 'TREK');
|
|
});
|
|
});
|
|
|
|
// ── buildEmailHtml ────────────────────────────────────────────────────────────
|
|
|
|
describe('buildEmailHtml', () => {
|
|
it('returns a string containing <!DOCTYPE html>', () => {
|
|
const html = buildEmailHtml('Test Subject', 'Test body text', 'en');
|
|
expect(html).toContain('<!DOCTYPE html>');
|
|
});
|
|
|
|
it('contains the subject text', () => {
|
|
const html = buildEmailHtml('My Email Subject', 'Some body', 'en');
|
|
expect(html).toContain('My Email Subject');
|
|
});
|
|
|
|
it('contains the body text', () => {
|
|
const html = buildEmailHtml('Subject', 'Hello world, this is the body!', 'en');
|
|
expect(html).toContain('Hello world, this is the body!');
|
|
});
|
|
|
|
it('uses English i18n strings for lang=en', () => {
|
|
const html = buildEmailHtml('Subject', 'Body', 'en');
|
|
expect(html).toContain('notifications enabled in TREK');
|
|
});
|
|
|
|
it('uses German i18n strings for lang=de', () => {
|
|
const html = buildEmailHtml('Subject', 'Body', 'de');
|
|
expect(html).toContain('TREK aktiviert');
|
|
});
|
|
|
|
it('falls back to English i18n for unknown language', () => {
|
|
const en = buildEmailHtml('Subject', 'Body', 'en');
|
|
const unknown = buildEmailHtml('Subject', 'Body', 'xx');
|
|
// Both should have the same footer text
|
|
expect(unknown).toContain('notifications enabled in TREK');
|
|
});
|
|
});
|
|
|
|
// ── SEC: XSS escaping in buildEmailHtml ──────────────────────────────────────
|
|
|
|
describe('buildEmailHtml XSS prevention (SEC-016)', () => {
|
|
it('escapes HTML special characters in subject', () => {
|
|
const html = buildEmailHtml('<script>alert(1)</script>', 'Body', 'en');
|
|
expect(html).not.toContain('<script>');
|
|
expect(html).toContain('<script>');
|
|
});
|
|
|
|
it('escapes HTML special characters in body', () => {
|
|
const html = buildEmailHtml('Subject', '<img src=x onerror=alert(1)>', 'en');
|
|
expect(html).toContain('<img');
|
|
expect(html).not.toContain('<img src=x');
|
|
});
|
|
|
|
it('escapes double quotes in subject to prevent attribute injection', () => {
|
|
const html = buildEmailHtml('He said "hello"', 'Body', 'en');
|
|
expect(html).toContain('"');
|
|
expect(html).not.toContain('"hello"');
|
|
});
|
|
|
|
it('escapes ampersands in body', () => {
|
|
const html = buildEmailHtml('Subject', 'a & b', 'en');
|
|
expect(html).toContain('&');
|
|
expect(html).not.toMatch(/>[^<]*a & b[^<]*</);
|
|
});
|
|
|
|
it('escapes user-controlled actor and preview in collab_message body', () => {
|
|
const { body } = getEventText('en', 'collab_message', {
|
|
trip: 'MyTrip',
|
|
actor: '<evil>',
|
|
preview: '<script>xss()</script>',
|
|
});
|
|
const html = buildEmailHtml('Subject', body, 'en');
|
|
expect(html).not.toContain('<evil>');
|
|
expect(html).not.toContain('<script>');
|
|
expect(html).toContain('<evil>');
|
|
expect(html).toContain('<script>');
|
|
});
|
|
});
|
|
|
|
// ── SEC: SSRF protection in sendWebhook ──────────────────────────────────────
|
|
|
|
describe('sendWebhook SSRF protection (SEC-017)', () => {
|
|
const payload = { event: 'test', title: 'T', body: 'B' };
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(logError).mockClear();
|
|
});
|
|
|
|
it('allows a public URL and calls fetch', async () => {
|
|
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
|
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
|
|
vi.mocked(checkSsrf).mockResolvedValueOnce({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' });
|
|
|
|
const result = await sendWebhook('https://example.com/hook', payload);
|
|
expect(result).toBe(true);
|
|
expect(mockFetch).toHaveBeenCalled();
|
|
});
|
|
|
|
it('blocks loopback address and returns false', async () => {
|
|
vi.mocked(checkSsrf).mockResolvedValueOnce({
|
|
allowed: false, isPrivate: true, resolvedIp: '127.0.0.1',
|
|
error: 'Requests to loopback and link-local addresses are not allowed',
|
|
});
|
|
|
|
const result = await sendWebhook('http://localhost/secret', payload);
|
|
expect(result).toBe(false);
|
|
expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('SSRF'));
|
|
});
|
|
|
|
it('blocks cloud metadata endpoint (169.254.169.254) and returns false', async () => {
|
|
vi.mocked(checkSsrf).mockResolvedValueOnce({
|
|
allowed: false, isPrivate: true, resolvedIp: '169.254.169.254',
|
|
error: 'Requests to loopback and link-local addresses are not allowed',
|
|
});
|
|
|
|
const result = await sendWebhook('http://169.254.169.254/latest/meta-data', payload);
|
|
expect(result).toBe(false);
|
|
expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('SSRF'));
|
|
});
|
|
|
|
it('blocks private network addresses and returns false', async () => {
|
|
vi.mocked(checkSsrf).mockResolvedValueOnce({
|
|
allowed: false, isPrivate: true, resolvedIp: '192.168.1.1',
|
|
error: 'Requests to private/internal network addresses are not allowed',
|
|
});
|
|
|
|
const result = await sendWebhook('http://192.168.1.1/hook', payload);
|
|
expect(result).toBe(false);
|
|
expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('SSRF'));
|
|
});
|
|
|
|
it('blocks non-HTTP protocols', async () => {
|
|
vi.mocked(checkSsrf).mockResolvedValueOnce({
|
|
allowed: false, isPrivate: false,
|
|
error: 'Only HTTP and HTTPS URLs are allowed',
|
|
});
|
|
|
|
const result = await sendWebhook('file:///etc/passwd', payload);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('does not call fetch when SSRF check blocks the URL', async () => {
|
|
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
|
mockFetch.mockClear();
|
|
vi.mocked(checkSsrf).mockResolvedValueOnce({
|
|
allowed: false, isPrivate: true, resolvedIp: '127.0.0.1',
|
|
error: 'blocked',
|
|
});
|
|
|
|
await sendWebhook('http://localhost/secret', payload);
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
afterAll(() => vi.unstubAllGlobals());
|
|
|
|
// ── resolveNtfyUrl ────────────────────────────────────────────────────────────
|
|
|
|
describe('resolveNtfyUrl', () => {
|
|
const adminCfg: NtfyConfig = { server: 'https://ntfy.sh', topic: 'admin-topic', token: null };
|
|
|
|
it('returns null when no user config, even when admin topic is set (#1608)', () => {
|
|
expect(resolveNtfyUrl(adminCfg, null)).toBeNull();
|
|
});
|
|
|
|
it('returns null when user config has no topic, even when admin topic is set (#1608)', () => {
|
|
const user: NtfyConfig = { server: 'https://ntfy.example.com', topic: null, token: null };
|
|
expect(resolveNtfyUrl(adminCfg, user)).toBeNull();
|
|
});
|
|
|
|
it('uses user topic with admin server fallback', () => {
|
|
const user: NtfyConfig = { server: null, topic: 'my-topic', token: null };
|
|
expect(resolveNtfyUrl(adminCfg, user)).toBe('https://ntfy.sh/my-topic');
|
|
});
|
|
|
|
it('uses user server override', () => {
|
|
const user: NtfyConfig = { server: 'https://ntfy.example.com', topic: 'my-topic', token: null };
|
|
expect(resolveNtfyUrl(adminCfg, user)).toBe('https://ntfy.example.com/my-topic');
|
|
});
|
|
|
|
it('strips trailing slash from server', () => {
|
|
const user: NtfyConfig = { server: 'https://ntfy.example.com/', topic: 'alerts', token: null };
|
|
expect(resolveNtfyUrl(adminCfg, user)).toBe('https://ntfy.example.com/alerts');
|
|
});
|
|
|
|
it('falls back to https://ntfy.sh when no server configured', () => {
|
|
const noServer: NtfyConfig = { server: null, topic: null, token: null };
|
|
const user: NtfyConfig = { server: null, topic: 'my-topic', token: null };
|
|
expect(resolveNtfyUrl(noServer, user)).toBe('https://ntfy.sh/my-topic');
|
|
});
|
|
});
|
|
|
|
describe('resolveAdminNtfyUrl', () => {
|
|
it('builds URL from admin topic and server', () => {
|
|
expect(resolveAdminNtfyUrl({ server: 'https://ntfy.example.com', topic: 'admin-topic', token: null })).toBe('https://ntfy.example.com/admin-topic');
|
|
});
|
|
|
|
it('returns null when no admin topic', () => {
|
|
expect(resolveAdminNtfyUrl({ server: 'https://ntfy.sh', topic: null, token: null })).toBeNull();
|
|
});
|
|
|
|
it('falls back to https://ntfy.sh when no server configured', () => {
|
|
expect(resolveAdminNtfyUrl({ server: null, topic: 'alerts', token: null })).toBe('https://ntfy.sh/alerts');
|
|
});
|
|
|
|
it('strips trailing slash from server', () => {
|
|
expect(resolveAdminNtfyUrl({ server: 'https://ntfy.sh/', topic: 'alerts', token: null })).toBe('https://ntfy.sh/alerts');
|
|
});
|
|
});
|
|
|
|
// ── sendNtfy ─────────────────────────────────────────────────────────────────
|
|
|
|
describe('sendNtfy', () => {
|
|
const ntfyUrl = 'https://ntfy.sh/trek-test';
|
|
const payload = { event: 'trip_invite', title: 'Test Title', body: 'Test body' };
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(logError).mockClear();
|
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockClear();
|
|
vi.mocked(checkSsrf).mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' });
|
|
});
|
|
|
|
it('NTFY-001 — sends POST to topic URL with plain text body and metadata in headers', async () => {
|
|
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
|
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
|
|
|
|
const result = await sendNtfy(ntfyUrl, null, payload);
|
|
expect(result).toBe(true);
|
|
expect(mockFetch).toHaveBeenCalledOnce();
|
|
|
|
const [calledUrl, calledOpts] = mockFetch.mock.calls[0];
|
|
expect(calledUrl).toBe(ntfyUrl);
|
|
// Body should be plain text, not JSON
|
|
expect(calledOpts.body).toBe('Test body');
|
|
// Title, Priority, Tags go in headers
|
|
expect(calledOpts.headers['Title']).toBe('Test Title');
|
|
expect(calledOpts.headers['Priority']).toBe('4'); // trip_invite maps to priority 4
|
|
expect(calledOpts.headers['Tags']).toContain('loudspeaker');
|
|
});
|
|
|
|
it('NTFY-002 — attaches Bearer token when token provided', async () => {
|
|
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
|
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
|
|
|
|
await sendNtfy(ntfyUrl, 'my-secret-token', payload);
|
|
|
|
const [, calledOpts] = mockFetch.mock.calls[0];
|
|
expect(calledOpts.headers['Authorization']).toBe('Bearer my-secret-token');
|
|
});
|
|
|
|
it('NTFY-003 — no Authorization header when token is null', async () => {
|
|
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
|
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
|
|
|
|
await sendNtfy(ntfyUrl, null, payload);
|
|
|
|
const [, calledOpts] = mockFetch.mock.calls[0];
|
|
expect(calledOpts.headers['Authorization']).toBeUndefined();
|
|
});
|
|
|
|
it('NTFY-004 — includes Click header when link is provided', async () => {
|
|
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
|
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
|
|
|
|
await sendNtfy(ntfyUrl, null, { ...payload, link: 'https://trek.example.com/trips/5' });
|
|
|
|
const [, calledOpts] = mockFetch.mock.calls[0];
|
|
expect(calledOpts.headers['Click']).toBe('https://trek.example.com/trips/5');
|
|
});
|
|
|
|
it('NTFY-005 — SSRF guard blocks private URL and returns false', async () => {
|
|
vi.mocked(checkSsrf).mockResolvedValueOnce({
|
|
allowed: false, isPrivate: true, resolvedIp: '192.168.1.1',
|
|
error: 'Requests to private/internal network addresses are not allowed',
|
|
});
|
|
|
|
const result = await sendNtfy('http://192.168.1.1/ntfy', null, payload);
|
|
expect(result).toBe(false);
|
|
expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('SSRF'));
|
|
expect(globalThis.fetch as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('NTFY-006 — HTTP non-2xx response returns false and logs error', async () => {
|
|
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
|
mockFetch.mockResolvedValueOnce({ ok: false, status: 403, text: async () => 'Forbidden' } as never);
|
|
|
|
const result = await sendNtfy(ntfyUrl, null, payload);
|
|
expect(result).toBe(false);
|
|
expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('403'));
|
|
});
|
|
|
|
it('NTFY-007 — network error returns false', async () => {
|
|
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
|
mockFetch.mockRejectedValueOnce(new Error('Network failure'));
|
|
|
|
const result = await sendNtfy(ntfyUrl, null, payload);
|
|
expect(result).toBe(false);
|
|
expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('Network failure'));
|
|
});
|
|
|
|
it('NTFY-008 — unknown event falls back to priority 3 and no Tags header', async () => {
|
|
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
|
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
|
|
|
|
await sendNtfy(ntfyUrl, null, { event: 'unknown_event', title: 'T', body: 'B' });
|
|
|
|
const [, calledOpts] = mockFetch.mock.calls[0];
|
|
expect(calledOpts.headers['Priority']).toBe('3');
|
|
expect(calledOpts.headers['Tags']).toBeUndefined(); // empty tags = no header
|
|
});
|
|
|
|
it('NTFY-009 — title with non-Latin-1 chars is RFC 2047 encoded', async () => {
|
|
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
|
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
|
|
|
|
await sendNtfy(ntfyUrl, null, { ...payload, title: 'Buy →€ ticket' });
|
|
|
|
const [, calledOpts] = mockFetch.mock.calls[0];
|
|
const encoded = calledOpts.headers['Title'] as string;
|
|
expect(encoded).toMatch(/^=\?UTF-8\?B\?/);
|
|
const b64 = encoded.replace(/^=\?UTF-8\?B\?/, '').replace(/\?=$/, '');
|
|
expect(Buffer.from(b64, 'base64').toString('utf8')).toBe('Buy →€ ticket');
|
|
});
|
|
|
|
it('NTFY-010 — ASCII-only title is passed through verbatim', async () => {
|
|
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
|
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
|
|
|
|
await sendNtfy(ntfyUrl, null, { ...payload, title: 'Simple ASCII title' });
|
|
|
|
const [, calledOpts] = mockFetch.mock.calls[0];
|
|
expect(calledOpts.headers['Title']).toBe('Simple ASCII title');
|
|
});
|
|
});
|