mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
3d7e8be9aa
An SSO login that matched no account by its subject took the account with the same email address and linked the provider identity to it. That address proves nothing when the account typed it in itself: anyone could sign up with, or switch their profile to, a colleague's address and receive the colleague's first SSO login. The provider switch branch (#2110) relinked a row on any email match without even asking for email_verified, and also for another subject of the same provider. users.email_self_asserted remembers an address the account set itself: signup (not the first account) and an email change in the profile settings set it, an admin entering a new address clears it, and accounts the provider creates start without it. Existing accounts keep linking, except those whose address moved away from the registered one without an admin editing it. A login by email now needs a verified address and an unmarked account, and is refused for another subject of the same issuer, with email_link_blocked and a translated message on the login screen. An email change in the profile settings writes a user.email_change audit row. Refs #2110
572 lines
25 KiB
TypeScript
572 lines
25 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);
|
|
});
|
|
});
|
|
|
|
// An address the account types in itself is not one an SSO sign-in may link to by email.
|
|
describe('updateSettings marks an email the account set itself', () => {
|
|
const flagOf = async (id: number) => (await readUser(await sharedTestOrm(testDb), id)).email_self_asserted;
|
|
|
|
it('AUTH-DB-120: a new address is marked and reported as changed', async () => {
|
|
const { user } = createUser(testDb, { email: 'before@example.com' });
|
|
const result = await profile.updateSettings(user.id, { email: 'colleague@example.com' });
|
|
expect(result.emailChanged).toBe(true);
|
|
expect(await flagOf(user.id)).toBe(1);
|
|
});
|
|
|
|
it('AUTH-DB-121: the same address in another case keeps its standing', async () => {
|
|
const { user } = createUser(testDb, { email: 'same@example.com' });
|
|
const result = await profile.updateSettings(user.id, { email: ' Same@Example.com ' });
|
|
expect(result.emailChanged).toBe(false);
|
|
expect(await flagOf(user.id)).toBe(0);
|
|
});
|
|
|
|
it('AUTH-DB-122: a save without an email leaves the mark alone', async () => {
|
|
const { user } = createUser(testDb);
|
|
const result = await profile.updateSettings(user.id, { username: 'renamed' });
|
|
expect(result.emailChanged).toBe(false);
|
|
expect(await flagOf(user.id)).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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();
|
|
}
|
|
});
|
|
});
|