mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
e3982a8e2a
Converts the raw prepare() fixtures and reads in the next set of specs to the ORM factories and readers: the password reset, webauthn, dawarich, budget, to-do and vacay repository specs, the journey, settings, addons and share MCP specs, the plugin POI and operator egress specs, the feeds, storage stats and query helper services, and the packing, collections, day reorder, leg mode, upload context and trip access integration suites. The notifications and google quota e2e suites now seed their users through makeUser with pinned ids, and the notifications suite moves from hand-rolled tables to the migrated snapshot. Legacy statements that serve as a parity oracle and the raw rows the primary-relation spike holds toObject() against stay raw, each with an allow marker naming why.
235 lines
11 KiB
TypeScript
235 lines
11 KiB
TypeScript
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
import { createHash } from 'crypto';
|
|
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
|
import { resetTestDb } from '../../../helpers/test-db';
|
|
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
|
import { createUser } from '../../../helpers/factories';
|
|
import { findRow, insertRow } from '../../../helpers/factories/rows';
|
|
import { PasswordResetTokens } from '../../../../src/db/entities/PasswordResetTokens.entity';
|
|
import type { PasswordResetTokensRepository } from '../../../../src/db/repositories/PasswordResetTokens.repository';
|
|
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
|
|
|
|
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 = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
|
|
|
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: new Date().toISOString(), 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();
|
|
});
|
|
});
|
|
});
|