/** * user-profile.service.test.ts * * DB-centric unit tests for UserProfileService against a real in-memory SQLite * database. The cases moved here with the methods, out of auth.service.test.ts; * the AUTH-DB-* case IDs are preserved so the history stays greppable. * Constructed directly (no TestingModule, repo convention). */ // --------------------------------------------------------------------------- // vi.hoisted: build the real in-memory DB and the module mock before any import // --------------------------------------------------------------------------- vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb } = await import('../../helpers/db-mock'); const db = createSnapshotTestDb(); const mock = { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => undefined, isOwner: () => false }; return mock; }); vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({ decrypt_api_key: vi.fn((v) => v), maybe_encrypt_api_key: vi.fn((v) => v), mask_stored_api_key: vi.fn((v: string | null | undefined) => (v ? '••••••••' : null)), encrypt_api_key: vi.fn((v) => v), })); // --------------------------------------------------------------------------- // Imports (after mocks) // --------------------------------------------------------------------------- import { db as testDb } from '../../../src/db/database'; import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createAdmin } from '../../helpers/factories'; import fs from 'node:fs'; import path from 'node:path'; import { UserProfileService } from '../../../src/nest/auth/user-profile.service'; import { UsersRepository } from '../../../src/db/repositories/Users.repository'; import { makeStorageFixture } from '../../helpers/storage-fixture'; import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, sharedTestOrm } from '../../helpers/test-uow'; import { updateRows } from '../../helpers/factories/rows'; import { readUser } from '../../helpers/factories/users'; import { readAppSetting, setAppSetting } from '../../helpers/factories/settings'; import { Users } from '../../../src/db/entities/Users.entity'; /** Writes columns of the user row directly, the state a case starts from. */ async function setUserColumns( userId: number, data: { maps_api_key?: string | null; openweather_api_key?: string | null; avatar?: string | null }, ): Promise { await updateRows(await sharedTestOrm(testDb), Users, { id: userId }, data); } import { SEARCH_TEXT_FIELD_MASK } from '../../../src/nest/maps/maps.helpers'; const avatarsFx = makeStorageFixture('avatars/'); let profile: UserProfileService; beforeAll(async () => { profile = new UserProfileService( avatarsFx.storage, await createTestUnitOfWork(testDb), await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb), ); }); beforeEach(() => { resetTestDb(testDb); vi.clearAllMocks(); }); afterAll(() => { testDb.close(); }); // --------------------------------------------------------------------------- // updateSettings // --------------------------------------------------------------------------- describe('updateSettings', () => { it('AUTH-DB-001: updates username successfully', async () => { const { user } = createUser(testDb); const result = await profile.updateSettings(user.id, { username: 'newname' }); expect(result.success).toBe(true); expect(result.user?.username).toBe('newname'); }); it('AUTH-DB-002: returns 400 when username is too short (< 2 chars)', async () => { const { user } = createUser(testDb); const result = await profile.updateSettings(user.id, { username: 'x' }); expect(result.status).toBe(400); expect(result.error).toMatch(/between 2 and 50/i); }); it('AUTH-DB-003: returns 400 when username has invalid characters (spaces)', async () => { const { user } = createUser(testDb); const result = await profile.updateSettings(user.id, { username: 'bad name' }); expect(result.status).toBe(400); expect(result.error).toMatch(/only contain/i); }); it('AUTH-DB-004: returns 409 when username is already taken by another user', async () => { const { user: user1 } = createUser(testDb, { username: 'alice' }); const { user: user2 } = createUser(testDb, { username: 'bob' }); const result = await profile.updateSettings(user2.id, { username: user1.username }); expect(result.status).toBe(409); expect(result.error).toMatch(/already taken/i); }); it('AUTH-DB-005: updates email successfully', async () => { const { user } = createUser(testDb); const result = await profile.updateSettings(user.id, { email: 'new@example.com' }); expect(result.success).toBe(true); expect(result.user?.email).toBe('new@example.com'); }); it('AUTH-DB-006: returns 400 for invalid email format', async () => { const { user } = createUser(testDb); const result = await profile.updateSettings(user.id, { email: 'not-an-email' }); expect(result.status).toBe(400); expect(result.error).toMatch(/invalid email/i); }); it('AUTH-DB-007: returns 409 when email is already taken by another user', async () => { const { user: user1 } = createUser(testDb, { email: 'taken@example.com' }); const { user: user2 } = createUser(testDb); const result = await profile.updateSettings(user2.id, { email: user1.email }); expect(result.status).toBe(409); expect(result.error).toMatch(/already taken/i); }); it('AUTH-DB-008: returns success with no field changes when empty body is passed', async () => { const { user } = createUser(testDb); const result = await profile.updateSettings(user.id, {}); expect(result.success).toBe(true); }); }); // --------------------------------------------------------------------------- // getSettings // --------------------------------------------------------------------------- describe('getSettings', () => { it('AUTH-DB-009: returns 403 for non-admin user', async () => { const { user } = createUser(testDb); const result = await profile.getSettings(user.id); expect(result.status).toBe(403); expect(result.error).toMatch(/admin/i); }); it('AUTH-DB-010: returns maps_api_key and openweather_api_key for admin', async () => { const { user } = createAdmin(testDb); await setUserColumns(user.id, { maps_api_key: 'maps-key-value', openweather_api_key: 'weather-key-value' }); const result = await profile.getSettings(user.id); expect(result.status).toBeUndefined(); expect(result.settings).toBeDefined(); expect(result.settings).toHaveProperty('maps_api_key'); expect(result.settings).toHaveProperty('openweather_api_key'); }); it('AUTH-DB-010b: round-trips unsplash_api_key through updateApiKeys — masked to the client, readable via getSettings', async () => { const { user } = createAdmin(testDb); const result = await profile.updateApiKeys(user.id, { unsplash_api_key: 'unsplash-secret-key' }); // Returned to the client masked, never in plaintext. expect(result.user.unsplash_api_key).toBe('-----key'); // getSettings returns the stored key to the admin. expect((await profile.getSettings(user.id)).settings?.unsplash_api_key).toBe('unsplash-secret-key'); }); it('AUTH-DB-010c: a key set in the environment comes back as its variable name, not a value (#1881)', async () => { const prev = process.env.PLACES_API_KEY; process.env.PLACES_API_KEY = 'google-from-env'; try { const { user } = createAdmin(testDb); await profile.updateApiKeys(user.id, { maps_api_key: 'stored-but-overridden', unsplash_api_key: 'stored-unsplash' }); const settings = (await profile.getSettings(user.id)).settings; // Neither the stored value nobody searches with nor the operator's own. expect(settings?.maps_api_key).toBeNull(); expect(settings?.env_keys).toEqual({ maps_api_key: 'PLACES_API_KEY' }); expect(JSON.stringify(settings)).not.toContain('google-from-env'); // A key without a variable reads as before. expect(settings?.unsplash_api_key).toBe('stored-unsplash'); } finally { if (prev === undefined) delete process.env.PLACES_API_KEY; else process.env.PLACES_API_KEY = prev; } }); it('AUTH-DB-010d: without a variable env_keys is empty', async () => { const { user } = createAdmin(testDb); expect((await profile.getSettings(user.id)).settings?.env_keys).toEqual({}); }); }); // --------------------------------------------------------------------------- // listUsers // --------------------------------------------------------------------------- describe('listUsers', () => { it('AUTH-DB-011: returns all users except self, sorted by username', async () => { const { user: self } = createUser(testDb, { username: 'zzself' }); createUser(testDb, { username: 'alice' }); createUser(testDb, { username: 'charlie' }); createUser(testDb, { username: 'bob' }); const result = await profile.listUsers(self.id); expect(result).toHaveLength(3); const names = result.map((u) => u.username); expect(names).toEqual([...names].sort()); expect(names).not.toContain('zzself'); }); it('AUTH-DB-012: returns empty array when only one user exists', async () => { const { user } = createUser(testDb); const result = await profile.listUsers(user.id); expect(result).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // validateKeys // --------------------------------------------------------------------------- describe('validateKeys', () => { it('AUTH-DB-015: returns 403 for non-admin', async () => { const { user } = createUser(testDb); const result = await profile.validateKeys(user.id); expect(result.status).toBe(403); expect(result.error).toMatch(/admin/i); expect(result.maps).toBe(false); expect(result.weather).toBe(false); }); it('AUTH-DB-016: returns { maps: false, weather: false } when no API keys are stored', async () => { const { user } = createAdmin(testDb); const result = await profile.validateKeys(user.id); expect(result.maps).toBe(false); expect(result.weather).toBe(false); expect(result.maps_details).toBeNull(); }); it('AUTH-DB-017: returns { maps: true } when fetch returns 200', async () => { const { user } = createAdmin(testDb); await setUserColumns(user.id, { maps_api_key: 'test-key' }); const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce({ status: 200, statusText: 'OK', text: async () => '', } as Response); const result = await profile.validateKeys(user.id); expect(result.maps).toBe(true); expect(result.maps_details?.ok).toBe(true); fetchSpy.mockRestore(); }); it('AUTH-DB-018: returns { maps: false } when fetch throws a network error', async () => { const { user } = createAdmin(testDb); await setUserColumns(user.id, { maps_api_key: 'test-key' }); const fetchSpy = vi .spyOn(global, 'fetch') .mockRejectedValueOnce(new Error('Network failure')); const result = await profile.validateKeys(user.id); expect(result.maps).toBe(false); expect(result.maps_details?.error_status).toBe('FETCH_ERROR'); expect(result.maps_details?.error_message).toBe('Network failure'); fetchSpy.mockRestore(); }); it('AUTH-DB-098: sends Referer from APP_URL so referrer-restricted keys validate like real requests', async () => { const { user } = createAdmin(testDb); await setUserColumns(user.id, { maps_api_key: 'test-key' }); const prevAppUrl = process.env.APP_URL; process.env.APP_URL = 'https://trek.example.com'; const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce({ status: 200, statusText: 'OK', text: async () => '', } as Response); await profile.validateKeys(user.id); const headers = (fetchSpy.mock.calls[0]?.[1]?.headers ?? {}) as Record; expect(headers.Referer).toBe('https://trek.example.com'); fetchSpy.mockRestore(); if (prevAppUrl === undefined) delete process.env.APP_URL; else process.env.APP_URL = prevAppUrl; }); it('AUTH-DB-111: probes the operator key on an install where the admin has no column of their own', async () => { const prevPlacesKey = process.env.PLACES_API_KEY; process.env.PLACES_API_KEY = 'operator-key'; const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce({ status: 200, statusText: 'OK', text: async () => '', } as Response); try { // The admin column stays empty on purpose: on an operator-run install the // key belongs to whoever runs the box, and the panel used to answer // "no maps key" while every search on that same install worked. const { user } = createAdmin(testDb); const result = await profile.validateKeys(user.id); const headers = (fetchSpy.mock.calls[0]?.[1]?.headers ?? {}) as Record; expect(headers['X-Goog-Api-Key']).toBe('operator-key'); expect(result.maps).toBe(true); } finally { fetchSpy.mockRestore(); if (prevPlacesKey === undefined) delete process.env.PLACES_API_KEY; else process.env.PLACES_API_KEY = prevPlacesKey; } }); it('AUTH-DB-112: probes the instance key, not a stale value in the admin own column', async () => { const { user } = createAdmin(testDb); // What a second admin sees after the instance key was saved by the first // one: their column still holds whatever they pasted long ago, while every // search on the install runs on the app_settings row. await setUserColumns(user.id, { maps_api_key: 'stale-personal-key' }); await setAppSetting(await sharedTestOrm(testDb), 'maps_api_key', 'live-instance-key'); const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce({ status: 200, statusText: 'OK', text: async () => '', } as Response); await profile.validateKeys(user.id); const headers = (fetchSpy.mock.calls[0]?.[1]?.headers ?? {}) as Record; expect(headers['X-Goog-Api-Key']).toBe('live-instance-key'); // And it asks for the fields the real search asks for, so a key restricted // to fewer Places SKUs fails the test button instead of passing it. expect(headers['X-Goog-FieldMask']).toBe(SEARCH_TEXT_FIELD_MASK); fetchSpy.mockRestore(); }); }); describe('updateMapsKey / avatar', () => { it('AUTH-DB-068: updateMapsKey stores and answers the masked key', async () => { const { user } = createUser(testDb); const result = await profile.updateMapsKey(user.id, 'maps-key-123456'); expect(result.success).toBe(true); expect(result.maps_api_key).toBe('----3456'); }); it('AUTH-DB-069: saveAvatar updates the row and answers the public url', async () => { const { user } = createUser(testDb); const result = await profile.saveAvatar(user.id, 'new.png'); expect(result).toEqual({ success: true, avatar_url: '/uploads/avatars/new.png' }); }); it('AUTH-DB-070: deleteAvatar nulls the column; an OIDC https avatar skips the file rm', async () => { const { user } = createUser(testDb); await setUserColumns(user.id, { avatar: 'https://idp/pic.jpg' }); expect(await profile.deleteAvatar(user.id)).toEqual({ success: true }); const row = await readUser(await sharedTestOrm(testDb), user.id); expect(row.avatar).toBeNull(); }); function writeAvatar(name: string): string { const dir = path.join(avatarsFx.root, 'avatars'); fs.mkdirSync(dir, { recursive: true }); const fp = path.join(dir, name); fs.writeFileSync(fp, 'png-bytes'); return fp; } it('AUTH-DB-069b: saveAvatar reclaims the previous uploaded avatar file', async () => { const { user } = createUser(testDb); const old = writeAvatar('old.png'); await setUserColumns(user.id, { avatar: 'old.png' }); await profile.saveAvatar(user.id, 'new.png'); expect(fs.existsSync(old)).toBe(false); }); it('AUTH-DB-070b: deleteAvatar removes the uploaded avatar file', async () => { const { user } = createUser(testDb); const fp = writeAvatar('mine.png'); await setUserColumns(user.id, { avatar: 'mine.png' }); expect(await profile.deleteAvatar(user.id)).toEqual({ success: true }); expect(fs.existsSync(fp)).toBe(false); }); it('AUTH-DB-070c: a hostile stored avatar value is swallowed, never thrown', async () => { const { user } = createUser(testDb); await setUserColumns(user.id, { avatar: '../../etc/passwd' }); // Central key validation rejects the value; the delete swallows it exactly // like the old rm().catch did — the DB update still wins. expect(await profile.deleteAvatar(user.id)).toEqual({ success: true }); }); }); describe('profile quirk fixes', () => { it('AUTH-DB-093: updateApiKeys degrades gracefully when the user row is gone (no TypeError/500)', async () => { await expect(profile.updateApiKeys(999999, { maps_api_key: 'k' })).resolves.not.toThrow(); const result = await profile.updateApiKeys(999999, { openweather_api_key: 'w' }); expect(result.success).toBe(true); }); }); // --------------------------------------------------------------------------- // Instance-wide keys + changedKeys (#1939) // // apiKeyCrypto is mocked to identity in this file, so nothing here asserts // anything about encryption — the random-IV half of changedKeys is pinned in // tests/integration/security.test.ts, which runs with a real ENCRYPTION_KEY. // --------------------------------------------------------------------------- const instanceRow = async (key: string) => (await readAppSetting(await sharedTestOrm(testDb), key)) ?? undefined; describe('instance-wide API keys', () => { it('AUTH-DB-102: an admin save lands in app_settings AND in their own column', async () => { const { user } = createAdmin(testDb); await profile.updateApiKeys(user.id, { maps_api_key: 'instance-google-key', unsplash_api_key: 'instance-unsplash-key' }); expect(await instanceRow('maps_api_key')).toBe('instance-google-key'); expect(await instanceRow('unsplash_api_key')).toBe('instance-unsplash-key'); // The column stays in step so clearing the field clears both. const row = await readUser(await sharedTestOrm(testDb), user.id); expect(row.maps_api_key).toBe('instance-google-key'); }); it('AUTH-DB-103: a non-admin save never touches the instance value', async () => { const { user: admin } = createAdmin(testDb); await profile.updateApiKeys(admin.id, { maps_api_key: 'admin-set' }); const { user } = createUser(testDb); await profile.updateApiKeys(user.id, { maps_api_key: 'members-own' }); expect(await instanceRow('maps_api_key')).toBe('admin-set'); const row = await readUser(await sharedTestOrm(testDb), user.id); expect(row.maps_api_key).toBe('members-own'); }); it('AUTH-DB-104: clearing the field stores an empty instance value rather than dropping the row', async () => { const { user } = createAdmin(testDb); await profile.updateApiKeys(user.id, { maps_api_key: 'to-be-removed' }); await profile.updateApiKeys(user.id, { maps_api_key: '' }); // '' and not a missing row: a missing row would fall through to whatever // still sat in the admin's own column. expect(await instanceRow('maps_api_key')).toBe(''); }); it('AUTH-DB-105: getSettings reads the instance value, not the admin own column', async () => { const { user } = createAdmin(testDb); await setUserColumns(user.id, { maps_api_key: 'stale-personal-key' }); await setAppSetting(await sharedTestOrm(testDb), 'maps_api_key', 'live-instance-key'); expect((await profile.getSettings(user.id)).settings?.maps_api_key).toBe('live-instance-key'); // openweather is per-user and unaffected. expect((await profile.getSettings(user.id)).settings?.openweather_api_key).toBeNull(); }); it('AUTH-DB-106: updateMapsKey mirrors for an admin and stays personal for a member', async () => { const { user: admin } = createAdmin(testDb); expect((await profile.updateMapsKey(admin.id, 'via-maps-key-route')).changedKeys).toEqual(['maps_api_key']); expect(await instanceRow('maps_api_key')).toBe('via-maps-key-route'); const { user } = createUser(testDb); await profile.updateMapsKey(user.id, 'members-own'); expect(await instanceRow('maps_api_key')).toBe('via-maps-key-route'); }); it('AUTH-DB-107: updateSettings mirrors the key half without touching name/email handling', async () => { const { user } = createAdmin(testDb); const result = await profile.updateSettings(user.id, { maps_api_key: 'from-settings-route', username: 'renamed' }); expect(result.success).toBe(true); expect(result.user?.username).toBe('renamed'); expect(await instanceRow('maps_api_key')).toBe('from-settings-route'); expect(result.changedKeys).toEqual(['maps_api_key']); }); }); describe('changedKeys', () => { it('AUTH-DB-108: only the names in the body count, and clearing counts as a change', async () => { const { user } = createAdmin(testDb); await profile.updateApiKeys(user.id, { maps_api_key: 'k1', openweather_api_key: 'w1' }); // unsplash was never sent, so it can never be reported. expect((await profile.updateApiKeys(user.id, { openweather_api_key: 'w2' })).changedKeys).toEqual(['openweather_api_key']); expect((await profile.updateApiKeys(user.id, { maps_api_key: '' })).changedKeys).toEqual(['maps_api_key']); }); it('AUTH-DB-109: an unchanged value reports nothing, whitespace included', async () => { const { user } = createAdmin(testDb); await profile.updateApiKeys(user.id, { maps_api_key: 'unchanged-key' }); expect((await profile.updateApiKeys(user.id, { maps_api_key: 'unchanged-key' })).changedKeys).toEqual([]); // maybe_encrypt_api_key trims on the way in, so the comparison has to too. expect((await profile.updateApiKeys(user.id, { maps_api_key: ' unchanged-key ' })).changedKeys).toEqual([]); // A member is measured against their own column, not the instance value. const { user: member } = createUser(testDb); expect((await profile.updateApiKeys(member.id, { maps_api_key: 'unchanged-key' })).changedKeys).toEqual(['maps_api_key']); }); it('AUTH-DB-110: a managed install reports no change for the names it refuses to write', async () => { const prev = process.env.TREK_MANAGED; process.env.TREK_MANAGED = 'true'; try { const { user } = createAdmin(testDb); const result = await profile.updateApiKeys(user.id, { maps_api_key: 'operator-owns-this' }); expect(result.managed_keys).toEqual(['maps_api_key']); expect(result.changedKeys).toEqual([]); expect(await instanceRow('maps_api_key')).toBeUndefined(); expect((await profile.updateMapsKey(user.id, 'operator-owns-this')).changedKeys).toEqual([]); expect((await profile.updateSettings(user.id, { maps_api_key: 'operator-owns-this' })).changedKeys).toEqual([]); } finally { if (prev === undefined) delete process.env.TREK_MANAGED; else process.env.TREK_MANAGED = prev; } }); }); describe('updateSettings loses the race for an email', () => { it('AUTH-IDENT-002: the database refusing what the check missed answers the same 409', async () => { createUser(testDb, { email: 'anna@example.com' }); const { user } = createUser(testDb, { email: 'carl@example.com' }); const check = vi.spyOn(UsersRepository.prototype, 'findIdByEmailCI').mockResolvedValue(null); try { const result = await profile.updateSettings(user.id, { email: 'ANNA@example.com' }); expect(result).toEqual({ error: 'Email already taken', status: 409 }); } finally { check.mockRestore(); } }); });