Files
TREK/server/tests/unit/nest/user-profile.service.test.ts
T
Maurice 2b41a55022 test(server): seed the reminder, roster, Dawarich and plugin specs through the factories
Reminder jobs, user profile, trip members, Dawarich suggestions, the doc
sync controller, the plugin registry and the plugin repository parity
suite now write and read their rows through the ORM factories. The doc
sync controller's itemsForTrip stand-in answers through the two
repository reads DocSyncService makes instead of a copy of their SQL, and
one more database mock drops its dead canAccessTrip/isOwner stand-ins.

What stays raw is marked: the legacy statements the parity cases are
held to, and the places stub in the Dawarich suggestions suite, which
runs inside the service's open transaction and has to write on the
connection that transaction holds.
2026-10-08 18:46:26 +02:00

532 lines
24 KiB
TypeScript

/**
* 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<void> {
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<string, string>;
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<string, string>;
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<string, string>;
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();
}
});
});