Add comprehensive backend test suite (#339)
* add test suite, mostly covers integration testing, tests are only backend side * workflow runs the correct script * workflow runs the correct script * workflow runs the correct script * unit tests incoming * Fix multer silent rejections and error handler info leak - Revert cb(null, false) to cb(new Error(...)) in auth.ts, collab.ts, and files.ts so invalid uploads return an error instead of silently dropping the file - Error handler in app.ts now always returns 500 / "Internal server error" instead of forwarding err.message to the client * Use statusCode consistently for multer errors and error handler - Error handler in app.ts reads err.statusCode to forward the correct HTTP status while keeping the response body generic
This commit is contained in:
79
server/tests/unit/services/apiKeyCrypto.test.ts
Normal file
79
server/tests/unit/services/apiKeyCrypto.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Inline factory to avoid vi.mock hoisting issue (no imported vars allowed)
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { encrypt_api_key, decrypt_api_key, maybe_encrypt_api_key } from '../../../src/services/apiKeyCrypto';
|
||||
|
||||
describe('apiKeyCrypto', () => {
|
||||
const PLAINTEXT_KEY = 'my-secret-api-key-12345';
|
||||
const ENC_PREFIX = 'enc:v1:';
|
||||
|
||||
// SEC-008 — Encrypted API keys not returned in plaintext
|
||||
describe('encrypt_api_key', () => {
|
||||
it('SEC-008: returns encrypted string with enc:v1: prefix', () => {
|
||||
const encrypted = encrypt_api_key(PLAINTEXT_KEY);
|
||||
expect(encrypted).toMatch(/^enc:v1:/);
|
||||
});
|
||||
|
||||
it('different calls produce different ciphertext (random IV)', () => {
|
||||
const enc1 = encrypt_api_key(PLAINTEXT_KEY);
|
||||
const enc2 = encrypt_api_key(PLAINTEXT_KEY);
|
||||
expect(enc1).not.toBe(enc2);
|
||||
});
|
||||
|
||||
it('encrypted value does not contain the plaintext', () => {
|
||||
const encrypted = encrypt_api_key(PLAINTEXT_KEY);
|
||||
expect(encrypted).not.toContain(PLAINTEXT_KEY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decrypt_api_key', () => {
|
||||
it('SEC-008: decrypts an encrypted key back to original', () => {
|
||||
const encrypted = encrypt_api_key(PLAINTEXT_KEY);
|
||||
const decrypted = decrypt_api_key(encrypted);
|
||||
expect(decrypted).toBe(PLAINTEXT_KEY);
|
||||
});
|
||||
|
||||
it('returns null for null input', () => {
|
||||
expect(decrypt_api_key(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(decrypt_api_key('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns plaintext as-is if not prefixed (legacy)', () => {
|
||||
expect(decrypt_api_key('plain-legacy-key')).toBe('plain-legacy-key');
|
||||
});
|
||||
|
||||
it('returns null for tampered ciphertext', () => {
|
||||
const encrypted = encrypt_api_key(PLAINTEXT_KEY);
|
||||
const tampered = encrypted.replace(ENC_PREFIX, ENC_PREFIX) + 'TAMPER';
|
||||
expect(decrypt_api_key(tampered)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('maybe_encrypt_api_key', () => {
|
||||
it('encrypts a new plaintext value', () => {
|
||||
const result = maybe_encrypt_api_key('my-key');
|
||||
expect(result).toMatch(/^enc:v1:/);
|
||||
});
|
||||
|
||||
it('returns null for empty/falsy values', () => {
|
||||
expect(maybe_encrypt_api_key('')).toBeNull();
|
||||
expect(maybe_encrypt_api_key(null)).toBeNull();
|
||||
expect(maybe_encrypt_api_key(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns already-encrypted value as-is (no double-encryption)', () => {
|
||||
const encrypted = encrypt_api_key(PLAINTEXT_KEY);
|
||||
const result = maybe_encrypt_api_key(encrypted);
|
||||
expect(result).toBe(encrypted);
|
||||
});
|
||||
});
|
||||
});
|
||||
70
server/tests/unit/services/auditLog.test.ts
Normal file
70
server/tests/unit/services/auditLog.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Prevent file I/O side effects at module load time
|
||||
vi.mock('fs', () => ({
|
||||
default: {
|
||||
mkdirSync: vi.fn(),
|
||||
existsSync: vi.fn(() => false),
|
||||
statSync: vi.fn(() => ({ size: 0 })),
|
||||
appendFileSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
},
|
||||
mkdirSync: vi.fn(),
|
||||
existsSync: vi.fn(() => false),
|
||||
statSync: vi.fn(() => ({ size: 0 })),
|
||||
appendFileSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: { prepare: () => ({ get: vi.fn(), run: vi.fn() }) },
|
||||
}));
|
||||
|
||||
import { getClientIp } from '../../../src/services/auditLog';
|
||||
import type { Request } from 'express';
|
||||
|
||||
function makeReq(options: {
|
||||
xff?: string | string[];
|
||||
remoteAddress?: string;
|
||||
} = {}): Request {
|
||||
return {
|
||||
headers: {
|
||||
...(options.xff !== undefined ? { 'x-forwarded-for': options.xff } : {}),
|
||||
},
|
||||
socket: { remoteAddress: options.remoteAddress ?? undefined },
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
describe('getClientIp', () => {
|
||||
it('returns first IP from comma-separated X-Forwarded-For string', () => {
|
||||
expect(getClientIp(makeReq({ xff: '1.2.3.4, 5.6.7.8, 9.10.11.12' }))).toBe('1.2.3.4');
|
||||
});
|
||||
|
||||
it('returns single IP when X-Forwarded-For has no comma', () => {
|
||||
expect(getClientIp(makeReq({ xff: '10.0.0.1' }))).toBe('10.0.0.1');
|
||||
});
|
||||
|
||||
it('returns first element when X-Forwarded-For is an array', () => {
|
||||
expect(getClientIp(makeReq({ xff: ['203.0.113.1', '10.0.0.1'] }))).toBe('203.0.113.1');
|
||||
});
|
||||
|
||||
it('trims whitespace from extracted IP', () => {
|
||||
expect(getClientIp(makeReq({ xff: ' 192.168.1.1 , 10.0.0.1' }))).toBe('192.168.1.1');
|
||||
});
|
||||
|
||||
it('falls back to req.socket.remoteAddress when no X-Forwarded-For', () => {
|
||||
expect(getClientIp(makeReq({ remoteAddress: '172.16.0.1' }))).toBe('172.16.0.1');
|
||||
});
|
||||
|
||||
it('returns null when no forwarded header and no socket address', () => {
|
||||
expect(getClientIp(makeReq({}))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string X-Forwarded-For', () => {
|
||||
const req = {
|
||||
headers: { 'x-forwarded-for': '' },
|
||||
socket: { remoteAddress: undefined },
|
||||
} as unknown as Request;
|
||||
expect(getClientIp(req)).toBeNull();
|
||||
});
|
||||
});
|
||||
299
server/tests/unit/services/authService.test.ts
Normal file
299
server/tests/unit/services/authService.test.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: { prepare: () => ({ get: vi.fn(), all: vi.fn(), run: vi.fn() }) },
|
||||
canAccessTrip: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
||||
vi.mock('../../../src/services/mfaCrypto', () => ({ encryptMfaSecret: vi.fn(), decryptMfaSecret: vi.fn() }));
|
||||
vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
||||
decrypt_api_key: vi.fn((v) => v),
|
||||
maybe_encrypt_api_key: vi.fn((v) => v),
|
||||
encrypt_api_key: vi.fn((v) => v),
|
||||
}));
|
||||
vi.mock('../../../src/services/permissions', () => ({ getAllPermissions: vi.fn(() => ({})), checkPermission: vi.fn() }));
|
||||
vi.mock('../../../src/services/ephemeralTokens', () => ({ createEphemeralToken: vi.fn() }));
|
||||
vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn() }));
|
||||
vi.mock('../../../src/scheduler', () => ({ startTripReminders: vi.fn(), buildCronExpression: vi.fn() }));
|
||||
|
||||
import {
|
||||
utcSuffix,
|
||||
stripUserForClient,
|
||||
maskKey,
|
||||
avatarUrl,
|
||||
normalizeBackupCode,
|
||||
hashBackupCode,
|
||||
generateBackupCodes,
|
||||
parseBackupCodeHashes,
|
||||
} from '../../../src/services/authService';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
// ── utcSuffix ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('utcSuffix', () => {
|
||||
it('returns null for null', () => {
|
||||
expect(utcSuffix(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for undefined', () => {
|
||||
expect(utcSuffix(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(utcSuffix('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns timestamp unchanged when already ending with Z', () => {
|
||||
expect(utcSuffix('2024-01-01T12:00:00Z')).toBe('2024-01-01T12:00:00Z');
|
||||
});
|
||||
|
||||
it('replaces space with T and appends Z for SQLite-style datetime', () => {
|
||||
expect(utcSuffix('2024-01-01 12:00:00')).toBe('2024-01-01T12:00:00Z');
|
||||
});
|
||||
|
||||
it('appends Z when T is present but Z is missing', () => {
|
||||
expect(utcSuffix('2024-06-15T08:30:00')).toBe('2024-06-15T08:30:00Z');
|
||||
});
|
||||
});
|
||||
|
||||
// ── stripUserForClient ───────────────────────────────────────────────────────
|
||||
|
||||
function makeUser(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
id: 1,
|
||||
username: 'alice',
|
||||
email: 'alice@example.com',
|
||||
role: 'user',
|
||||
password_hash: 'supersecret',
|
||||
maps_api_key: 'maps-key',
|
||||
openweather_api_key: 'weather-key',
|
||||
unsplash_api_key: 'unsplash-key',
|
||||
mfa_secret: 'totpsecret',
|
||||
mfa_backup_codes: '["hash1","hash2"]',
|
||||
mfa_enabled: 0,
|
||||
must_change_password: 0,
|
||||
avatar: null,
|
||||
created_at: '2024-01-01 00:00:00',
|
||||
updated_at: '2024-06-01 00:00:00',
|
||||
last_login: null,
|
||||
...overrides,
|
||||
} as unknown as User;
|
||||
}
|
||||
|
||||
describe('stripUserForClient', () => {
|
||||
it('SEC-008: omits password_hash', () => {
|
||||
const result = stripUserForClient(makeUser());
|
||||
expect(result).not.toHaveProperty('password_hash');
|
||||
});
|
||||
|
||||
it('SEC-008: omits maps_api_key', () => {
|
||||
const result = stripUserForClient(makeUser());
|
||||
expect(result).not.toHaveProperty('maps_api_key');
|
||||
});
|
||||
|
||||
it('SEC-008: omits openweather_api_key', () => {
|
||||
const result = stripUserForClient(makeUser());
|
||||
expect(result).not.toHaveProperty('openweather_api_key');
|
||||
});
|
||||
|
||||
it('SEC-008: omits unsplash_api_key', () => {
|
||||
const result = stripUserForClient(makeUser());
|
||||
expect(result).not.toHaveProperty('unsplash_api_key');
|
||||
});
|
||||
|
||||
it('SEC-008: omits mfa_secret', () => {
|
||||
const result = stripUserForClient(makeUser());
|
||||
expect(result).not.toHaveProperty('mfa_secret');
|
||||
});
|
||||
|
||||
it('SEC-008: omits mfa_backup_codes', () => {
|
||||
const result = stripUserForClient(makeUser());
|
||||
expect(result).not.toHaveProperty('mfa_backup_codes');
|
||||
});
|
||||
|
||||
it('preserves non-sensitive fields', () => {
|
||||
const result = stripUserForClient(makeUser({ username: 'alice', email: 'alice@example.com', role: 'user' }));
|
||||
expect(result.id).toBe(1);
|
||||
expect(result.username).toBe('alice');
|
||||
expect(result.email).toBe('alice@example.com');
|
||||
expect(result.role).toBe('user');
|
||||
});
|
||||
|
||||
it('normalizes mfa_enabled integer 1 to true', () => {
|
||||
const result = stripUserForClient(makeUser({ mfa_enabled: 1 } as any));
|
||||
expect(result.mfa_enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes mfa_enabled integer 0 to false', () => {
|
||||
const result = stripUserForClient(makeUser({ mfa_enabled: 0 } as any));
|
||||
expect(result.mfa_enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes mfa_enabled boolean true to true', () => {
|
||||
const result = stripUserForClient(makeUser({ mfa_enabled: true } as any));
|
||||
expect(result.mfa_enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes must_change_password integer 1 to true', () => {
|
||||
const result = stripUserForClient(makeUser({ must_change_password: 1 } as any));
|
||||
expect(result.must_change_password).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes must_change_password integer 0 to false', () => {
|
||||
const result = stripUserForClient(makeUser({ must_change_password: 0 } as any));
|
||||
expect(result.must_change_password).toBe(false);
|
||||
});
|
||||
|
||||
it('converts created_at through utcSuffix', () => {
|
||||
const result = stripUserForClient(makeUser({ created_at: '2024-01-01 00:00:00' }));
|
||||
expect(result.created_at).toBe('2024-01-01T00:00:00Z');
|
||||
});
|
||||
|
||||
it('converts updated_at through utcSuffix', () => {
|
||||
const result = stripUserForClient(makeUser({ updated_at: '2024-06-01 12:00:00' }));
|
||||
expect(result.updated_at).toBe('2024-06-01T12:00:00Z');
|
||||
});
|
||||
|
||||
it('passes null last_login through as null', () => {
|
||||
const result = stripUserForClient(makeUser({ last_login: null }));
|
||||
expect(result.last_login).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── maskKey ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('maskKey', () => {
|
||||
it('returns null for null', () => {
|
||||
expect(maskKey(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for undefined', () => {
|
||||
expect(maskKey(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(maskKey('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns -------- for keys with 8 or fewer characters', () => {
|
||||
expect(maskKey('abcd1234')).toBe('--------');
|
||||
expect(maskKey('short')).toBe('--------');
|
||||
expect(maskKey('a')).toBe('--------');
|
||||
});
|
||||
|
||||
it('returns ---- + last 4 chars for keys longer than 8 characters', () => {
|
||||
expect(maskKey('abcdefghijkl')).toBe('----ijkl');
|
||||
expect(maskKey('sk-test-12345678')).toBe('----5678');
|
||||
});
|
||||
});
|
||||
|
||||
// ── avatarUrl ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('avatarUrl', () => {
|
||||
it('returns /uploads/avatars/<filename> when avatar is set', () => {
|
||||
expect(avatarUrl({ avatar: 'photo.jpg' })).toBe('/uploads/avatars/photo.jpg');
|
||||
});
|
||||
|
||||
it('returns null when avatar is null', () => {
|
||||
expect(avatarUrl({ avatar: null })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when avatar is undefined', () => {
|
||||
expect(avatarUrl({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── normalizeBackupCode ──────────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeBackupCode', () => {
|
||||
it('uppercases the input', () => {
|
||||
expect(normalizeBackupCode('abcd1234')).toBe('ABCD1234');
|
||||
});
|
||||
|
||||
it('strips non-alphanumeric characters', () => {
|
||||
expect(normalizeBackupCode('AB-CD 12!34')).toBe('ABCD1234');
|
||||
});
|
||||
|
||||
it('handles code with dashes (normal backup code format)', () => {
|
||||
expect(normalizeBackupCode('A1B2-C3D4')).toBe('A1B2C3D4');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(normalizeBackupCode('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── hashBackupCode ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('hashBackupCode', () => {
|
||||
it('returns a 64-character hex string', () => {
|
||||
const hash = hashBackupCode('A1B2-C3D4');
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('is deterministic: same input always produces same output', () => {
|
||||
expect(hashBackupCode('A1B2-C3D4')).toBe(hashBackupCode('A1B2-C3D4'));
|
||||
});
|
||||
|
||||
it('normalizes before hashing: dashed and plain form produce the same hash', () => {
|
||||
expect(hashBackupCode('A1B2-C3D4')).toBe(hashBackupCode('a1b2c3d4'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── generateBackupCodes ──────────────────────────────────────────────────────
|
||||
|
||||
describe('generateBackupCodes', () => {
|
||||
it('returns 10 codes by default', () => {
|
||||
const codes = generateBackupCodes();
|
||||
expect(codes).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('respects a custom count', () => {
|
||||
expect(generateBackupCodes(5)).toHaveLength(5);
|
||||
expect(generateBackupCodes(20)).toHaveLength(20);
|
||||
});
|
||||
|
||||
it('each code matches the XXXX-XXXX uppercase hex pattern', () => {
|
||||
const codes = generateBackupCodes();
|
||||
for (const code of codes) {
|
||||
expect(code).toMatch(/^[0-9A-F]{4}-[0-9A-F]{4}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('generates no duplicate codes', () => {
|
||||
const codes = generateBackupCodes(10);
|
||||
expect(new Set(codes).size).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ── parseBackupCodeHashes ────────────────────────────────────────────────────
|
||||
|
||||
describe('parseBackupCodeHashes', () => {
|
||||
it('returns [] for null', () => {
|
||||
expect(parseBackupCodeHashes(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for undefined', () => {
|
||||
expect(parseBackupCodeHashes(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for empty string', () => {
|
||||
expect(parseBackupCodeHashes('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for invalid JSON', () => {
|
||||
expect(parseBackupCodeHashes('not-json')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for JSON that is not an array', () => {
|
||||
expect(parseBackupCodeHashes('{"key":"value"}')).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters out non-string entries', () => {
|
||||
expect(parseBackupCodeHashes('[1, "abc", null, true]')).toEqual(['abc']);
|
||||
});
|
||||
|
||||
it('returns all strings from a valid JSON string array', () => {
|
||||
expect(parseBackupCodeHashes('["hash1","hash2","hash3"]')).toEqual(['hash1', 'hash2', 'hash3']);
|
||||
});
|
||||
});
|
||||
207
server/tests/unit/services/budgetService.test.ts
Normal file
207
server/tests/unit/services/budgetService.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── DB mock setup ────────────────────────────────────────────────────────────
|
||||
|
||||
interface MockPrepared {
|
||||
all: ReturnType<typeof vi.fn>;
|
||||
get: ReturnType<typeof vi.fn>;
|
||||
run: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
const preparedMap: Record<string, MockPrepared> = {};
|
||||
let defaultAll: ReturnType<typeof vi.fn>;
|
||||
let defaultGet: ReturnType<typeof vi.fn>;
|
||||
|
||||
const mockDb = vi.hoisted(() => {
|
||||
return {
|
||||
db: {
|
||||
prepare: vi.fn((sql: string) => {
|
||||
return {
|
||||
all: vi.fn(() => []),
|
||||
get: vi.fn(() => undefined),
|
||||
run: vi.fn(),
|
||||
};
|
||||
}),
|
||||
},
|
||||
canAccessTrip: vi.fn(() => true),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => mockDb);
|
||||
|
||||
import { calculateSettlement, avatarUrl } from '../../../src/services/budgetService';
|
||||
import type { BudgetItem, BudgetItemMember } from '../../../src/types';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeItem(id: number, total_price: number, trip_id = 1): BudgetItem {
|
||||
return { id, trip_id, name: `Item ${id}`, total_price, category: 'Other' } as BudgetItem;
|
||||
}
|
||||
|
||||
function makeMember(budget_item_id: number, user_id: number, paid: boolean | 0 | 1, username: string): BudgetItemMember & { budget_item_id: number } {
|
||||
return {
|
||||
budget_item_id,
|
||||
user_id,
|
||||
paid: paid ? 1 : 0,
|
||||
username,
|
||||
avatar: null,
|
||||
} as BudgetItemMember & { budget_item_id: number };
|
||||
}
|
||||
|
||||
function setupDb(items: BudgetItem[], members: (BudgetItemMember & { budget_item_id: number })[]) {
|
||||
mockDb.db.prepare.mockImplementation((sql: string) => {
|
||||
if (sql.includes('SELECT * FROM budget_items')) {
|
||||
return { all: vi.fn(() => items), get: vi.fn(), run: vi.fn() };
|
||||
}
|
||||
if (sql.includes('budget_item_members')) {
|
||||
return { all: vi.fn(() => members), get: vi.fn(), run: vi.fn() };
|
||||
}
|
||||
return { all: vi.fn(() => []), get: vi.fn(), run: vi.fn() };
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupDb([], []);
|
||||
});
|
||||
|
||||
// ── avatarUrl ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('avatarUrl', () => {
|
||||
it('returns /uploads/avatars/<filename> when avatar is set', () => {
|
||||
expect(avatarUrl({ avatar: 'photo.jpg' })).toBe('/uploads/avatars/photo.jpg');
|
||||
});
|
||||
|
||||
it('returns null when avatar is null', () => {
|
||||
expect(avatarUrl({ avatar: null })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when avatar is undefined', () => {
|
||||
expect(avatarUrl({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── calculateSettlement ──────────────────────────────────────────────────────
|
||||
|
||||
describe('calculateSettlement', () => {
|
||||
it('returns empty balances and flows when trip has no items', () => {
|
||||
setupDb([], []);
|
||||
const result = calculateSettlement(1);
|
||||
expect(result.balances).toEqual([]);
|
||||
expect(result.flows).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no flows when there are items but no members', () => {
|
||||
setupDb([makeItem(1, 100)], []);
|
||||
const result = calculateSettlement(1);
|
||||
expect(result.flows).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no flows when no one is marked as paid', () => {
|
||||
setupDb(
|
||||
[makeItem(1, 100)],
|
||||
[makeMember(1, 1, 0, 'alice'), makeMember(1, 2, 0, 'bob')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
expect(result.flows).toEqual([]);
|
||||
});
|
||||
|
||||
it('2 members, 1 payer: payer is owed half, non-payer owes half', () => {
|
||||
// Item: $100. Alice paid, Bob did not. Each owes $50. Alice net: +$50. Bob net: -$50.
|
||||
setupDb(
|
||||
[makeItem(1, 100)],
|
||||
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
expect(alice.balance).toBe(50);
|
||||
expect(bob.balance).toBe(-50);
|
||||
expect(result.flows).toHaveLength(1);
|
||||
expect(result.flows[0].from.user_id).toBe(2); // Bob owes
|
||||
expect(result.flows[0].to.user_id).toBe(1); // Alice is owed
|
||||
expect(result.flows[0].amount).toBe(50);
|
||||
});
|
||||
|
||||
it('3 members, 1 payer: correct 3-way split', () => {
|
||||
// Item: $90. Alice paid. Each of 3 owes $30. Alice net: +$60. Bob: -$30. Carol: -$30.
|
||||
setupDb(
|
||||
[makeItem(1, 90)],
|
||||
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob'), makeMember(1, 3, 0, 'carol')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
const carol = result.balances.find(b => b.user_id === 3)!;
|
||||
expect(alice.balance).toBe(60);
|
||||
expect(bob.balance).toBe(-30);
|
||||
expect(carol.balance).toBe(-30);
|
||||
expect(result.flows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('all paid equally: all balances are zero, no flows', () => {
|
||||
// Item: $60. 3 members, all paid equally (each paid $20, each owes $20). Net: 0.
|
||||
// Actually with "paid" flag it means: paidPerPayer = item.total / numPayers.
|
||||
// If all 3 paid: each gets +20 credit, each owes -20 = net 0 for everyone.
|
||||
setupDb(
|
||||
[makeItem(1, 60)],
|
||||
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 1, 'bob'), makeMember(1, 3, 1, 'carol')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
for (const b of result.balances) {
|
||||
expect(Math.abs(b.balance)).toBeLessThanOrEqual(0.01);
|
||||
}
|
||||
expect(result.flows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('flow direction: from is debtor (owes), to is creditor (is owed)', () => {
|
||||
// Alice paid $100 for 2 people. Bob owes Alice $50.
|
||||
setupDb(
|
||||
[makeItem(1, 100)],
|
||||
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const flow = result.flows[0];
|
||||
expect(flow.from.username).toBe('bob'); // debtor
|
||||
expect(flow.to.username).toBe('alice'); // creditor
|
||||
});
|
||||
|
||||
it('amounts are rounded to 2 decimal places', () => {
|
||||
// Item: $10. 3 members, 1 payer. Share = 3.333... Each rounded to 3.33.
|
||||
setupDb(
|
||||
[makeItem(1, 10)],
|
||||
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob'), makeMember(1, 3, 0, 'carol')],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
for (const b of result.balances) {
|
||||
const str = b.balance.toString();
|
||||
const decimals = str.includes('.') ? str.split('.')[1].length : 0;
|
||||
expect(decimals).toBeLessThanOrEqual(2);
|
||||
}
|
||||
for (const flow of result.flows) {
|
||||
const str = flow.amount.toString();
|
||||
const decimals = str.includes('.') ? str.split('.')[1].length : 0;
|
||||
expect(decimals).toBeLessThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('2 items with different payers: aggregates balances correctly', () => {
|
||||
// Item 1: $100, Alice paid, [Alice, Bob] (Alice net: +50, Bob: -50)
|
||||
// Item 2: $60, Bob paid, [Alice, Bob] (Bob net: +30, Alice: -30)
|
||||
// Final: Alice: +50 - 30 = +20, Bob: -50 + 30 = -20
|
||||
setupDb(
|
||||
[makeItem(1, 100), makeItem(2, 60)],
|
||||
[
|
||||
makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob'),
|
||||
makeMember(2, 1, 0, 'alice'), makeMember(2, 2, 1, 'bob'),
|
||||
],
|
||||
);
|
||||
const result = calculateSettlement(1);
|
||||
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
expect(alice.balance).toBe(20);
|
||||
expect(bob.balance).toBe(-20);
|
||||
expect(result.flows).toHaveLength(1);
|
||||
expect(result.flows[0].amount).toBe(20);
|
||||
});
|
||||
});
|
||||
56
server/tests/unit/services/cookie.test.ts
Normal file
56
server/tests/unit/services/cookie.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { cookieOptions } from '../../../src/services/cookie';
|
||||
|
||||
describe('cookieOptions', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('always sets httpOnly: true', () => {
|
||||
expect(cookieOptions()).toHaveProperty('httpOnly', true);
|
||||
});
|
||||
|
||||
it('always sets sameSite: strict', () => {
|
||||
expect(cookieOptions()).toHaveProperty('sameSite', 'strict');
|
||||
});
|
||||
|
||||
it('always sets path: /', () => {
|
||||
expect(cookieOptions()).toHaveProperty('path', '/');
|
||||
});
|
||||
|
||||
it('sets secure: false in test environment (COOKIE_SECURE=false from setup)', () => {
|
||||
// setup.ts sets COOKIE_SECURE=false, so secure should be false
|
||||
const opts = cookieOptions();
|
||||
expect(opts.secure).toBe(false);
|
||||
});
|
||||
|
||||
it('sets secure: true when NODE_ENV=production and COOKIE_SECURE is not false', () => {
|
||||
vi.stubEnv('COOKIE_SECURE', 'true');
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
expect(cookieOptions().secure).toBe(true);
|
||||
});
|
||||
|
||||
it('sets secure: false when COOKIE_SECURE=false even in production', () => {
|
||||
vi.stubEnv('COOKIE_SECURE', 'false');
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
expect(cookieOptions().secure).toBe(false);
|
||||
});
|
||||
|
||||
it('sets secure: true when FORCE_HTTPS=true', () => {
|
||||
vi.stubEnv('COOKIE_SECURE', 'true');
|
||||
vi.stubEnv('FORCE_HTTPS', 'true');
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
expect(cookieOptions().secure).toBe(true);
|
||||
});
|
||||
|
||||
it('includes maxAge: 86400000 when clear is false (default)', () => {
|
||||
expect(cookieOptions()).toHaveProperty('maxAge', 24 * 60 * 60 * 1000);
|
||||
expect(cookieOptions(false)).toHaveProperty('maxAge', 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('omits maxAge when clear is true', () => {
|
||||
const opts = cookieOptions(true);
|
||||
expect(opts).not.toHaveProperty('maxAge');
|
||||
});
|
||||
});
|
||||
71
server/tests/unit/services/ephemeralTokens.test.ts
Normal file
71
server/tests/unit/services/ephemeralTokens.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Reset module between tests that need a fresh token store
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe('ephemeralTokens', () => {
|
||||
async function getModule() {
|
||||
return import('../../../src/services/ephemeralTokens');
|
||||
}
|
||||
|
||||
// AUTH-030 — Resource token creation (single-use)
|
||||
describe('createEphemeralToken', () => {
|
||||
it('AUTH-030: creates a token and returns a hex string', async () => {
|
||||
const { createEphemeralToken } = await getModule();
|
||||
const token = createEphemeralToken(1, 'download');
|
||||
expect(token).not.toBeNull();
|
||||
expect(typeof token).toBe('string');
|
||||
expect(token!.length).toBe(64); // 32 bytes hex
|
||||
});
|
||||
|
||||
it('AUTH-030: different calls produce different tokens', async () => {
|
||||
const { createEphemeralToken } = await getModule();
|
||||
const t1 = createEphemeralToken(1, 'download');
|
||||
const t2 = createEphemeralToken(1, 'download');
|
||||
expect(t1).not.toBe(t2);
|
||||
});
|
||||
});
|
||||
|
||||
// AUTH-029 — WebSocket token expiry (single-use)
|
||||
describe('consumeEphemeralToken', () => {
|
||||
it('AUTH-030: token is consumed and returns userId on first use', async () => {
|
||||
const { createEphemeralToken, consumeEphemeralToken } = await getModule();
|
||||
const token = createEphemeralToken(42, 'download')!;
|
||||
const userId = consumeEphemeralToken(token, 'download');
|
||||
expect(userId).toBe(42);
|
||||
});
|
||||
|
||||
it('AUTH-030: token is single-use — second consume returns null', async () => {
|
||||
const { createEphemeralToken, consumeEphemeralToken } = await getModule();
|
||||
const token = createEphemeralToken(42, 'download')!;
|
||||
consumeEphemeralToken(token, 'download'); // first use
|
||||
const second = consumeEphemeralToken(token, 'download'); // second use
|
||||
expect(second).toBeNull();
|
||||
});
|
||||
|
||||
it('AUTH-029: purpose mismatch returns null', async () => {
|
||||
const { createEphemeralToken, consumeEphemeralToken } = await getModule();
|
||||
const token = createEphemeralToken(42, 'ws')!;
|
||||
const result = consumeEphemeralToken(token, 'download');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('AUTH-029: expired token returns null', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { createEphemeralToken, consumeEphemeralToken } = await getModule();
|
||||
const token = createEphemeralToken(42, 'ws')!; // 30s TTL
|
||||
vi.advanceTimersByTime(31_000); // advance past expiry
|
||||
const result = consumeEphemeralToken(token, 'ws');
|
||||
expect(result).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns null for unknown token', async () => {
|
||||
const { consumeEphemeralToken } = await getModule();
|
||||
const result = consumeEphemeralToken('nonexistent-token', 'download');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
58
server/tests/unit/services/mfaCrypto.test.ts
Normal file
58
server/tests/unit/services/mfaCrypto.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Inline factory to avoid vi.mock hoisting issue (no imported vars allowed)
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { encryptMfaSecret, decryptMfaSecret } from '../../../src/services/mfaCrypto';
|
||||
|
||||
describe('mfaCrypto', () => {
|
||||
const TOTP_SECRET = 'JBSWY3DPEHPK3PXP'; // typical base32 TOTP secret
|
||||
|
||||
// SEC-009 — Encrypted MFA secrets not exposed
|
||||
describe('encryptMfaSecret', () => {
|
||||
it('SEC-009: returns a base64 string (not the plaintext)', () => {
|
||||
const encrypted = encryptMfaSecret(TOTP_SECRET);
|
||||
expect(encrypted).not.toBe(TOTP_SECRET);
|
||||
// Should be valid base64
|
||||
expect(() => Buffer.from(encrypted, 'base64')).not.toThrow();
|
||||
});
|
||||
|
||||
it('different calls produce different ciphertext (random IV)', () => {
|
||||
const enc1 = encryptMfaSecret(TOTP_SECRET);
|
||||
const enc2 = encryptMfaSecret(TOTP_SECRET);
|
||||
expect(enc1).not.toBe(enc2);
|
||||
});
|
||||
|
||||
it('encrypted value does not contain plaintext', () => {
|
||||
const encrypted = encryptMfaSecret(TOTP_SECRET);
|
||||
expect(encrypted).not.toContain(TOTP_SECRET);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decryptMfaSecret', () => {
|
||||
it('SEC-009: roundtrip — decrypt returns original secret', () => {
|
||||
const encrypted = encryptMfaSecret(TOTP_SECRET);
|
||||
const decrypted = decryptMfaSecret(encrypted);
|
||||
expect(decrypted).toBe(TOTP_SECRET);
|
||||
});
|
||||
|
||||
it('handles secrets of varying lengths', () => {
|
||||
const short = 'ABC123';
|
||||
const long = 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP';
|
||||
expect(decryptMfaSecret(encryptMfaSecret(short))).toBe(short);
|
||||
expect(decryptMfaSecret(encryptMfaSecret(long))).toBe(long);
|
||||
});
|
||||
|
||||
it('throws or returns garbage on tampered ciphertext', () => {
|
||||
const encrypted = encryptMfaSecret(TOTP_SECRET);
|
||||
const buf = Buffer.from(encrypted, 'base64');
|
||||
buf[buf.length - 1] ^= 0xff; // flip last byte
|
||||
const tampered = buf.toString('base64');
|
||||
expect(() => decryptMfaSecret(tampered)).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
195
server/tests/unit/services/notifications.test.ts
Normal file
195
server/tests/unit/services/notifications.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, vi, afterEach } 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.mock('node-fetch', () => ({ default: vi.fn() }));
|
||||
|
||||
import { getEventText, buildEmailHtml, buildWebhookBody } from '../../../src/services/notifications';
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
101
server/tests/unit/services/passwordPolicy.test.ts
Normal file
101
server/tests/unit/services/passwordPolicy.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validatePassword } from '../../../src/services/passwordPolicy';
|
||||
|
||||
describe('validatePassword', () => {
|
||||
// AUTH-006 — Registration with weak password
|
||||
describe('length requirement', () => {
|
||||
it('AUTH-006: rejects passwords shorter than 8 characters', () => {
|
||||
expect(validatePassword('Ab1!')).toEqual({ ok: false, reason: expect.stringContaining('8 characters') });
|
||||
expect(validatePassword('Ab1!456')).toEqual({ ok: false, reason: expect.stringContaining('8 characters') });
|
||||
});
|
||||
|
||||
it('accepts passwords of exactly 8 characters that meet all requirements', () => {
|
||||
expect(validatePassword('Ab1!abcd')).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('complexity requirements', () => {
|
||||
it('AUTH-006: rejects password missing uppercase letter', () => {
|
||||
const result = validatePassword('abcd1234!');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toContain('uppercase');
|
||||
});
|
||||
|
||||
it('AUTH-006: rejects password missing lowercase letter', () => {
|
||||
const result = validatePassword('ABCD1234!');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toContain('lowercase');
|
||||
});
|
||||
|
||||
it('AUTH-006: rejects password missing a number', () => {
|
||||
const result = validatePassword('Abcdefg!');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toContain('number');
|
||||
});
|
||||
|
||||
it('AUTH-006: rejects password missing a special character', () => {
|
||||
// 'TrekApp1' — has upper, lower, number, NO special char, NOT in blocklist
|
||||
const result = validatePassword('TrekApp1');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toContain('special character');
|
||||
});
|
||||
});
|
||||
|
||||
// AUTH-007 — Registration with common password
|
||||
describe('common password blocklist', () => {
|
||||
it('AUTH-007: rejects password matching exact blocklist entry (case-insensitive)', () => {
|
||||
// 'password1' is in the blocklist. A capitalised+special variant still matches
|
||||
// because the check is COMMON_PASSWORDS.has(password.toLowerCase()).
|
||||
// However, 'Password1!' lowercased is 'password1!' which is NOT in the set.
|
||||
// We must use a password whose lowercase is exactly in the set:
|
||||
// 'Iloveyou1!' — lowercased: 'iloveyou1!' — NOT in set.
|
||||
// Use a password whose *lowercase* IS in set: 'changeme' → 'Changeme' is 8 chars
|
||||
// but lacks uppercase/number/special — test blocklist with full complex variants:
|
||||
// 'ILoveyou1!' lowercased = 'iloveyou1!' — not in set.
|
||||
// Just test exact matches that satisfy complexity: use blocklist entry itself.
|
||||
// 'Iloveyou' is 8 chars, no number/special → fails complexity, not blocklist.
|
||||
// Better: pick a blocklist entry that, when capitalised + special added, still matches.
|
||||
// The check is: COMMON_PASSWORDS.has(password.toLowerCase())
|
||||
// So 'FOOTBALL!' lowercased = 'football!' — not in set ('football' is in set).
|
||||
// We need password.toLowerCase() to equal a set entry exactly:
|
||||
// 'football' → add uppercase → 'Football' is still 8 chars, no number, no special → fails complexity first
|
||||
// The blocklist check happens BEFORE complexity checks, after length + repetitive checks.
|
||||
// So any 8+ char string whose lowercase is in the blocklist gets caught first.
|
||||
// 'Password1' lowercased = 'password1' → in blocklist! ✓ (length ok, not repetitive)
|
||||
expect(validatePassword('Password1')).toEqual({
|
||||
ok: false,
|
||||
reason: expect.stringContaining('common'),
|
||||
});
|
||||
});
|
||||
|
||||
it('AUTH-007: rejects "Changeme" whose lowercase is in the blocklist', () => {
|
||||
// 'changeme' is in the set; 'Changeme'.toLowerCase() === 'changeme' ✓
|
||||
expect(validatePassword('Changeme')).toEqual({
|
||||
ok: false,
|
||||
reason: expect.stringContaining('common'),
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a strong password that is not in the blocklist', () => {
|
||||
expect(validatePassword('MyUniq!1Trek')).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('repetitive password', () => {
|
||||
it('rejects passwords made of a single repeated character', () => {
|
||||
const result = validatePassword('AAAAAAAA');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toContain('repetitive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('valid passwords', () => {
|
||||
it('accepts a strong unique password', () => {
|
||||
expect(validatePassword('Tr3k!SecurePass')).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('accepts a strong password with special characters', () => {
|
||||
expect(validatePassword('MyP@ss#2024')).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
83
server/tests/unit/services/permissions.test.ts
Normal file
83
server/tests/unit/services/permissions.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mock database — permissions module queries app_settings at runtime
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: {
|
||||
prepare: () => ({
|
||||
all: () => [], // no custom permissions → fall back to defaults
|
||||
run: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { checkPermission, getPermissionLevel, PERMISSION_ACTIONS } from '../../../src/services/permissions';
|
||||
|
||||
describe('permissions', () => {
|
||||
describe('checkPermission — admin bypass', () => {
|
||||
it('admin always passes regardless of permission level', () => {
|
||||
for (const action of PERMISSION_ACTIONS) {
|
||||
expect(checkPermission(action.key, 'admin', 1, 1, false)).toBe(true);
|
||||
expect(checkPermission(action.key, 'admin', 99, 1, false)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkPermission — everybody level', () => {
|
||||
it('trip_create (everybody) allows any authenticated user', () => {
|
||||
expect(checkPermission('trip_create', 'user', null, 42, false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkPermission — trip_owner level', () => {
|
||||
const ownerId = 10;
|
||||
const memberId = 20;
|
||||
|
||||
it('trip owner passes trip_owner check', () => {
|
||||
expect(checkPermission('trip_delete', 'user', ownerId, ownerId, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('member fails trip_owner check', () => {
|
||||
expect(checkPermission('trip_delete', 'user', ownerId, memberId, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('non-member non-owner fails trip_owner check', () => {
|
||||
expect(checkPermission('trip_delete', 'user', ownerId, memberId, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkPermission — trip_member level', () => {
|
||||
const ownerId = 10;
|
||||
const memberId = 20;
|
||||
const outsiderId = 30;
|
||||
|
||||
it('trip owner passes trip_member check', () => {
|
||||
expect(checkPermission('day_edit', 'user', ownerId, ownerId, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('trip member passes trip_member check', () => {
|
||||
expect(checkPermission('day_edit', 'user', ownerId, memberId, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('outsider fails trip_member check', () => {
|
||||
expect(checkPermission('day_edit', 'user', ownerId, outsiderId, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPermissionLevel — defaults', () => {
|
||||
it('returns default level for known actions (no DB overrides)', () => {
|
||||
const defaults: Record<string, string> = {
|
||||
trip_create: 'everybody',
|
||||
trip_delete: 'trip_owner',
|
||||
day_edit: 'trip_member',
|
||||
budget_edit: 'trip_member',
|
||||
};
|
||||
for (const [key, expected] of Object.entries(defaults)) {
|
||||
expect(getPermissionLevel(key)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns trip_owner for unknown action key', () => {
|
||||
expect(getPermissionLevel('nonexistent_action')).toBe('trip_owner');
|
||||
});
|
||||
});
|
||||
});
|
||||
123
server/tests/unit/services/queryHelpers.test.ts
Normal file
123
server/tests/unit/services/queryHelpers.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: { prepare: () => ({ all: () => [], get: vi.fn() }) },
|
||||
}));
|
||||
|
||||
import { formatAssignmentWithPlace } from '../../../src/services/queryHelpers';
|
||||
import type { AssignmentRow, Tag, Participant } from '../../../src/types';
|
||||
|
||||
function makeRow(overrides: Partial<AssignmentRow> = {}): AssignmentRow {
|
||||
return {
|
||||
id: 1,
|
||||
day_id: 10,
|
||||
place_id: 100,
|
||||
order_index: 0,
|
||||
notes: 'assignment note',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
place_name: 'Eiffel Tower',
|
||||
place_description: 'Famous landmark',
|
||||
lat: 48.8584,
|
||||
lng: 2.2945,
|
||||
address: 'Champ de Mars, Paris',
|
||||
category_id: 5,
|
||||
category_name: 'Sightseeing',
|
||||
category_color: '#3b82f6',
|
||||
category_icon: 'landmark',
|
||||
price: 25.0,
|
||||
place_currency: 'EUR',
|
||||
place_time: '10:00',
|
||||
end_time: '12:00',
|
||||
duration_minutes: 120,
|
||||
place_notes: 'Bring tickets',
|
||||
image_url: 'https://example.com/img.jpg',
|
||||
transport_mode: 'walk',
|
||||
google_place_id: 'ChIJLU7jZClu5kcR4PcOOO6p3I0',
|
||||
website: 'https://eiffel-tower.com',
|
||||
phone: '+33 1 2345 6789',
|
||||
...overrides,
|
||||
} as AssignmentRow;
|
||||
}
|
||||
|
||||
const sampleTags: Partial<Tag>[] = [
|
||||
{ id: 1, name: 'Must-see', color: '#ef4444' },
|
||||
];
|
||||
|
||||
const sampleParticipants: Participant[] = [
|
||||
{ user_id: 42, username: 'alice', avatar: null },
|
||||
];
|
||||
|
||||
describe('formatAssignmentWithPlace', () => {
|
||||
it('returns correct top-level shape', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow(), sampleTags, sampleParticipants);
|
||||
expect(result).toHaveProperty('id', 1);
|
||||
expect(result).toHaveProperty('day_id', 10);
|
||||
expect(result).toHaveProperty('order_index', 0);
|
||||
expect(result).toHaveProperty('notes', 'assignment note');
|
||||
expect(result).toHaveProperty('created_at');
|
||||
expect(result).toHaveProperty('place');
|
||||
expect(result).toHaveProperty('participants');
|
||||
});
|
||||
|
||||
it('nests place fields correctly from flat row', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow(), [], []);
|
||||
const { place } = result;
|
||||
expect(place.id).toBe(100);
|
||||
expect(place.name).toBe('Eiffel Tower');
|
||||
expect(place.description).toBe('Famous landmark');
|
||||
expect(place.lat).toBe(48.8584);
|
||||
expect(place.lng).toBe(2.2945);
|
||||
expect(place.address).toBe('Champ de Mars, Paris');
|
||||
expect(place.price).toBe(25.0);
|
||||
expect(place.currency).toBe('EUR');
|
||||
expect(place.place_time).toBe('10:00');
|
||||
expect(place.end_time).toBe('12:00');
|
||||
expect(place.duration_minutes).toBe(120);
|
||||
expect(place.notes).toBe('Bring tickets');
|
||||
expect(place.image_url).toBe('https://example.com/img.jpg');
|
||||
expect(place.transport_mode).toBe('walk');
|
||||
expect(place.google_place_id).toBe('ChIJLU7jZClu5kcR4PcOOO6p3I0');
|
||||
expect(place.website).toBe('https://eiffel-tower.com');
|
||||
expect(place.phone).toBe('+33 1 2345 6789');
|
||||
});
|
||||
|
||||
it('constructs place.category object when category_id is present', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow(), [], []);
|
||||
expect(result.place.category).toEqual({
|
||||
id: 5,
|
||||
name: 'Sightseeing',
|
||||
color: '#3b82f6',
|
||||
icon: 'landmark',
|
||||
});
|
||||
});
|
||||
|
||||
it('sets place.category to null when category_id is null', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow({ category_id: null as any }), [], []);
|
||||
expect(result.place.category).toBeNull();
|
||||
});
|
||||
|
||||
it('sets place.category to null when category_id is 0 (falsy)', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow({ category_id: 0 as any }), [], []);
|
||||
expect(result.place.category).toBeNull();
|
||||
});
|
||||
|
||||
it('includes provided tags in place.tags', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow(), sampleTags, []);
|
||||
expect(result.place.tags).toEqual(sampleTags);
|
||||
});
|
||||
|
||||
it('defaults place.tags to [] when empty array provided', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow(), [], []);
|
||||
expect(result.place.tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('includes provided participants', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow(), [], sampleParticipants);
|
||||
expect(result.participants).toEqual(sampleParticipants);
|
||||
});
|
||||
|
||||
it('defaults participants to [] when empty array provided', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow(), [], []);
|
||||
expect(result.participants).toEqual([]);
|
||||
});
|
||||
});
|
||||
105
server/tests/unit/services/weatherService.test.ts
Normal file
105
server/tests/unit/services/weatherService.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
||||
|
||||
// Prevent the module-level setInterval from running during tests
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Mock node-fetch to prevent real HTTP requests
|
||||
vi.mock('node-fetch', () => ({ default: vi.fn() }));
|
||||
|
||||
import { estimateCondition, cacheKey } from '../../../src/services/weatherService';
|
||||
|
||||
// ── estimateCondition ────────────────────────────────────────────────────────
|
||||
|
||||
describe('estimateCondition', () => {
|
||||
describe('heavy precipitation (precipMm > 5)', () => {
|
||||
it('returns Snow when temp <= 0', () => {
|
||||
expect(estimateCondition(0, 6)).toBe('Snow');
|
||||
expect(estimateCondition(-5, 10)).toBe('Snow');
|
||||
});
|
||||
|
||||
it('returns Rain when temp > 0', () => {
|
||||
expect(estimateCondition(1, 6)).toBe('Rain');
|
||||
expect(estimateCondition(20, 50)).toBe('Rain');
|
||||
});
|
||||
|
||||
it('boundary: precipMm = 5.01 and temp = 0 -> Snow', () => {
|
||||
expect(estimateCondition(0, 5.01)).toBe('Snow');
|
||||
});
|
||||
|
||||
it('boundary: precipMm = 5 is NOT heavy (exactly 5, not > 5) -> falls through', () => {
|
||||
// precipMm = 5 fails the > 5 check, falls to > 1 check -> Snow or Drizzle
|
||||
expect(estimateCondition(0, 5)).toBe('Snow'); // > 1 and temp <= 0
|
||||
expect(estimateCondition(5, 5)).toBe('Drizzle'); // > 1 and temp > 0
|
||||
});
|
||||
});
|
||||
|
||||
describe('moderate precipitation (precipMm > 1)', () => {
|
||||
it('returns Snow when temp <= 0', () => {
|
||||
expect(estimateCondition(0, 2)).toBe('Snow');
|
||||
expect(estimateCondition(-10, 1.5)).toBe('Snow');
|
||||
});
|
||||
|
||||
it('returns Drizzle when temp > 0', () => {
|
||||
expect(estimateCondition(5, 2)).toBe('Drizzle');
|
||||
expect(estimateCondition(15, 3)).toBe('Drizzle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('light precipitation (precipMm > 0.3)', () => {
|
||||
it('returns Clouds regardless of temperature', () => {
|
||||
expect(estimateCondition(-5, 0.5)).toBe('Clouds');
|
||||
expect(estimateCondition(25, 0.5)).toBe('Clouds');
|
||||
});
|
||||
|
||||
it('boundary: precipMm = 0.31 -> Clouds', () => {
|
||||
expect(estimateCondition(20, 0.31)).toBe('Clouds');
|
||||
});
|
||||
|
||||
it('boundary: precipMm = 0.3 is NOT light precipitation -> falls through', () => {
|
||||
// precipMm = 0.3 fails the > 0.3 check, falls to temperature check
|
||||
expect(estimateCondition(20, 0.3)).toBe('Clear'); // temp > 15
|
||||
expect(estimateCondition(10, 0.3)).toBe('Clouds'); // temp <= 15
|
||||
});
|
||||
});
|
||||
|
||||
describe('dry conditions (precipMm <= 0.3)', () => {
|
||||
it('returns Clear when temp > 15', () => {
|
||||
expect(estimateCondition(16, 0)).toBe('Clear');
|
||||
expect(estimateCondition(30, 0.1)).toBe('Clear');
|
||||
});
|
||||
|
||||
it('returns Clouds when temp <= 15', () => {
|
||||
expect(estimateCondition(15, 0)).toBe('Clouds');
|
||||
expect(estimateCondition(10, 0)).toBe('Clouds');
|
||||
expect(estimateCondition(-5, 0)).toBe('Clouds');
|
||||
});
|
||||
|
||||
it('boundary: temp = 15 -> Clouds (not > 15)', () => {
|
||||
expect(estimateCondition(15, 0)).toBe('Clouds');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── cacheKey ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('cacheKey', () => {
|
||||
it('rounds lat and lng to 2 decimal places', () => {
|
||||
expect(cacheKey('48.8566', '2.3522', '2024-06-15')).toBe('48.86_2.35_2024-06-15');
|
||||
});
|
||||
|
||||
it('uses "current" when date is undefined', () => {
|
||||
expect(cacheKey('10.0', '20.0')).toBe('10.00_20.00_current');
|
||||
});
|
||||
|
||||
it('handles negative coordinates', () => {
|
||||
expect(cacheKey('-33.8688', '151.2093', '2024-01-01')).toBe('-33.87_151.21_2024-01-01');
|
||||
});
|
||||
|
||||
it('pads to 2 decimal places for round numbers', () => {
|
||||
expect(cacheKey('48', '2', '2024-01-01')).toBe('48.00_2.00_2024-01-01');
|
||||
});
|
||||
|
||||
it('preserves the date string as-is', () => {
|
||||
expect(cacheKey('0', '0', 'climate')).toBe('0.00_0.00_climate');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user