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

260 lines
11 KiB
TypeScript

import { PasswordResetTokens } from '../../../../src/db/entities/PasswordResetTokens.entity';
import type { PasswordResetTokensRepository } from '../../../../src/db/repositories/PasswordResetTokens.repository';
import { dbNow } from '../../../../src/db/types';
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { createUser } from '../../../helpers/factories';
import { findRow, insertRow } from '../../../helpers/factories/rows';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createHash } from 'crypto';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let tokens: PasswordResetTokensRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
tokens = t.repo(PasswordResetTokens);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
function hash(raw: string): string {
return createHash('sha256').update(raw).digest('hex');
}
async function rawRow(id: number) {
const row = await findRow(t, PasswordResetTokens, { id });
if (!row) throw new Error(`no password reset token ${id}`);
return row;
}
async function seedToken(
userId: number,
tokenHash: string,
opts: { expires_at?: string; consumed_at?: string | null; created_ip?: string | null } = {},
): Promise<number> {
const expiresAt = opts.expires_at ?? new Date(Date.now() + 60 * 60 * 1000).toISOString();
return insertRow(t, PasswordResetTokens, {
user: userId,
token_hash: tokenHash,
expires_at: expiresAt,
consumed_at: opts.consumed_at ?? null,
created_ip: opts.created_ip ?? null,
});
}
describe('PasswordResetTokensRepository', () => {
describe('insertToken', () => {
it('PWDRESETREPO-001: writes the exact column set, hash-only (the raw token never appears)', async () => {
const { user } = createUser(testDb);
const tokenHash = hash('raw-token-value');
const expiresAt = dbNow(new Date(Date.now() + 60 * 60 * 1000));
await tokens.insertToken({
user_id: user.id,
token_hash: tokenHash,
expires_at: expiresAt,
created_ip: '1.2.3.4',
});
const row = await findRow(t, PasswordResetTokens, { token_hash: tokenHash });
expect(row).toMatchObject({
user_id: user.id,
token_hash: tokenHash,
expires_at: expiresAt,
consumed_at: null,
created_ip: '1.2.3.4',
});
});
it('PWDRESETREPO-002: writes a NULL created_ip', async () => {
const { user } = createUser(testDb);
const tokenHash = hash('no-ip-token');
await tokens.insertToken({ user_id: user.id, token_hash: tokenHash, expires_at: dbNow(), created_ip: null });
const row = await findRow(t, PasswordResetTokens, { token_hash: tokenHash });
expect(row?.created_ip).toBeNull();
});
});
describe('findByTokenHash', () => {
it('PWDRESETREPO-003: finds a live token by its hash', async () => {
const { user } = createUser(testDb);
const tokenHash = hash('lookup-me');
const id = await seedToken(user.id, tokenHash);
const found = await tokens.findByTokenHash(tokenHash);
expect(found).toEqual({ id, user_id: user.id, expires_at: expect.any(String), consumed_at: null });
});
it('PWDRESETREPO-004: a consumed token is still found (consumed_at is checked by the caller, not filtered here)', async () => {
const { user } = createUser(testDb);
const tokenHash = hash('already-used');
await seedToken(user.id, tokenHash, { consumed_at: '2026-01-01T00:00:00.000Z' });
const found = await tokens.findByTokenHash(tokenHash);
expect(found?.consumed_at).toBe('2026-01-01T00:00:00.000Z');
});
it('PWDRESETREPO-005: an unknown hash is null', async () => {
expect(await tokens.findByTokenHash(hash('never-issued'))).toBeNull();
});
});
describe('markConsumed', () => {
it('PWDRESETREPO-006: stamps consumed_at on the target row only', async () => {
const { user } = createUser(testDb);
const target = await seedToken(user.id, hash('mark-me'));
const other = await seedToken(user.id, hash('leave-me'));
await tokens.markConsumed(target);
expect((await rawRow(target)).consumed_at).not.toBeNull();
expect((await rawRow(other)).consumed_at).toBeNull();
});
});
describe('consumeAllLiveForUser', () => {
it('PWDRESETREPO-007: without an exclusion, burns every live token for the user', async () => {
const { user } = createUser(testDb);
const a = await seedToken(user.id, hash('a'));
const b = await seedToken(user.id, hash('b'));
await tokens.consumeAllLiveForUser(user.id);
expect((await rawRow(a)).consumed_at).not.toBeNull();
expect((await rawRow(b)).consumed_at).not.toBeNull();
});
it('PWDRESETREPO-008: with an exclusion, burns every OTHER live token but leaves the excluded id alone', async () => {
const { user } = createUser(testDb);
const keep = await seedToken(user.id, hash('keep-me'));
const burn1 = await seedToken(user.id, hash('burn-1'));
const burn2 = await seedToken(user.id, hash('burn-2'));
await tokens.consumeAllLiveForUser(user.id, keep);
expect((await rawRow(keep)).consumed_at).toBeNull();
expect((await rawRow(burn1)).consumed_at).not.toBeNull();
expect((await rawRow(burn2)).consumed_at).not.toBeNull();
});
it("PWDRESETREPO-009: never touches another user's tokens or an already-consumed row", async () => {
const { user: victim } = createUser(testDb);
const { user: bystander } = createUser(testDb);
const already = await seedToken(victim.id, hash('already-consumed'), { consumed_at: '2026-01-01T00:00:00.000Z' });
const bystanderToken = await seedToken(bystander.id, hash('bystander'));
await tokens.consumeAllLiveForUser(victim.id);
// The already-consumed row's consumed_at is untouched (still the original
// stamp, not overwritten by this call — the WHERE only matches consumed_at
// IS NULL rows).
expect((await rawRow(already)).consumed_at).toBe('2026-01-01T00:00:00.000Z');
expect((await rawRow(bystanderToken)).consumed_at).toBeNull();
});
});
// ---------------------------------------------------------------------
// Identity-map isolation (Plan 3b Task 5 ruling — every row-out read
// passes `disableIdentityMap: true`, applied by `TrekRepository`'s
// default). This repository has only ONE entity-hydrating read method
// (`findByTokenHash`) — unlike `McpTokensRepository`/`Users.repository.ts`,
// which each have two differently-projected NAMED read methods to
// combine. PWDRESETREPO-010 is the structural proof (no managed entity
// left behind); PWDRESETREPO-011 pins the REAL call shape (`findByTokenHash`
// then `markConsumed` inside the same `uow.transactional`) as safe under
// the shipped default, but — by construction — it CANNOT fail: a single
// managed read re-snapshots every field it names, so nothing is dirty at
// the closing flush regardless of whether the mechanism exists on this
// table. It is not, on its own, proof the B1 stale-write-back mechanism
// (`task-1-review.md` B1) is absent here.
//
// Task 5's original report tried a THIRD test — two raw `find`/`findOne`
// calls with disjoint field sets, `disableIdentityMap: false` — and did
// not reproduce it. `task-5-review-template.md` T2 found that attempt used
// the ordering `task-4-review.md` F1 already showed is non-load-bearing
// (narrower projection first). The mechanism DOES reproduce on this table
// once the WIDER projection — the one carrying the written column,
// `consumed_at` — runs FIRST: PWDRESETREPO-012 pairs this repository's own
// read (projection A, wide) with a narrower **managed** `em.findOne`
// (projection B) and is mutation-proved (green here, red once
// `findByTokenHash` is forced to `disableIdentityMap: false, refresh:
// true` — see the fix report). PWDRESETREPO-013 repeats the shape for
// `consumeAllLiveForUser`, the repository's other write method.
// ---------------------------------------------------------------------
describe('identity-map isolation', () => {
it('PWDRESETREPO-010: findByTokenHash never leaves a managed entity behind', async () => {
const { user } = createUser(testDb);
const id = await seedToken(user.id, hash('isolated'));
await tokens.findByTokenHash(hash('isolated'));
expect(t.em.getUnitOfWork().getById(PasswordResetTokens, id)).toBeUndefined();
});
it('PWDRESETREPO-011: findByTokenHash then markConsumed inside uow.transactional survives the closing flush (cannot fail by construction — see PWDRESETREPO-012 for the mutation-proved guard)', async () => {
const { user } = createUser(testDb);
const id = await seedToken(user.id, hash('survives'));
const uow = new UnitOfWork(t.em);
await uow.transactional(async () => {
const found = await tokens.findByTokenHash(hash('survives'));
expect(found?.consumed_at).toBeNull();
await tokens.markConsumed(id);
});
expect((await rawRow(id)).consumed_at).not.toBeNull();
});
it('PWDRESETREPO-012: findByTokenHash is the FIRST, wider read (it carries consumed_at); a narrower managed read follows; markConsumed still lands', async () => {
const { user } = createUser(testDb);
const id = await seedToken(user.id, hash('dshape'));
const uow = new UnitOfWork(t.em);
await uow.transactional(async () => {
await tokens.findByTokenHash(hash('dshape')); // projection A — WIDE (carries consumed_at)
await t.em.findOne(
PasswordResetTokens,
{ id },
{ fields: ['id', 'token_hash'], refresh: true, disableIdentityMap: false },
); // projection B — NARROW, managed
await tokens.markConsumed(id);
});
expect((await rawRow(id)).consumed_at).not.toBeNull();
});
it('PWDRESETREPO-013: same D-shape guard for consumeAllLiveForUser', async () => {
const { user } = createUser(testDb);
const id = await seedToken(user.id, hash('dshape-consume-all'));
const uow = new UnitOfWork(t.em);
await uow.transactional(async () => {
await tokens.findByTokenHash(hash('dshape-consume-all')); // projection A — WIDE (carries consumed_at)
await t.em.findOne(
PasswordResetTokens,
{ id },
{ fields: ['id', 'token_hash'], refresh: true, disableIdentityMap: false },
); // projection B — NARROW, managed
await tokens.consumeAllLiveForUser(user.id);
});
expect((await rawRow(id)).consumed_at).not.toBeNull();
});
});
});