Files
TREK/server/tests/unit/utils/ssrfGuard.test.ts
T
jubnl b4922322ae test: expand test suite to 87.3% backend coverage
Add new integration test files covering previously untested routes:
- categories.test.ts — GET /api/categories
- oidc.test.ts — full OIDC login flow (callback, state, errors)
- settings.test.ts — GET/PUT /api/settings, bulk save
- tags.test.ts — CRUD for trip tags
- todo.test.ts — todo items CRUD and reorder

Add new unit test files covering service-layer logic:
- adminService.test.ts — user/invite management, packing templates, OIDC settings
- atlasService.test.ts — atlas search and place enrichment
- authServiceDb.test.ts — DB-backed auth helpers (login, register, MFA)
- backupService.test.ts — export/import/restore logic
- categoryService.test.ts — category CRUD
- dayService.test.ts — day management and accommodation helpers
- mapsService.test.ts — route/directions helpers
- oidcService.test.ts — OIDC state, auth code, role resolution, user upsert
- packingService.test.ts — packing item/bag/template operations
- placeService.test.ts — place CRUD and tag attachment
- settingsService.test.ts — settings get/set/bulk
- tagService.test.ts — tag CRUD
- todoService.test.ts — todo CRUD and reorder
- tripService.test.ts — trip CRUD, member management, archiving
- vacayService.test.ts — vacay integration helpers
- tripAccess.test.ts (middleware) — requireTripAccess middleware

Expand existing integration and unit test files with additional cases
across admin, atlas, auth, backup, collab, days, files, maps, memories
(Immich/Synology), notifications, places, reservations, share, vacay,
weather, auth middleware, ephemeral tokens, notification preferences,
permissions, SSRF guard, and WebSocket connection tests.

Update test helpers (factories.ts, test-db.ts) with new factory
functions and seed data required by the expanded suite.

Fix minor issues in server/src/routes/reservations.ts and
server/src/services/atlasService.ts surfaced by new test coverage.

Update sonar-project.properties to reflect new coverage thresholds.
2026-04-06 20:08:30 +02:00

250 lines
8.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Capture Agent constructor options so we can test the lookup callback
const { agentCapture } = vi.hoisted(() => ({ agentCapture: { options: null as any } }));
// Mock dns/promises to avoid real DNS lookups in unit tests
vi.mock('dns/promises', () => ({
default: { lookup: vi.fn() },
lookup: vi.fn(),
}));
// Mock undici Agent so we can inspect the connect.lookup option
vi.mock('undici', () => ({
Agent: class MockAgent {
options: any;
constructor(opts: any) {
this.options = opts;
agentCapture.options = opts;
}
},
}));
import dns from 'dns/promises';
import { checkSsrf, SsrfBlockedError, safeFetch, createPinnedDispatcher } from '../../../src/utils/ssrfGuard';
const mockLookup = vi.mocked(dns.lookup);
function mockIp(ip: string) {
mockLookup.mockResolvedValue({ address: ip, family: ip.includes(':') ? 6 : 4 });
}
describe('checkSsrf', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
// SEC-001 — Loopback always blocked
describe('loopback addresses (always blocked)', () => {
it('SEC-001: blocks 127.0.0.1', async () => {
mockIp('127.0.0.1');
const result = await checkSsrf('http://example.com');
expect(result.allowed).toBe(false);
expect(result.isPrivate).toBe(true);
});
it('SEC-001: blocks ::1 (IPv6 loopback)', async () => {
mockIp('::1');
const result = await checkSsrf('http://example.com');
expect(result.allowed).toBe(false);
});
it('SEC-001: blocks 127.x.x.x range', async () => {
mockIp('127.0.0.2');
const result = await checkSsrf('http://example.com');
expect(result.allowed).toBe(false);
});
});
// SEC-002 — Link-local (AWS metadata) always blocked
describe('link-local addresses (always blocked)', () => {
it('SEC-002: blocks 169.254.169.254 (AWS metadata)', async () => {
mockIp('169.254.169.254');
const result = await checkSsrf('http://example.com');
expect(result.allowed).toBe(false);
expect(result.isPrivate).toBe(true);
});
it('SEC-002: blocks any 169.254.x.x address', async () => {
mockIp('169.254.0.1');
const result = await checkSsrf('http://example.com');
expect(result.allowed).toBe(false);
});
});
// SEC-003 — Private network blocked when ALLOW_INTERNAL_NETWORK is false
describe('private network addresses (conditionally blocked)', () => {
beforeEach(() => {
vi.stubEnv('ALLOW_INTERNAL_NETWORK', 'false');
});
it('SEC-003: blocks 10.x.x.x (RFC-1918)', async () => {
mockIp('10.0.0.1');
const result = await checkSsrf('http://example.com');
expect(result.allowed).toBe(false);
expect(result.isPrivate).toBe(true);
});
it('SEC-003: blocks 192.168.x.x (RFC-1918)', async () => {
mockIp('192.168.1.100');
const result = await checkSsrf('http://example.com');
expect(result.allowed).toBe(false);
});
it('SEC-003: blocks 172.16.x.x through 172.31.x.x (RFC-1918)', async () => {
mockIp('172.16.0.1');
const result = await checkSsrf('http://example.com');
expect(result.allowed).toBe(false);
});
});
// SEC-004 — Private network allowed with ALLOW_INTERNAL_NETWORK=true
describe('ALLOW_INTERNAL_NETWORK=true', () => {
it('SEC-004: allows private IP when flag is set', async () => {
vi.stubEnv('ALLOW_INTERNAL_NETWORK', 'true');
mockIp('192.168.1.100');
// Need to reload module since ALLOW_INTERNAL_NETWORK is read at module load time
vi.resetModules();
const { checkSsrf: checkSsrfFresh } = await import('../../../src/utils/ssrfGuard');
const { lookup: freshLookup } = await import('dns/promises');
vi.mocked(freshLookup).mockResolvedValue({ address: '192.168.1.100', family: 4 });
const result = await checkSsrfFresh('http://example.com');
expect(result.allowed).toBe(true);
expect(result.isPrivate).toBe(true);
});
});
describe('protocol restrictions', () => {
it('rejects non-HTTP/HTTPS protocols', async () => {
const result = await checkSsrf('ftp://example.com');
expect(result.allowed).toBe(false);
expect(result.error).toContain('HTTP');
});
it('rejects file:// protocol', async () => {
const result = await checkSsrf('file:///etc/passwd');
expect(result.allowed).toBe(false);
});
});
describe('invalid URLs', () => {
it('rejects malformed URLs', async () => {
const result = await checkSsrf('not-a-url');
expect(result.allowed).toBe(false);
expect(result.error).toContain('Invalid URL');
});
});
describe('public URLs', () => {
it('allows a normal public IP', async () => {
mockIp('8.8.8.8');
const result = await checkSsrf('https://example.com');
expect(result.allowed).toBe(true);
expect(result.isPrivate).toBe(false);
expect(result.resolvedIp).toBe('8.8.8.8');
});
});
describe('internal hostname suffixes', () => {
it('blocks .local domains', async () => {
const result = await checkSsrf('http://myserver.local');
expect(result.allowed).toBe(false);
});
it('blocks .internal domains', async () => {
const result = await checkSsrf('http://service.internal');
expect(result.allowed).toBe(false);
});
});
describe('DNS resolution failure', () => {
it('returns allowed:false when dns.lookup throws', async () => {
mockLookup.mockRejectedValue(new Error('ENOTFOUND nxdomain.example'));
const result = await checkSsrf('http://nxdomain.example.com');
expect(result.allowed).toBe(false);
expect(result.isPrivate).toBe(false);
expect(result.error).toBe('Could not resolve hostname');
});
});
});
describe('SsrfBlockedError', () => {
it('is an instance of Error', () => {
const err = new SsrfBlockedError('blocked');
expect(err).toBeInstanceOf(Error);
});
it('has name SsrfBlockedError', () => {
const err = new SsrfBlockedError('test message');
expect(err.name).toBe('SsrfBlockedError');
});
it('has the correct message', () => {
const err = new SsrfBlockedError('my message');
expect(err.message).toBe('my message');
});
});
describe('safeFetch', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('throws SsrfBlockedError for a blocked URL (invalid URL)', async () => {
await expect(safeFetch('not-a-valid-url')).rejects.toThrow(SsrfBlockedError);
});
it('throws SsrfBlockedError for a loopback URL', async () => {
mockLookup.mockResolvedValue({ address: '127.0.0.1', family: 4 });
await expect(safeFetch('http://localhost')).rejects.toThrow(SsrfBlockedError);
});
it('calls fetch with the resolved URL when allowed', async () => {
mockLookup.mockResolvedValue({ address: '93.184.216.34', family: 4 });
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal('fetch', mockFetch);
const result = await safeFetch('https://example.com');
expect(mockFetch).toHaveBeenCalledOnce();
expect(result.status).toBe(200);
});
it('throws SsrfBlockedError with fallback message when error is undefined', async () => {
// non-http protocol → error:'Only HTTP and HTTPS URLs are allowed'
await expect(safeFetch('ftp://example.com')).rejects.toThrow(SsrfBlockedError);
});
});
describe('createPinnedDispatcher', () => {
it('returns an object (Agent instance)', () => {
const dispatcher = createPinnedDispatcher('93.184.216.34');
expect(dispatcher).toBeDefined();
expect(typeof dispatcher).toBe('object');
});
it('pinned lookup callback calls back with the resolved IPv4 address', () => {
createPinnedDispatcher('93.184.216.34');
const lookup = agentCapture.options?.connect?.lookup;
expect(typeof lookup).toBe('function');
const cb = vi.fn();
lookup('example.com', {}, cb);
expect(cb).toHaveBeenCalledWith(null, '93.184.216.34', 4);
});
it('pinned lookup callback uses family 6 for IPv6 address', () => {
createPinnedDispatcher('2001:4860:4860::8888');
const lookup = agentCapture.options?.connect?.lookup;
const cb = vi.fn();
lookup('example.com', {}, cb);
expect(cb).toHaveBeenCalledWith(null, '2001:4860:4860::8888', 6);
});
it('returns array format when opts.all is true', () => {
createPinnedDispatcher('93.184.216.34');
const lookup = agentCapture.options?.connect?.lookup;
const cb = vi.fn();
lookup('example.com', { all: true }, cb);
expect(cb).toHaveBeenCalledWith(null, [{ address: '93.184.216.34', family: 4 }]);
});
});