mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
test(server): seed the users repository spec through the factories
Converts the raw prepare() fixtures and post-condition reads in the users repository spec to updateRows, findRow and readUser. The Immich read helper becomes async and still returns only the three columns its toEqual assertions compare, and the guest insert check compares its columns with toMatchObject now that the read returns the whole row. The raw statements that remain carry a test-sql-allow marker: the legacy statements run as parity oracles for the repository reads, the NOT NULL DEFAULT 0 probe on is_guest, which has to bypass the entity default, and the unflushed-entity check, which must read the table without an EntityManager that could flush first.
This commit is contained in:
@@ -4,6 +4,8 @@ import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createUser, createAdmin, type TestUser } from '../../../helpers/factories';
|
||||
import { Users } from '../../../../src/db/entities/Users.entity';
|
||||
import { findRow, updateRows } from '../../../helpers/factories/rows';
|
||||
import { readUser } from '../../../helpers/factories/users';
|
||||
import type { UsersRepository } from '../../../../src/db/repositories/Users.repository';
|
||||
import { UserIdentityTakenError } from '../../../../src/db/repositories/Users.repository';
|
||||
|
||||
@@ -39,7 +41,7 @@ describe('UsersRepository', () => {
|
||||
it('USERSREPO-001: getEmail reads the stored email, matching SELECT email FROM users WHERE id = ?', async () => {
|
||||
const { user } = createUser(testDb, { email: 'someone@example.com' });
|
||||
expect(await users.getEmail(user.id)).toBe('someone@example.com');
|
||||
const raw = testDb.prepare('SELECT email FROM users WHERE id = ?').get(user.id) as { email: string };
|
||||
const raw = await findRow(t, Users, { id: user.id }) as { email: string };
|
||||
expect(await users.getEmail(user.id)).toBe(raw.email);
|
||||
});
|
||||
|
||||
@@ -49,20 +51,19 @@ describe('UsersRepository', () => {
|
||||
|
||||
it('USERSREPO-003: getApiKeyColumn reads maps_api_key, matching SELECT maps_api_key FROM users WHERE id = ?', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('encrypted-maps-key', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { maps_api_key: 'encrypted-maps-key' });
|
||||
expect(await users.getApiKeyColumn(user.id, 'maps_api_key')).toBe('encrypted-maps-key');
|
||||
});
|
||||
|
||||
it('USERSREPO-004: getApiKeyColumn reads unsplash_api_key, not the other two columns', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET maps_api_key = ?, unsplash_api_key = ?, amap_api_key = ? WHERE id = ?')
|
||||
.run('maps-value', 'unsplash-value', 'amap-value', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { maps_api_key: 'maps-value', unsplash_api_key: 'unsplash-value', amap_api_key: 'amap-value' });
|
||||
expect(await users.getApiKeyColumn(user.id, 'unsplash_api_key')).toBe('unsplash-value');
|
||||
});
|
||||
|
||||
it('USERSREPO-005: getApiKeyColumn reads amap_api_key', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET amap_api_key = ? WHERE id = ?').run('amap-value', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { amap_api_key: 'amap-value' });
|
||||
expect(await users.getApiKeyColumn(user.id, 'amap_api_key')).toBe('amap-value');
|
||||
});
|
||||
|
||||
@@ -82,7 +83,7 @@ describe('UsersRepository', () => {
|
||||
it('USERSREPO-007: a raw UPDATE users SET email then getEmail reads the new email, in one query', async () => {
|
||||
const { user } = createUser(testDb, { email: 'old@example.com' });
|
||||
expect(await users.getEmail(user.id)).toBe('old@example.com'); // populate the identity map
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('new@example.com', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { email: 'new@example.com' });
|
||||
const { value, queries } = await withQueryCount(() => users.getEmail(user.id));
|
||||
expect(value).toBe('new@example.com');
|
||||
expect(queries).toBe(1);
|
||||
@@ -90,9 +91,9 @@ describe('UsersRepository', () => {
|
||||
|
||||
it('USERSREPO-008: a raw UPDATE users SET maps_api_key then getApiKeyColumn reads the new value, in one query', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('old-key', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { maps_api_key: 'old-key' });
|
||||
expect(await users.getApiKeyColumn(user.id, 'maps_api_key')).toBe('old-key'); // populate the identity map
|
||||
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('new-key', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { maps_api_key: 'new-key' });
|
||||
const { value, queries } = await withQueryCount(() => users.getApiKeyColumn(user.id, 'maps_api_key'));
|
||||
expect(value).toBe('new-key');
|
||||
expect(queries).toBe(1);
|
||||
@@ -107,7 +108,7 @@ describe('UsersRepository', () => {
|
||||
|
||||
it('USERSREPO-009: findByIdWithPasswordVersion (JV1) reads id/username/email/role/password_version', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET password_version = 3 WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { password_version: 3 });
|
||||
const row = await users.findByIdWithPasswordVersion(user.id);
|
||||
expect(row).toEqual({ id: user.id, username: user.username, email: user.email, role: 'user', password_version: 3 });
|
||||
expect(await users.findByIdWithPasswordVersion(999999)).toBeNull();
|
||||
@@ -115,7 +116,7 @@ describe('UsersRepository', () => {
|
||||
|
||||
it('USERSREPO-009b: findForWsHandshake (Plan 4 Task 1, realtime.gateway.ts) reads id/username/email/role/mfa_enabled/password_version', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET password_version = 3, mfa_enabled = 1 WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { password_version: 3, mfa_enabled: 1 });
|
||||
const row = await users.findForWsHandshake(user.id);
|
||||
expect(row).toEqual({ id: user.id, username: user.username, email: user.email, role: 'user', mfa_enabled: 1, password_version: 3 });
|
||||
expect(await users.findForWsHandshake(999999)).toBeNull();
|
||||
@@ -123,14 +124,14 @@ describe('UsersRepository', () => {
|
||||
|
||||
it('USERSREPO-009c: findForWsHandshake reports mfa_enabled as null, not 0, for a user who never had it set (Plan 4 Task 1)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = NULL WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { mfa_enabled: null });
|
||||
expect((await users.findForWsHandshake(user.id))?.mfa_enabled).toBeNull();
|
||||
});
|
||||
|
||||
it('USERSREPO-010: getPasswordVersion reads password_version, null for a missing user', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
expect(await users.getPasswordVersion(user.id)).toBe(0);
|
||||
testDb.prepare('UPDATE users SET password_version = 7 WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { password_version: 7 });
|
||||
expect(await users.getPasswordVersion(user.id)).toBe(7);
|
||||
expect(await users.getPasswordVersion(999999)).toBeNull();
|
||||
});
|
||||
@@ -140,14 +141,17 @@ describe('UsersRepository', () => {
|
||||
createUser(testDb);
|
||||
createUser(testDb);
|
||||
const { user: guest } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id);
|
||||
await updateRows(t, Users, { id: guest.id }, { is_guest: 1 });
|
||||
expect(await users.countNonGuest()).toBe(2);
|
||||
});
|
||||
|
||||
it('USERSREPO-012: a NULL is_guest row cannot exist — the column is NOT NULL DEFAULT 0 (verified against the baseline + guest-members migrations), so a direct INSERT that omits the column, or an explicit NULL, both resolve to 0, never leaving a row this filter would miscount', () => {
|
||||
// test-sql-allow: probes the column's NOT NULL DEFAULT 0 itself, below the ORM and its entity defaults.
|
||||
testDb.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)').run('nog', 'nog@test.example.com', 'x', 'user');
|
||||
// test-sql-allow: probes the column's NOT NULL DEFAULT 0 itself, below the ORM and its entity defaults.
|
||||
const stored = testDb.prepare("SELECT is_guest FROM users WHERE username = 'nog'").get() as { is_guest: number };
|
||||
expect(stored.is_guest).toBe(0); // the DEFAULT 0, not a nullable column silently coercing
|
||||
// test-sql-allow: probes the column's NOT NULL DEFAULT 0 itself, below the ORM and its entity defaults.
|
||||
expect(() => testDb.prepare('UPDATE users SET is_guest = NULL WHERE username = ?').run('nog')).toThrow(/NOT NULL constraint failed/);
|
||||
});
|
||||
});
|
||||
@@ -157,9 +161,9 @@ describe('UsersRepository', () => {
|
||||
const { user: plainAdmin } = createAdmin(testDb);
|
||||
expect(await users.findAdminNeedingPasswordChange()).toBeNull();
|
||||
const { user: flaggedUser } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET must_change_password = 1 WHERE id = ?').run(flaggedUser.id);
|
||||
await updateRows(t, Users, { id: flaggedUser.id }, { must_change_password: 1 });
|
||||
expect(await users.findAdminNeedingPasswordChange()).toBeNull(); // not an admin
|
||||
testDb.prepare('UPDATE users SET must_change_password = 1 WHERE id = ?').run(plainAdmin.id);
|
||||
await updateRows(t, Users, { id: plainAdmin.id }, { must_change_password: 1 });
|
||||
expect(await users.findAdminNeedingPasswordChange()).toBe(plainAdmin.id);
|
||||
});
|
||||
|
||||
@@ -173,7 +177,7 @@ describe('UsersRepository', () => {
|
||||
|
||||
it('USERSREPO-015: excludes a guest even on an exact match', async () => {
|
||||
const { user } = createUser(testDb, { email: 'guest1@example.com', username: 'guest1' });
|
||||
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { is_guest: 1 });
|
||||
expect(await users.findIdByEmailOrUsernameCI('guest1@example.com', 'guest1')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -210,7 +214,7 @@ describe('UsersRepository', () => {
|
||||
expect(row.oidc_issuer).toBeNull();
|
||||
expect(row.avatar).toBeNull();
|
||||
expect(row.created_at).not.toBeNull();
|
||||
const raw = testDb.prepare('SELECT * FROM users WHERE id = ?').get(row.id);
|
||||
const raw = await findRow(t, Users, { id: row.id });
|
||||
expect(raw).toMatchObject({ username: 'localuser', email: 'local@example.com', role: 'user', login_count: 0 });
|
||||
});
|
||||
|
||||
@@ -255,8 +259,10 @@ describe('UsersRepository', () => {
|
||||
first_seen_version: '1.2.3',
|
||||
});
|
||||
|
||||
// test-sql-allow: reads the table directly, so no EntityManager can flush the pending entity first.
|
||||
const pendingRow = testDb.prepare('SELECT * FROM users WHERE username = ?').get('pending-unflushed');
|
||||
expect(pendingRow).toBeUndefined(); // insertUser must not have flushed it
|
||||
// test-sql-allow: reads the table directly, so no EntityManager can flush the pending entity first.
|
||||
const freshRow = testDb.prepare('SELECT * FROM users WHERE username = ?').get('freshuser');
|
||||
expect(freshRow).toBeDefined(); // but the intended row is there
|
||||
|
||||
@@ -287,7 +293,7 @@ describe('UsersRepository', () => {
|
||||
|
||||
it('USERSREPO-019: a guest with a matching email is not returned', async () => {
|
||||
const { user } = createUser(testDb, { email: 'guest2@example.com' });
|
||||
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { is_guest: 1 });
|
||||
expect(await users.findByEmailCI('guest2@example.com')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -316,7 +322,7 @@ describe('UsersRepository', () => {
|
||||
|
||||
it('USERSREPO-018d: a guest with a matching email is not returned', async () => {
|
||||
const { user } = createUser(testDb, { email: 'guest2b@example.com' });
|
||||
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { is_guest: 1 });
|
||||
expect(await users.findByEmailLoweredBind('guest2b@example.com')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -348,7 +354,7 @@ describe('UsersRepository', () => {
|
||||
|
||||
it('USERSREPO-057C: a guest row IS returned — no guest filter, unlike findByEmailCI/findForPasswordReset', async () => {
|
||||
const { user } = createUser(testDb, { email: 'demoguest@example.com' });
|
||||
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { is_guest: 1 });
|
||||
const row = await users.findByEmailExact('demoguest@example.com');
|
||||
expect(row?.id).toBe(user.id);
|
||||
});
|
||||
@@ -357,25 +363,25 @@ describe('UsersRepository', () => {
|
||||
describe('touchLastLogin (AU13/AU34/AU35/PK12/O16)', () => {
|
||||
it('USERSREPO-020: bumps login_count by exactly 1 and stamps last_login, in one statement', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { login_count: 5 });
|
||||
await users.touchLastLogin(user.id);
|
||||
const row = testDb.prepare('SELECT login_count, last_login FROM users WHERE id = ?').get(user.id) as { login_count: number; last_login: string };
|
||||
const row = await findRow(t, Users, { id: user.id }) as { login_count: number; last_login: string };
|
||||
expect(row.login_count).toBe(6);
|
||||
expect(row.last_login).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it('USERSREPO-021: two concurrent calls both land — +2, never +1 (no read-modify-write)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET login_count = 10 WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { login_count: 10 });
|
||||
await Promise.all([users.touchLastLogin(user.id), users.touchLastLogin(user.id)]);
|
||||
const row = testDb.prepare('SELECT login_count FROM users WHERE id = ?').get(user.id) as { login_count: number };
|
||||
const row = await findRow(t, Users, { id: user.id }) as { login_count: number };
|
||||
expect(row.login_count).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
it('USERSREPO-022: findMeRow (AU14) reads the /api/auth/me projection', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET avatar = ?, mfa_enabled = 1, must_change_password = 1 WHERE id = ?').run('pic.png', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { avatar: 'pic.png', mfa_enabled: 1, must_change_password: 1 });
|
||||
const row = await users.findMeRow(user.id);
|
||||
expect(row).toMatchObject({ id: user.id, username: user.username, email: user.email, role: 'user', avatar: 'pic.png', mfa_enabled: 1, must_change_password: 1 });
|
||||
expect(row?.created_at).not.toBeNull();
|
||||
@@ -384,7 +390,7 @@ describe('UsersRepository', () => {
|
||||
|
||||
it('USERSREPO-022b: findMeRow — every nullable column comes back null, not undefined, when NULL in the row (coverage: rule 16)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET avatar = NULL, oidc_issuer = NULL, created_at = NULL, mfa_enabled = NULL, must_change_password = NULL WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { avatar: null, oidc_issuer: null, created_at: null, mfa_enabled: null, must_change_password: null });
|
||||
const row = await users.findMeRow(user.id);
|
||||
expect(row?.avatar).toBeNull();
|
||||
expect(row?.oidc_issuer).toBeNull();
|
||||
@@ -395,7 +401,7 @@ describe('UsersRepository', () => {
|
||||
|
||||
it('USERSREPO-023: getPasswordHashAndVersion (AU15) reads both columns together', async () => {
|
||||
const { user, password: _password } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET password_version = 2 WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { password_version: 2 });
|
||||
const row = await users.getPasswordHashAndVersion(user.id);
|
||||
expect(row?.password_hash).toBe(user.password_hash);
|
||||
expect(row?.password_version).toBe(2);
|
||||
@@ -407,9 +413,9 @@ describe('UsersRepository', () => {
|
||||
|
||||
it('USERSREPO-024: setPassword (AU16/AU43) writes hash+version, clears must_change_password, stamps updated_at', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET must_change_password = 1 WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { must_change_password: 1 });
|
||||
await users.setPassword(user.id, 'new-hash', 5);
|
||||
const row = testDb.prepare('SELECT password_hash, password_version, must_change_password, updated_at FROM users WHERE id = ?').get(user.id) as {
|
||||
const row = await findRow(t, Users, { id: user.id }) as {
|
||||
password_hash: string; password_version: number; must_change_password: number; updated_at: string;
|
||||
};
|
||||
expect(row.password_hash).toBe('new-hash');
|
||||
@@ -435,7 +441,7 @@ describe('UsersRepository', () => {
|
||||
it('USERSREPO-027: returns the row wrapper when the user exists', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
expect(await users.getMfaEnabled(user.id)).toEqual({ mfa_enabled: 0 });
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = 1 WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { mfa_enabled: 1 });
|
||||
expect(await users.getMfaEnabled(user.id)).toEqual({ mfa_enabled: 1 });
|
||||
});
|
||||
|
||||
@@ -445,7 +451,7 @@ describe('UsersRepository', () => {
|
||||
|
||||
it('USERSREPO-028b: mfa_enabled NULL on the row comes back null on the wrapper (coverage: rule 16)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = NULL WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { mfa_enabled: null });
|
||||
expect(await users.getMfaEnabled(user.id)).toEqual({ mfa_enabled: null });
|
||||
});
|
||||
});
|
||||
@@ -453,7 +459,7 @@ describe('UsersRepository', () => {
|
||||
it('USERSREPO-029: enableMfa (AU28) sets mfa_enabled=1, writes the already-encrypted secret and codes, stamps updated_at', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await users.enableMfa(user.id, 'encrypted-secret', '["hash1","hash2"]');
|
||||
const row = testDb.prepare('SELECT mfa_enabled, mfa_secret, mfa_backup_codes, updated_at FROM users WHERE id = ?').get(user.id) as {
|
||||
const row = await findRow(t, Users, { id: user.id }) as {
|
||||
mfa_enabled: number; mfa_secret: string; mfa_backup_codes: string; updated_at: string;
|
||||
};
|
||||
expect(row.mfa_enabled).toBe(1);
|
||||
@@ -475,7 +481,7 @@ describe('UsersRepository', () => {
|
||||
it('USERSREPO-031: sees a raw UPDATE on the same id in the same request (disableIdentityMap regression)', async () => {
|
||||
const { user } = createUser(testDb, { username: 'before' });
|
||||
await users.findById(user.id); // populate the identity map
|
||||
testDb.prepare('UPDATE users SET username = ? WHERE id = ?').run('after', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { username: 'after' });
|
||||
const { value, queries } = await withQueryCount(() => users.findById(user.id));
|
||||
expect(value?.username).toBe('after');
|
||||
expect(queries).toBe(1);
|
||||
@@ -484,9 +490,9 @@ describe('UsersRepository', () => {
|
||||
|
||||
it('USERSREPO-032: disableMfa (AU31) clears mfa_enabled/secret/codes, stamps updated_at', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ? WHERE id = ?').run('s', 'c', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 's', mfa_backup_codes: 'c' });
|
||||
await users.disableMfa(user.id);
|
||||
const row = testDb.prepare('SELECT mfa_enabled, mfa_secret, mfa_backup_codes, updated_at FROM users WHERE id = ?').get(user.id) as {
|
||||
const row = await findRow(t, Users, { id: user.id }) as {
|
||||
mfa_enabled: number; mfa_secret: string | null; mfa_backup_codes: string | null; updated_at: string;
|
||||
};
|
||||
expect(row.mfa_enabled).toBe(0);
|
||||
@@ -498,18 +504,18 @@ describe('UsersRepository', () => {
|
||||
describe('setBackupCodesAndTouch (AU33) vs setBackupCodes (AU44) — one stamps updated_at, the other does not', () => {
|
||||
it('USERSREPO-033: setBackupCodesAndTouch writes codes and stamps updated_at', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("UPDATE users SET updated_at = '2020-01-01 00:00:00' WHERE id = ?").run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { updated_at: '2020-01-01 00:00:00' });
|
||||
await users.setBackupCodesAndTouch(user.id, '["a"]');
|
||||
const row = testDb.prepare('SELECT mfa_backup_codes, updated_at FROM users WHERE id = ?').get(user.id) as { mfa_backup_codes: string; updated_at: string };
|
||||
const row = await findRow(t, Users, { id: user.id }) as { mfa_backup_codes: string; updated_at: string };
|
||||
expect(row.mfa_backup_codes).toBe('["a"]');
|
||||
expect(row.updated_at).not.toBe('2020-01-01 00:00:00');
|
||||
});
|
||||
|
||||
it('USERSREPO-034: setBackupCodes writes codes WITHOUT touching updated_at', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("UPDATE users SET updated_at = '2020-01-01 00:00:00' WHERE id = ?").run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { updated_at: '2020-01-01 00:00:00' });
|
||||
await users.setBackupCodes(user.id, '["b"]');
|
||||
const row = testDb.prepare('SELECT mfa_backup_codes, updated_at FROM users WHERE id = ?').get(user.id) as { mfa_backup_codes: string; updated_at: string };
|
||||
const row = await findRow(t, Users, { id: user.id }) as { mfa_backup_codes: string; updated_at: string };
|
||||
expect(row.mfa_backup_codes).toBe('["b"]');
|
||||
expect(row.updated_at).toBe('2020-01-01 00:00:00');
|
||||
});
|
||||
@@ -525,13 +531,13 @@ describe('UsersRepository', () => {
|
||||
|
||||
it('USERSREPO-036: excludes a guest', async () => {
|
||||
const { user } = createUser(testDb, { email: 'guest3@example.com' });
|
||||
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { is_guest: 1 });
|
||||
expect(await users.findForPasswordReset('guest3@example.com')).toBeNull();
|
||||
});
|
||||
|
||||
it('USERSREPO-036b: oidc_sub NULL on the row comes back null, not undefined (coverage: rule 16)', async () => {
|
||||
const { user } = createUser(testDb, { email: 'reset-null@example.com' });
|
||||
testDb.prepare('UPDATE users SET oidc_sub = NULL WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { oidc_sub: null });
|
||||
const row = await users.findForPasswordReset('reset-null@example.com');
|
||||
expect(row?.oidc_sub).toBeNull();
|
||||
});
|
||||
@@ -539,14 +545,14 @@ describe('UsersRepository', () => {
|
||||
|
||||
it('USERSREPO-037: findResetTarget (AU40) reads the reset-branch projection', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?, password_version = 4 WHERE id = ?').run('s', 'c', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 's', mfa_backup_codes: 'c', password_version: 4 });
|
||||
const row = await users.findResetTarget(user.id);
|
||||
expect(row).toEqual({ id: user.id, email: user.email, mfa_enabled: 1, mfa_secret: 's', mfa_backup_codes: 'c', password_version: 4 });
|
||||
});
|
||||
|
||||
it('USERSREPO-037b: findResetTarget — mfa_enabled/mfa_secret/mfa_backup_codes NULL come back null, not undefined (coverage: rule 16)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = NULL, mfa_secret = NULL, mfa_backup_codes = NULL WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { mfa_enabled: null, mfa_secret: null, mfa_backup_codes: null });
|
||||
const row = await users.findResetTarget(user.id);
|
||||
expect(row?.mfa_enabled).toBeNull();
|
||||
expect(row?.mfa_secret).toBeNull();
|
||||
@@ -571,7 +577,7 @@ describe('UsersRepository', () => {
|
||||
describe('findByOidcIdentity (O3)', () => {
|
||||
it('USERSREPO-040: matches on the (sub, issuer) pair', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run('sub-1', 'https://idp.example.com', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { oidc_sub: 'sub-1', oidc_issuer: 'https://idp.example.com' });
|
||||
const row = await users.findByOidcIdentity('sub-1', 'https://idp.example.com');
|
||||
expect(row?.id).toBe(user.id);
|
||||
expect(await users.findByOidcIdentity('sub-1', 'https://other-idp.example.com')).toBeNull();
|
||||
@@ -580,9 +586,9 @@ describe('UsersRepository', () => {
|
||||
|
||||
it('USERSREPO-041: linkOidcIdentity (O5/O6) writes sub+issuer WITHOUT touching updated_at', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("UPDATE users SET updated_at = '2020-01-01 00:00:00' WHERE id = ?").run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { updated_at: '2020-01-01 00:00:00' });
|
||||
await users.linkOidcIdentity(user.id, 'sub-2', 'https://idp2.example.com');
|
||||
const row = testDb.prepare('SELECT oidc_sub, oidc_issuer, updated_at FROM users WHERE id = ?').get(user.id) as {
|
||||
const row = await findRow(t, Users, { id: user.id }) as {
|
||||
oidc_sub: string; oidc_issuer: string; updated_at: string;
|
||||
};
|
||||
expect(row.oidc_sub).toBe('sub-2');
|
||||
@@ -592,18 +598,18 @@ describe('UsersRepository', () => {
|
||||
|
||||
it('USERSREPO-042: setRole (O8) writes role WITHOUT touching updated_at', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("UPDATE users SET updated_at = '2020-01-01 00:00:00' WHERE id = ?").run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { updated_at: '2020-01-01 00:00:00' });
|
||||
await users.setRole(user.id, 'admin');
|
||||
const row = testDb.prepare('SELECT role, updated_at FROM users WHERE id = ?').get(user.id) as { role: string; updated_at: string };
|
||||
const row = await findRow(t, Users, { id: user.id }) as { role: string; updated_at: string };
|
||||
expect(row.role).toBe('admin');
|
||||
expect(row.updated_at).toBe('2020-01-01 00:00:00');
|
||||
});
|
||||
|
||||
it('USERSREPO-043: setAvatarRaw (O9) writes avatar WITHOUT touching updated_at, unlike setAvatar', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("UPDATE users SET updated_at = '2020-01-01 00:00:00' WHERE id = ?").run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { updated_at: '2020-01-01 00:00:00' });
|
||||
await users.setAvatarRaw(user.id, 'https://idp.example.com/pic.png');
|
||||
const row = testDb.prepare('SELECT avatar, updated_at FROM users WHERE id = ?').get(user.id) as { avatar: string; updated_at: string };
|
||||
const row = await findRow(t, Users, { id: user.id }) as { avatar: string; updated_at: string };
|
||||
expect(row.avatar).toBe('https://idp.example.com/pic.png');
|
||||
expect(row.updated_at).toBe('2020-01-01 00:00:00');
|
||||
});
|
||||
@@ -614,13 +620,13 @@ describe('UsersRepository', () => {
|
||||
const { user: b } = createUser(testDb, { username: 'other' });
|
||||
expect(await users.findIdByUsernameCI('dupename', b.id)).toBe(a.id);
|
||||
expect(await users.findIdByUsernameCI('dupename', a.id)).toBeNull(); // excludes self
|
||||
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(a.id);
|
||||
await updateRows(t, Users, { id: a.id }, { is_guest: 1 });
|
||||
expect(await users.findIdByUsernameCI('dupename', b.id)).toBeNull(); // guest excluded
|
||||
});
|
||||
|
||||
it('USERSREPO-045: findIdByUsernameCIAny has no exclude and no guest filter', async () => {
|
||||
const { user } = createUser(testDb, { username: 'AnyName' });
|
||||
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { is_guest: 1 });
|
||||
expect(await users.findIdByUsernameCIAny('anyname')).toBe(user.id); // a guest still matches here
|
||||
});
|
||||
|
||||
@@ -643,8 +649,7 @@ describe('UsersRepository', () => {
|
||||
|
||||
it('USERSREPO-046: getApiKeyColumns (UP1) reads role + the four key columns', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
testDb.prepare('UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, amap_api_key = ? WHERE id = ?')
|
||||
.run('m', 'o', 'u', 'a', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { maps_api_key: 'm', openweather_api_key: 'o', unsplash_api_key: 'u', amap_api_key: 'a' });
|
||||
expect(await users.getApiKeyColumns(user.id)).toEqual({ role: 'admin', maps_api_key: 'm', openweather_api_key: 'o', unsplash_api_key: 'u', amap_api_key: 'a' });
|
||||
});
|
||||
|
||||
@@ -658,7 +663,7 @@ describe('UsersRepository', () => {
|
||||
it('USERSREPO-047: updateMapsKey (UP2) writes maps_api_key + updated_at', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await users.updateMapsKey(user.id, 'encrypted');
|
||||
const row = testDb.prepare('SELECT maps_api_key, updated_at FROM users WHERE id = ?').get(user.id) as { maps_api_key: string; updated_at: string };
|
||||
const row = await findRow(t, Users, { id: user.id }) as { maps_api_key: string; updated_at: string };
|
||||
expect(row.maps_api_key).toBe('encrypted');
|
||||
expect(row.updated_at).toMatch(/^\d{4}-\d{2}-\d{2}/);
|
||||
});
|
||||
@@ -666,14 +671,14 @@ describe('UsersRepository', () => {
|
||||
it('USERSREPO-048: updateApiKeys (UP3) writes all four columns + updated_at in one statement', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await users.updateApiKeys(user.id, { maps_api_key: 'm2', openweather_api_key: 'o2', unsplash_api_key: 'u2', amap_api_key: null });
|
||||
const row = testDb.prepare('SELECT maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key, updated_at FROM users WHERE id = ?').get(user.id) as Record<string, unknown>;
|
||||
const row = await findRow(t, Users, { id: user.id }) as Record<string, unknown>;
|
||||
expect(row).toMatchObject({ maps_api_key: 'm2', openweather_api_key: 'o2', unsplash_api_key: 'u2', amap_api_key: null });
|
||||
expect(row.updated_at).toMatch(/^\d{4}-\d{2}-\d{2}/);
|
||||
});
|
||||
|
||||
it('USERSREPO-049: findProfileWithKeys (UP4/UP8) reads the profile-with-keys projection', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET maps_api_key = ?, mfa_enabled = 1 WHERE id = ?').run('m', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { maps_api_key: 'm', mfa_enabled: 1 });
|
||||
const row = await users.findProfileWithKeys(user.id);
|
||||
expect(row).toMatchObject({ id: user.id, username: user.username, email: user.email, role: 'user', maps_api_key: 'm', mfa_enabled: 1 });
|
||||
});
|
||||
@@ -681,7 +686,7 @@ describe('UsersRepository', () => {
|
||||
it('USERSREPO-049b: findProfileWithKeys returns null for a missing user; every nullable column comes back null (coverage: rule 16)', async () => {
|
||||
expect(await users.findProfileWithKeys(999999)).toBeNull();
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = NULL WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { mfa_enabled: null });
|
||||
const row = await users.findProfileWithKeys(user.id);
|
||||
expect(row).toMatchObject({
|
||||
maps_api_key: null, openweather_api_key: null, unsplash_api_key: null, amap_api_key: null, avatar: null, mfa_enabled: null,
|
||||
@@ -708,7 +713,7 @@ describe('UsersRepository', () => {
|
||||
it('USERSREPO-051: patchProfile (UP7) writes only the given bounded columns + updated_at, in one statement', async () => {
|
||||
const { user } = createUser(testDb, { username: 'patchme', email: 'patchme@example.com' });
|
||||
await users.patchProfile(user.id, { username: 'patched', maps_api_key: 'newkey' });
|
||||
const row = testDb.prepare('SELECT username, email, maps_api_key, updated_at FROM users WHERE id = ?').get(user.id) as Record<string, unknown>;
|
||||
const row = await findRow(t, Users, { id: user.id }) as Record<string, unknown>;
|
||||
expect(row.username).toBe('patched');
|
||||
expect(row.email).toBe('patchme@example.com'); // untouched — not in `changes`
|
||||
expect(row.maps_api_key).toBe('newkey');
|
||||
@@ -719,20 +724,20 @@ describe('UsersRepository', () => {
|
||||
it('USERSREPO-052: getAvatar reads the avatar column', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
expect(await users.getAvatar(user.id)).toBeNull();
|
||||
testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('pic.png', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { avatar: 'pic.png' });
|
||||
expect(await users.getAvatar(user.id)).toBe('pic.png');
|
||||
});
|
||||
|
||||
it('USERSREPO-053: setAvatar writes avatar + updated_at (both a filename and NULL for delete)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await users.setAvatar(user.id, 'new.png');
|
||||
let row = testDb.prepare('SELECT avatar, updated_at FROM users WHERE id = ?').get(user.id) as { avatar: string; updated_at: string };
|
||||
let row = await findRow(t, Users, { id: user.id }) as { avatar: string; updated_at: string };
|
||||
expect(row.avatar).toBe('new.png');
|
||||
expect(row.updated_at).toMatch(/^\d{4}-\d{2}-\d{2}/);
|
||||
|
||||
testDb.prepare("UPDATE users SET updated_at = '2020-01-01 00:00:00' WHERE id = ?").run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { updated_at: '2020-01-01 00:00:00' });
|
||||
await users.setAvatar(user.id, null);
|
||||
row = testDb.prepare('SELECT avatar, updated_at FROM users WHERE id = ?').get(user.id) as { avatar: string | null; updated_at: string };
|
||||
row = await findRow(t, Users, { id: user.id }) as { avatar: string | null; updated_at: string };
|
||||
expect(row.avatar).toBeNull();
|
||||
expect(row.updated_at).not.toBe('2020-01-01 00:00:00');
|
||||
});
|
||||
@@ -740,7 +745,7 @@ describe('UsersRepository', () => {
|
||||
|
||||
it('USERSREPO-054: findProfileBasic (UP11) reads id/username/email/role/avatar', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('a.png', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { avatar: 'a.png' });
|
||||
expect(await users.findProfileBasic(user.id)).toEqual({ id: user.id, username: user.username, email: user.email, role: 'user', avatar: 'a.png' });
|
||||
});
|
||||
|
||||
@@ -756,7 +761,7 @@ describe('UsersRepository', () => {
|
||||
const { user: bravo } = createUser(testDb, { username: 'bravo' });
|
||||
const { user: alpha } = createUser(testDb, { username: 'alpha' });
|
||||
const { user: guest } = createUser(testDb, { username: 'zzz-guest' });
|
||||
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id);
|
||||
await updateRows(t, Users, { id: guest.id }, { is_guest: 1 });
|
||||
const rows = await users.listOthersNonGuest(me.id);
|
||||
expect(rows.map((r) => r.id)).toEqual([alpha.id, bravo.id]);
|
||||
});
|
||||
@@ -764,7 +769,7 @@ describe('UsersRepository', () => {
|
||||
|
||||
it('USERSREPO-056: getRoleAndWeatherKey (UP15) reads role + openweather_api_key', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
testDb.prepare('UPDATE users SET openweather_api_key = ? WHERE id = ?').run('w', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { openweather_api_key: 'w' });
|
||||
expect(await users.getRoleAndWeatherKey(user.id)).toEqual({ role: 'admin', openweather_api_key: 'w' });
|
||||
});
|
||||
|
||||
@@ -777,7 +782,7 @@ describe('UsersRepository', () => {
|
||||
it('USERSREPO-058: deleteById (UC11) deletes the row', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await users.deleteById(user.id);
|
||||
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(user.id)).toBeUndefined();
|
||||
expect(await findRow(t, Users, { id: user.id })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -791,7 +796,7 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => {
|
||||
describe('findOwnerSummary (TM3)', () => {
|
||||
it('USERSREPO-059: prefers display_name over username, matching the legacy COALESCE', async () => {
|
||||
const { user } = createUser(testDb, { username: 'raw-handle' });
|
||||
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Displayed Name', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { display_name: 'Displayed Name' });
|
||||
expect(await users.findOwnerSummary(user.id)).toEqual({
|
||||
id: user.id, username: 'Displayed Name', email: user.email, avatar: null,
|
||||
});
|
||||
@@ -807,7 +812,7 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => {
|
||||
|
||||
it('USERSREPO-061: a NULL avatar comes back null, not undefined (rule 16)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('me.png', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { avatar: 'me.png' });
|
||||
expect((await users.findOwnerSummary(user.id))?.avatar).toBe('me.png');
|
||||
});
|
||||
|
||||
@@ -821,7 +826,7 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => {
|
||||
it('USERSREPO-062 (fresh after a raw UPDATE, not D-shape): a display_name written after an unrelated identity-map read is visible in the FIRST wider projection', async () => {
|
||||
const { user } = createUser(testDb, { username: 'fresh-owner' });
|
||||
await t.repo(Users).find({}, { disableIdentityMap: false }); // populate the identity map with an unrelated read
|
||||
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Fresh Name', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { display_name: 'Fresh Name' });
|
||||
expect(await users.findOwnerSummary(user.id)).toEqual({
|
||||
id: user.id, username: 'Fresh Name', email: user.email, avatar: null,
|
||||
});
|
||||
@@ -835,6 +840,7 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => {
|
||||
// literal-object assertion below (rule 19; #1362 is security-relevant,
|
||||
// so this is one of the L7 reads the fix wave covers).
|
||||
const legacyInvitable = (identifier: string) =>
|
||||
// test-sql-allow: legacy statement run raw as the parity oracle for the repository read.
|
||||
testDb.prepare(
|
||||
'SELECT id, username, email, avatar FROM users WHERE (email = ? OR username = ?) AND COALESCE(is_guest, 0) = 0',
|
||||
).get(identifier, identifier) ?? null;
|
||||
@@ -857,7 +863,7 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => {
|
||||
|
||||
it('USERSREPO-064: excludes guests — a trip-scoped guest can never be re-invited through the box', async () => {
|
||||
const { user: guest } = createUser(testDb, { email: 'guest-look@example.test', username: 'guest-handle' });
|
||||
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id);
|
||||
await updateRows(t, Users, { id: guest.id }, { is_guest: 1 });
|
||||
expect(await users.findInvitableByEmailOrUsername('guest-look@example.test')).toBeNull();
|
||||
expect(await users.findInvitableByEmailOrUsername('guest-handle')).toBeNull();
|
||||
});
|
||||
@@ -879,7 +885,7 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => {
|
||||
it('USERSREPO-072 (fresh after a raw UPDATE, not D-shape): an is_guest flip written after an unrelated identity-map read is visible in the FIRST wider projection', async () => {
|
||||
const { user } = createUser(testDb, { email: 'flip@example.test', username: 'flip-handle' });
|
||||
await t.repo(Users).find({}, { disableIdentityMap: false }); // populate the identity map with an unrelated read
|
||||
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { is_guest: 1 });
|
||||
expect(await users.findInvitableByEmailOrUsername('flip@example.test')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -888,7 +894,7 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => {
|
||||
it('USERSREPO-066: reads id/email/is_guest; a missing user is null', async () => {
|
||||
const { user } = createUser(testDb, { email: 'target@example.test' });
|
||||
expect(await users.findIdEmailGuest(user.id)).toEqual({ id: user.id, email: 'target@example.test', is_guest: 0 });
|
||||
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { is_guest: 1 });
|
||||
expect(await users.findIdEmailGuest(user.id)).toEqual({ id: user.id, email: 'target@example.test', is_guest: 1 });
|
||||
expect(await users.findIdEmailGuest(999999)).toBeNull();
|
||||
});
|
||||
@@ -906,7 +912,7 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => {
|
||||
// (`is_guest`) — a PK-only `findOne` on `id` alone, the exact shape
|
||||
// `findIdEmailGuest` itself uses.
|
||||
await t.repo(Users).findOne({ id: user.id }, { disableIdentityMap: false });
|
||||
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { is_guest: 1 });
|
||||
|
||||
const { value, queries } = await withQueryCount(() => users.findIdEmailGuest(user.id));
|
||||
expect(value).toEqual({ id: user.id, email: 'guest-gate@example.test', is_guest: 1 });
|
||||
@@ -917,8 +923,8 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => {
|
||||
describe('insertGuest (TM16, security-sensitive)', () => {
|
||||
it('USERSREPO-067: writes the fixed literal columns and the given display_name; returns the generated id', async () => {
|
||||
const id = await users.insertGuest({ username: 'guest-abc', email: 'guest-abc@guests.invalid', display_name: 'Ida' });
|
||||
const row = testDb.prepare('SELECT username, email, password_hash, role, is_guest, display_name FROM users WHERE id = ?').get(id);
|
||||
expect(row).toEqual({
|
||||
const row = await findRow(t, Users, { id });
|
||||
expect(row).toMatchObject({
|
||||
username: 'guest-abc', email: 'guest-abc@guests.invalid', password_hash: '', role: 'user', is_guest: 1, display_name: 'Ida',
|
||||
});
|
||||
});
|
||||
@@ -928,28 +934,28 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => {
|
||||
it('USERSREPO-068: renameGuest updates display_name + updated_at only for an is_guest = 1 row', async () => {
|
||||
const id = await users.insertGuest({ username: 'guest-rn', email: 'guest-rn@guests.invalid', display_name: 'Old' });
|
||||
await users.renameGuest(id, 'New Name');
|
||||
const row = testDb.prepare('SELECT display_name, updated_at FROM users WHERE id = ?').get(id) as { display_name: string; updated_at: string | null };
|
||||
const row = await findRow(t, Users, { id }) as { display_name: string; updated_at: string | null };
|
||||
expect(row.display_name).toBe('New Name');
|
||||
expect(row.updated_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('USERSREPO-069: renameGuest is a no-op on a real (non-guest) user id — the belt-and-braces predicate, pinned', async () => {
|
||||
const { user } = createUser(testDb, { username: 'real-user' });
|
||||
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Untouched', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { display_name: 'Untouched' });
|
||||
await users.renameGuest(user.id, 'Attempted Rename');
|
||||
expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(user.id) as { display_name: string }).display_name).toBe('Untouched');
|
||||
expect((await findRow(t, Users, { id: user.id }) as { display_name: string }).display_name).toBe('Untouched');
|
||||
});
|
||||
|
||||
it('USERSREPO-070: deleteGuest removes only an is_guest = 1 row', async () => {
|
||||
const id = await users.insertGuest({ username: 'guest-del', email: 'guest-del@guests.invalid', display_name: 'Gone' });
|
||||
await users.deleteGuest(id);
|
||||
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(id)).toBeUndefined();
|
||||
expect(await findRow(t, Users, { id })).toBeNull();
|
||||
});
|
||||
|
||||
it('USERSREPO-071: deleteGuest is a no-op on a real (non-guest) user id — the belt-and-braces predicate, pinned', async () => {
|
||||
const { user } = createUser(testDb, { username: 'real-user-2' });
|
||||
await users.deleteGuest(user.id);
|
||||
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(user.id)).toBeDefined();
|
||||
expect(await findRow(t, Users, { id: user.id })).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -960,8 +966,8 @@ describe('UsersRepository — TripMembersService (Plan 3c Task 6)', () => {
|
||||
describe('UsersRepository — feed tokens (Plan 3d Task 5, FD5-FD8/FD10)', () => {
|
||||
it('USERSREPO-072: getFeedToken reads the stored token, matching SELECT feed_token FROM users WHERE id = ?', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run('tok-abc', user.id);
|
||||
const legacy = testDb.prepare('SELECT feed_token FROM users WHERE id = ?').get(user.id) as { feed_token: string | null };
|
||||
await updateRows(t, Users, { id: user.id }, { feed_token: 'tok-abc' });
|
||||
const legacy = await findRow(t, Users, { id: user.id }) as { feed_token: string | null };
|
||||
expect(await users.getFeedToken(user.id)).toBe(legacy.feed_token);
|
||||
expect(await users.getFeedToken(user.id)).toBe('tok-abc');
|
||||
});
|
||||
@@ -975,19 +981,20 @@ describe('UsersRepository — feed tokens (Plan 3d Task 5, FD5-FD8/FD10)', () =>
|
||||
it('USERSREPO-074: setFeedToken (FD6/FD7) writes a fresh token verbatim', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await users.setFeedToken(user.id, 'tok-fresh');
|
||||
expect((testDb.prepare('SELECT feed_token FROM users WHERE id = ?').get(user.id) as { feed_token: string }).feed_token).toBe('tok-fresh');
|
||||
expect((await findRow(t, Users, { id: user.id }) as { feed_token: string }).feed_token).toBe('tok-fresh');
|
||||
});
|
||||
|
||||
it('USERSREPO-075: setFeedToken (FD8) clears the column to NULL when the token is null', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run('tok-old', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { feed_token: 'tok-old' });
|
||||
await users.setFeedToken(user.id, null);
|
||||
expect((testDb.prepare('SELECT feed_token FROM users WHERE id = ?').get(user.id) as { feed_token: string | null }).feed_token).toBeNull();
|
||||
expect((await findRow(t, Users, { id: user.id }) as { feed_token: string | null }).feed_token).toBeNull();
|
||||
});
|
||||
|
||||
it('USERSREPO-076: findIdAndUsernameByFeedToken (FD10) — the anonymous credential lookup, exact projection', async () => {
|
||||
const { user } = createUser(testDb, { username: 'feed-holder' });
|
||||
testDb.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run('tok-holder', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { feed_token: 'tok-holder' });
|
||||
// test-sql-allow: legacy statement run raw as the parity oracle for the repository read.
|
||||
const legacy = testDb.prepare('SELECT id, username FROM users WHERE feed_token = ?').get('tok-holder');
|
||||
expect(await users.findIdAndUsernameByFeedToken('tok-holder')).toEqual(legacy);
|
||||
expect(await users.findIdAndUsernameByFeedToken('tok-holder')).toEqual({ id: user.id, username: 'feed-holder' });
|
||||
@@ -1003,7 +1010,7 @@ describe('UsersRepository — feed tokens (Plan 3d Task 5, FD5-FD8/FD10)', () =>
|
||||
|
||||
it('M1: getImmichAutoUpload / getSynologyUsername / findUsernameEmail return their column, and the missing-user branch, honestly', async () => {
|
||||
const { user } = createUser(testDb, { username: 'imm-user', email: 'imm@example.com' });
|
||||
testDb.prepare('UPDATE users SET immich_auto_upload = 1, synology_username = ? WHERE id = ?').run('syno-login', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { immich_auto_upload: 1, synology_username: 'syno-login' });
|
||||
|
||||
expect(await users.getImmichAutoUpload(user.id)).toBe(1);
|
||||
expect(await users.getImmichAutoUpload(999999)).toBeNull();
|
||||
@@ -1017,9 +1024,7 @@ describe('UsersRepository — feed tokens (Plan 3d Task 5, FD5-FD8/FD10)', () =>
|
||||
|
||||
it('M1b: getImmichCredentials / getImmichConnectionPrefs carry immich_allow_insecure_tls (#2475), and the missing-user branch', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb
|
||||
.prepare('UPDATE users SET immich_url = ?, immich_api_key = ?, immich_auto_upload = 1, immich_allow_insecure_tls = 1 WHERE id = ?')
|
||||
.run('https://immich.test', 'key-1', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { immich_url: 'https://immich.test', immich_api_key: 'key-1', immich_auto_upload: 1, immich_allow_insecure_tls: 1 });
|
||||
|
||||
expect(await users.getImmichCredentials(user.id)).toEqual({
|
||||
immich_url: 'https://immich.test',
|
||||
@@ -1046,16 +1051,17 @@ describe('UsersRepository — admin (AD1-AD17) read methods, full-key parity', (
|
||||
let plain: TestUser;
|
||||
let guest: TestUser;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
admin = createAdmin(testDb, { username: 'ad-admin', email: 'ad-admin@example.com' }).user;
|
||||
plain = createUser(testDb, { username: 'ad-plain', email: 'ad-plain@example.com' }).user;
|
||||
const { user: guestRow } = createUser(testDb, { username: 'ad-guest', email: 'ad-guest@example.com' });
|
||||
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guestRow.id);
|
||||
await updateRows(t, Users, { id: guestRow.id }, { is_guest: 1 });
|
||||
guest = { ...guestRow, } as TestUser;
|
||||
});
|
||||
|
||||
it('USERSREPO-078 (AD1): listForAdmin matches SELECT id, username, email, role, avatar, created_at, updated_at, last_login FROM users WHERE COALESCE(is_guest, 0) = 0 ORDER BY created_at DESC, and excludes the guest row', async () => {
|
||||
const legacy = testDb
|
||||
// test-sql-allow: legacy statement run raw as the parity oracle for the repository read.
|
||||
.prepare('SELECT id, username, email, role, avatar, created_at, updated_at, last_login FROM users WHERE COALESCE(is_guest, 0) = 0 ORDER BY created_at DESC')
|
||||
.all();
|
||||
const rows = await users.listForAdmin();
|
||||
@@ -1075,7 +1081,7 @@ describe('UsersRepository — admin (AD1-AD17) read methods, full-key parity', (
|
||||
|
||||
it('USERSREPO-080 (AD4/AD5): insertAdminCreatedUser stores exactly the four named columns, every other column left to the entity default, then findAdminSummary re-selects it', async () => {
|
||||
const id = await users.insertAdminCreatedUser({ username: 'ad-new', email: 'ad-new@example.com', password_hash: 'hash-x', role: 'user' });
|
||||
const legacyRow = testDb.prepare('SELECT * FROM users WHERE id = ?').get(id) as Record<string, unknown>;
|
||||
const legacyRow = await findRow(t, Users, { id }) as Record<string, unknown>;
|
||||
expect(legacyRow.username).toBe('ad-new');
|
||||
expect(legacyRow.email).toBe('ad-new@example.com');
|
||||
expect(legacyRow.password_hash).toBe('hash-x');
|
||||
@@ -1087,12 +1093,14 @@ describe('UsersRepository — admin (AD1-AD17) read methods, full-key parity', (
|
||||
expect(legacyRow.login_count).toBe(0);
|
||||
expect(legacyRow.is_guest).toBe(0);
|
||||
|
||||
// test-sql-allow: legacy statement run raw as the parity oracle for the repository read.
|
||||
const legacySummary = testDb.prepare('SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?').get(id);
|
||||
expect(await users.findAdminSummary(id)).toEqual(legacySummary);
|
||||
});
|
||||
|
||||
it('USERSREPO-081 (AD14): findAdminSummary matches the byte-identical re-select text on an EXISTING (updated) row too', async () => {
|
||||
testDb.prepare('UPDATE users SET username = ?, role = ? WHERE id = ?').run('ad-plain-renamed', 'admin', plain.id);
|
||||
await updateRows(t, Users, { id: plain.id }, { username: 'ad-plain-renamed', role: 'admin' });
|
||||
// test-sql-allow: legacy statement run raw as the parity oracle for the repository read.
|
||||
const legacy = testDb.prepare('SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?').get(plain.id);
|
||||
expect(await users.findAdminSummary(plain.id)).toEqual(legacy);
|
||||
expect(await users.findAdminSummary(999999)).toBeNull();
|
||||
@@ -1110,12 +1118,12 @@ describe('UsersRepository — admin (AD1-AD17) read methods, full-key parity', (
|
||||
});
|
||||
|
||||
it('USERSREPO-083 (AD11): applyAdminEdit only touches the keys supplied (coalesceParam semantics) and always stamps updated_at', async () => {
|
||||
const before = testDb.prepare('SELECT email, updated_at FROM users WHERE id = ?').get(plain.id) as { email: string; updated_at: string };
|
||||
const before = await findRow(t, Users, { id: plain.id }) as { email: string; updated_at: string };
|
||||
// CURRENT_TIMESTAMP has 1-second resolution — wait past a tick so a
|
||||
// genuine "always stamped" pass is distinguishable from a no-op.
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
await users.applyAdminEdit(plain.id, { username: 'ad-plain-2' });
|
||||
const after = testDb.prepare('SELECT username, email, updated_at FROM users WHERE id = ?').get(plain.id) as { username: string; email: string; updated_at: string };
|
||||
const after = await findRow(t, Users, { id: plain.id }) as { username: string; email: string; updated_at: string };
|
||||
expect(after.username).toBe('ad-plain-2');
|
||||
// email was NOT in the patch, so it is unchanged — the coalesceParam
|
||||
// "new value wins when supplied, existing column wins when omitted" shape.
|
||||
@@ -1125,7 +1133,8 @@ describe('UsersRepository — admin (AD1-AD17) read methods, full-key parity', (
|
||||
});
|
||||
|
||||
it('USERSREPO-084 (AD16): findIdEmailMfaEnabled matches SELECT id, email, mfa_enabled FROM users WHERE id = ?', async () => {
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = 1 WHERE id = ?').run(admin.id);
|
||||
await updateRows(t, Users, { id: admin.id }, { mfa_enabled: 1 });
|
||||
// test-sql-allow: legacy statement run raw as the parity oracle for the repository read.
|
||||
const legacy = testDb.prepare('SELECT id, email, mfa_enabled FROM users WHERE id = ?').get(admin.id);
|
||||
expect(await users.findIdEmailMfaEnabled(admin.id)).toEqual(legacy);
|
||||
expect(await users.findIdEmailMfaEnabled(999999)).toBeNull();
|
||||
@@ -1138,41 +1147,43 @@ describe('UsersRepository — admin (AD1-AD17) read methods, full-key parity', (
|
||||
// the legacy statement's own text, and that the write stays one statement —
|
||||
// a read-then-write would be an interleaving window the legacy SQL never had.
|
||||
describe('UsersRepository — Immich settings write (IM4/IM5)', () => {
|
||||
const read = (id: number) =>
|
||||
testDb.prepare('SELECT immich_url, immich_api_key, immich_allow_insecure_tls FROM users WHERE id = ?').get(id) as {
|
||||
immich_url: string | null; immich_api_key: string | null; immich_allow_insecure_tls: number;
|
||||
const read = async (id: number) => {
|
||||
const row = await readUser(t, id);
|
||||
return {
|
||||
immich_url: row.immich_url, immich_api_key: row.immich_api_key, immich_allow_insecure_tls: row.immich_allow_insecure_tls,
|
||||
};
|
||||
};
|
||||
|
||||
it('USERSREPO-085 (IM4): setImmichSettings keeps the stored switch on a null value while the URL stays the same, and starts a new URL off', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET immich_url = ?, immich_api_key = ?, immich_allow_insecure_tls = 1 WHERE id = ?').run('https://nas.local', 'enc-old', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { immich_url: 'https://nas.local', immich_api_key: 'enc-old', immich_allow_insecure_tls: 1 });
|
||||
|
||||
// An older client that does not know the switch cannot clear it by saving.
|
||||
await users.setImmichSettings(user.id, 'https://nas.local', 'enc-new', null);
|
||||
expect(read(user.id)).toEqual({ immich_url: 'https://nas.local', immich_api_key: 'enc-new', immich_allow_insecure_tls: 1 });
|
||||
expect(await read(user.id)).toEqual({ immich_url: 'https://nas.local', immich_api_key: 'enc-new', immich_allow_insecure_tls: 1 });
|
||||
|
||||
// Sent explicitly, the value wins over the stored one.
|
||||
await users.setImmichSettings(user.id, 'https://nas.local', 'enc-new', 0);
|
||||
expect(read(user.id).immich_allow_insecure_tls).toBe(0);
|
||||
expect((await read(user.id)).immich_allow_insecure_tls).toBe(0);
|
||||
await users.setImmichSettings(user.id, 'https://nas.local', 'enc-new', 1);
|
||||
expect(read(user.id).immich_allow_insecure_tls).toBe(1);
|
||||
expect((await read(user.id)).immich_allow_insecure_tls).toBe(1);
|
||||
|
||||
// The switch trusts one server: another URL without it starts off ...
|
||||
await users.setImmichSettings(user.id, 'https://photos.example.com', 'enc-2', null);
|
||||
expect(read(user.id)).toEqual({ immich_url: 'https://photos.example.com', immich_api_key: 'enc-2', immich_allow_insecure_tls: 0 });
|
||||
expect(await read(user.id)).toEqual({ immich_url: 'https://photos.example.com', immich_api_key: 'enc-2', immich_allow_insecure_tls: 0 });
|
||||
// ... and with it, holds for that server.
|
||||
await users.setImmichSettings(user.id, 'https://other.example.com', 'enc-3', 1);
|
||||
expect(read(user.id)).toEqual({ immich_url: 'https://other.example.com', immich_api_key: 'enc-3', immich_allow_insecure_tls: 1 });
|
||||
expect(await read(user.id)).toEqual({ immich_url: 'https://other.example.com', immich_api_key: 'enc-3', immich_allow_insecure_tls: 1 });
|
||||
|
||||
// A first connection (no stored URL) without the switch starts off too.
|
||||
testDb.prepare('UPDATE users SET immich_url = NULL, immich_allow_insecure_tls = 1 WHERE id = ?').run(user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { immich_url: null, immich_allow_insecure_tls: 1 });
|
||||
await users.setImmichSettings(user.id, 'https://nas.local', 'enc-4', null);
|
||||
expect(read(user.id)).toEqual({ immich_url: 'https://nas.local', immich_api_key: 'enc-4', immich_allow_insecure_tls: 0 });
|
||||
expect(await read(user.id)).toEqual({ immich_url: 'https://nas.local', immich_api_key: 'enc-4', immich_allow_insecure_tls: 0 });
|
||||
});
|
||||
|
||||
it('USERSREPO-086 (IM4): setImmichSettings matches the legacy CASE statement across every (stored url, stored switch, new url, value) combination, in ONE statement', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const seed = testDb.prepare('UPDATE users SET immich_url = ?, immich_api_key = NULL, immich_allow_insecure_tls = ? WHERE id = ?');
|
||||
// test-sql-allow: legacy statement run raw as the parity oracle for the repository read.
|
||||
const legacy = testDb.prepare(
|
||||
`UPDATE users SET immich_url = ?, immich_api_key = ?,
|
||||
immich_allow_insecure_tls = CASE WHEN immich_url IS ? THEN COALESCE(?, immich_allow_insecure_tls) ELSE COALESCE(?, 0) END
|
||||
@@ -1182,14 +1193,14 @@ describe('UsersRepository — Immich settings write (IM4/IM5)', () => {
|
||||
for (const storedFlag of [0, 1]) {
|
||||
for (const newUrl of ['https://nas.local', 'https://photos.example.com']) {
|
||||
for (const value of [null, 0, 1]) {
|
||||
seed.run(storedUrl, storedFlag, user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { immich_url: storedUrl, immich_api_key: null, immich_allow_insecure_tls: storedFlag });
|
||||
legacy.run(newUrl, 'enc-key', newUrl, value, value, user.id);
|
||||
const expected = read(user.id);
|
||||
const expected = await read(user.id);
|
||||
|
||||
seed.run(storedUrl, storedFlag, user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { immich_url: storedUrl, immich_api_key: null, immich_allow_insecure_tls: storedFlag });
|
||||
const { queries } = await withQueryCount(() => users.setImmichSettings(user.id, newUrl, 'enc-key', value));
|
||||
expect(queries).toBe(1);
|
||||
expect(read(user.id)).toEqual(expected);
|
||||
expect(await read(user.id)).toEqual(expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1198,9 +1209,9 @@ describe('UsersRepository — Immich settings write (IM4/IM5)', () => {
|
||||
|
||||
it('USERSREPO-087 (IM5): clearImmichSettings nulls the URL, stores the key it is handed and always turns the switch off', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET immich_url = ?, immich_api_key = ?, immich_allow_insecure_tls = 1 WHERE id = ?').run('https://nas.local', 'enc-old', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { immich_url: 'https://nas.local', immich_api_key: 'enc-old', immich_allow_insecure_tls: 1 });
|
||||
await users.clearImmichSettings(user.id, null);
|
||||
expect(read(user.id)).toEqual({ immich_url: null, immich_api_key: null, immich_allow_insecure_tls: 0 });
|
||||
expect(await read(user.id)).toEqual({ immich_url: null, immich_api_key: null, immich_allow_insecure_tls: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user