Files
TREK/server/tests/unit/nest/user-profile.service.test.ts
T
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

546 lines
24 KiB
TypeScript

// ---------------------------------------------------------------------------
// Imports (after mocks)
// ---------------------------------------------------------------------------
import { db as testDb } from '../../../src/db/database';
import { Users } from '../../../src/db/entities/Users.entity';
import { UsersRepository } from '../../../src/db/repositories/Users.repository';
import { UserProfileService } from '../../../src/nest/auth/user-profile.service';
import { SEARCH_TEXT_FIELD_MASK } from '../../../src/nest/maps/providers/google-places.constants';
import { asLegacyResult } from '../../helpers/domain-error';
import { createUser, createAdmin } from '../../helpers/factories';
import { updateRows } from '../../helpers/factories/rows';
import { readAppSetting, setAppSetting } from '../../helpers/factories/settings';
import { readUser } from '../../helpers/factories/users';
import { makeStorageFixture } from '../../helpers/storage-fixture';
import { resetTestDb } from '../../helpers/test-db';
import {
createTestUnitOfWork,
createTestAppSettingsRepo,
createTestUsersRepo,
sharedTestOrm,
} from '../../helpers/test-uow';
import fs from 'node:fs';
import path from 'node:path';
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
/**
* 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),
}));
/** 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);
}
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 asLegacyResult(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 asLegacyResult(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 asLegacyResult(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 asLegacyResult(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 asLegacyResult(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 asLegacyResult(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 asLegacyResult(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 asLegacyResult(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 asLegacyResult(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 asLegacyResult(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 asLegacyResult(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 asLegacyResult(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 asLegacyResult(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 asLegacyResult(profile.validateKeys(user.id));
expect(result.status).toBe(403);
expect(result.error).toMatch(/admin/i);
});
it('AUTH-DB-016: returns { maps: false, weather: false } when no API keys are stored', async () => {
const { user } = createAdmin(testDb);
const result = await asLegacyResult(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 asLegacyResult(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 asLegacyResult(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 asLegacyResult(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 asLegacyResult(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 asLegacyResult(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 asLegacyResult(profile.getSettings(user.id))).settings?.maps_api_key).toBe('live-instance-key');
// openweather is per-user and unaffected.
expect((await asLegacyResult(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 asLegacyResult(
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 asLegacyResult(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 asLegacyResult(profile.updateSettings(user.id, { email: 'ANNA@example.com' }));
expect(result).toEqual({ error: 'Email already taken', status: 409 });
} finally {
check.mockRestore();
}
});
});