Files
TREK/server/tests/unit/nest/user-profile.service.test.ts
T
Maurice bb5cc8f795 fix(db): one account per email address, whatever its case
Login, password reset and the sign-up checks all match emails
case-insensitively, but the only constraint was the column's own
UNIQUE, which SQLite compares case-sensitively. Two accounts whose
addresses differ only in case (legacy rows, two sign-ups at once) left a
login to resolve to whichever row came first, and LOWER(email) lookups
had no index to use.

A migration adds a unique index on lower(email). An install that
already holds such a pair must still start, so the migration then
leaves the index out, logs how many addresses are shared and by which
user ids (not the addresses), and records email_case_index_pending.
Since a migration runs once, db/email-case-index.ts runs on every boot
while that flag is set: it repeats the warning until the accounts are
told apart, then creates the index and clears the flag. The app-level
checks keep guarding meanwhile.

A write that passes the up-front check and still loses the race is now
refused by the database. The users repository turns that unique
violation into UserIdentityTakenError, and sign-up, admin create and
edit, and the profile update answer it with the same 409 their checks
give, instead of a 500 or an unhandled rejection (ADMIN-SVC-094 pinned
the old behaviour and now pins the 409).

The entity generator could not see an expression index (PRAGMA
index_info names its column NULL), so PARITY-009c found a db index with
no entity declaration. Rule 14 carries such an index into the entity as
a named entry with its own statement; Users.entity.ts is regenerated.
2026-10-08 13:02:58 +02:00

523 lines
23 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 } from '../../helpers/test-uow';
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);
testDb
.prepare('UPDATE users SET maps_api_key = ?, openweather_api_key = ? WHERE id = ?')
.run('maps-key-value', 'weather-key-value', user.id);
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);
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('test-key', user.id);
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);
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('test-key', user.id);
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);
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('test-key', user.id);
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.
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('stale-personal-key', user.id);
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('maps_api_key', 'live-instance-key')").run();
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);
testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('https://idp/pic.jpg', user.id);
expect(await profile.deleteAvatar(user.id)).toEqual({ success: true });
const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as { avatar: string | null };
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');
testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('old.png', user.id);
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');
testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('mine.png', user.id);
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);
testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('../../etc/passwd', user.id);
// 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 = (key: string) =>
(testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
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(instanceRow('maps_api_key')).toBe('instance-google-key');
expect(instanceRow('unsplash_api_key')).toBe('instance-unsplash-key');
// The column stays in step so clearing the field clears both.
const row = testDb.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(user.id) as { maps_api_key: string };
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(instanceRow('maps_api_key')).toBe('admin-set');
const row = testDb.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(user.id) as { maps_api_key: string };
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(instanceRow('maps_api_key')).toBe('');
});
it('AUTH-DB-105: getSettings reads the instance value, not the admin own column', async () => {
const { user } = createAdmin(testDb);
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('stale-personal-key', user.id);
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('maps_api_key', 'live-instance-key')").run();
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(instanceRow('maps_api_key')).toBe('via-maps-key-route');
const { user } = createUser(testDb);
await profile.updateMapsKey(user.id, 'members-own');
expect(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(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(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();
}
});
});