diff --git a/CHANGELOG.md b/CHANGELOG.md index 883a3a0..b3f964e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.16.0] - 2026-07-17 + +### Fixed +- **`/auth/login` returned 500 instead of 401 for an existing user with a null password hash** — `bcrypt.compare` throws on a null/undefined hash (reachable only via a direct DB insert that bypasses `UsersService.create()`'s hashing); `validateUser` now guards for a missing hash and fails as a clean, enumeration-safe 401 like any other bad credential. (#145) + +### Added +- **`seed:normalize-hero-urls` script** — a re-runnable, idempotent dev-only seed that rewrites any project's `heroImage` origin to the canonical production host (`cativo.dev`), fixing local hero images that 404 after a hand-edited dev DB pointed at a stale `localhost:3001` origin. No effect on production data. (#146) + +### Changed +- **`localhost:3002` added to the dev-only CORS allowlist** — the frontend dev server's port. (#146) + +--- + ## [2.15.0] - 2026-07-16 ### Changed diff --git a/README.md b/README.md index 82f1823..31b63cb 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,10 @@ -[![Lines](https://img.shields.io/badge/lines-96.84%25-brightgreen.svg?style=flat)](https://github.com/cativo23/portfolio-api) -[![Statements](https://img.shields.io/badge/statements-96.63%25-brightgreen.svg?style=flat)](https://github.com/cativo23/portfolio-api) -[![Branches](https://img.shields.io/badge/branches-91.08%25-brightgreen.svg?style=flat)](https://github.com/cativo23/portfolio-api) -[![Functions](https://img.shields.io/badge/functions-93.35%25-brightgreen.svg?style=flat)](https://github.com/cativo23/portfolio-api) +[![Lines](https://img.shields.io/badge/lines-96.86%25-brightgreen.svg?style=flat)](https://github.com/cativo23/portfolio-api) +[![Statements](https://img.shields.io/badge/statements-96.66%25-brightgreen.svg?style=flat)](https://github.com/cativo23/portfolio-api) +[![Branches](https://img.shields.io/badge/branches-89.84%25-yellow.svg?style=flat)](https://github.com/cativo23/portfolio-api) +[![Functions](https://img.shields.io/badge/functions-93.41%25-brightgreen.svg?style=flat)](https://github.com/cativo23/portfolio-api) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Node Version](https://img.shields.io/badge/node-%3E%3D23.0.0-brightgreen.svg)](https://nodejs.org/) diff --git a/package.json b/package.json index 0da320a..c2bf4ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "portfolio-api", - "version": "2.15.0", + "version": "2.16.0", "description": "Production NestJS REST API — JWT auth, TypeORM, rate limiting, Docker, 90%+ test coverage", "author": "", "private": true, @@ -30,7 +30,8 @@ "seed:test": "npx ts-node -r tsconfig-paths/register src/database/seeder.ts", "seed:test:prod": "node dist/src/database/seeder.prod.js", "seed:update-projects": "npx ts-node -r tsconfig-paths/register src/database/seeds/update-projects-seed.ts", - "seed:update-projects:prod": "node dist/src/database/seeds/update-projects-seed.prod.js" + "seed:update-projects:prod": "node dist/src/database/seeds/update-projects-seed.prod.js", + "seed:normalize-hero-urls": "npx ts-node -r tsconfig-paths/register src/database/seeds/normalize-hero-urls.ts" }, "dependencies": { "@nestjs/axios": "^4", diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index b5e47c6..7f7a43d 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -133,6 +133,23 @@ describe('AuthService', () => { service.validateUser(user.email, 'wrongpass'), ).rejects.toThrow(AuthenticationException); }); + + it('should throw AuthenticationException (not 500) if the stored password is missing', async () => { + // A user inserted directly into the DB without going through + // UsersService.create() can have a null/undefined password. bcrypt.compare + // throws "data and hash arguments required" on such a hash, which would + // otherwise surface as a 500 instead of a clean 401. + const user = { + id: 1, + email: 'test@mail.com', + password: undefined, + } as unknown as User; + usersService.findOneByEmail.mockResolvedValue(user); + + await expect(service.validateUser(user.email, password)).rejects.toThrow( + AuthenticationException, + ); + }); }); describe('login', () => { diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index bc62ec7..4630c86 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -47,8 +47,7 @@ export class AuthService { * @param email - User's email address * @param password - User's password * @returns Promise resolving to an object containing the access token, expiration time, and user information - * @throws NotFoundException if the user is not found - * @throws AuthenticationException if the password is incorrect + * @throws AuthenticationException if the user is not found or the password is incorrect */ async login( email: string, @@ -92,13 +91,15 @@ export class AuthService { * @param email - User's email address * @param password - User's password * @returns Promise resolving to the user entity if validation is successful - * @throws NotFoundException if the user is not found - * @throws AuthenticationException if the password is incorrect + * @throws AuthenticationException if the user is not found or the password is incorrect */ async validateUser(email: string, password: string): Promise { const user = await this.usersService.findOneByEmail(email); - if (!user) { - // Same error message as invalid password to prevent user enumeration + if (!user || !user.password) { + // Also covers users inserted directly into the DB without a hashed + // password: bcrypt.compare throws on a null/undefined hash, which would + // otherwise surface as a 500 instead of a clean 401. Same message as an + // incorrect password to prevent user enumeration. throw new AuthenticationException('Invalid email or password'); } const isMatch: boolean = await bcrypt.compare(password, user.password); diff --git a/src/config/cors.utils.ts b/src/config/cors.utils.ts index 3c31d46..041b62a 100644 --- a/src/config/cors.utils.ts +++ b/src/config/cors.utils.ts @@ -3,6 +3,7 @@ import type { AppConfig } from '@config/configuration.types'; const DEV_LOCALHOST_ORIGINS = [ 'http://localhost:3000', 'http://localhost:3001', + 'http://localhost:3002', 'http://localhost:4173', 'http://localhost:4200', 'http://localhost:5173', diff --git a/src/database/seeds/normalize-hero-urls.spec.ts b/src/database/seeds/normalize-hero-urls.spec.ts new file mode 100644 index 0000000..d0855b0 --- /dev/null +++ b/src/database/seeds/normalize-hero-urls.spec.ts @@ -0,0 +1,42 @@ +import { + normalizeHeroImageUrl, + HERO_ASSET_ORIGIN, +} from './normalize-hero-urls'; + +describe('normalizeHeroImageUrl', () => { + it('rewrites a stale localhost:3001 dev origin to the canonical prod host', () => { + expect( + normalizeHeroImageUrl( + 'http://localhost:3001/img/projects/heroes/portfolio.svg', + ), + ).toBe(`${HERO_ASSET_ORIGIN}/img/projects/heroes/portfolio.svg`); + }); + + it('rewrites any other dev port (e.g. 3002) the same way', () => { + expect( + normalizeHeroImageUrl( + 'http://localhost:3002/img/projects/heroes/nightwire.svg', + ), + ).toBe(`${HERO_ASSET_ORIGIN}/img/projects/heroes/nightwire.svg`); + }); + + it('is idempotent when the value already points at the canonical host', () => { + const canonical = `${HERO_ASSET_ORIGIN}/img/projects/heroes/lumira.svg`; + expect(normalizeHeroImageUrl(canonical)).toBe(canonical); + }); + + it('leaves unrelated URLs (no hero asset path) untouched', () => { + expect(normalizeHeroImageUrl('https://cativo.dev/og-image.png')).toBe( + 'https://cativo.dev/og-image.png', + ); + expect(normalizeHeroImageUrl('https://blog.example.com/hero.png')).toBe( + 'https://blog.example.com/hero.png', + ); + }); + + it('returns null/undefined/empty unchanged', () => { + expect(normalizeHeroImageUrl(null)).toBeNull(); + expect(normalizeHeroImageUrl(undefined)).toBeUndefined(); + expect(normalizeHeroImageUrl('')).toBe(''); + }); +}); diff --git a/src/database/seeds/normalize-hero-urls.ts b/src/database/seeds/normalize-hero-urls.ts new file mode 100644 index 0000000..851c76a --- /dev/null +++ b/src/database/seeds/normalize-hero-urls.ts @@ -0,0 +1,79 @@ +import AppDataSource from '@config/typeorm.config'; +import { Logger } from '@nestjs/common'; +import { Project } from '@projects/entities/project.entity'; + +/** + * Canonical host that serves the project hero assets in production. + * Local hero images are loaded from here too, so local dev matches prod + * exactly (same external-passthrough render path) regardless of which port + * the dev server runs on. + */ +export const HERO_ASSET_ORIGIN = 'https://cativo.dev'; + +const HERO_PATH_SEGMENT = '/img/projects/heroes/'; + +/** + * Normalizes a project `heroImage` value so its origin points at the canonical + * prod asset host, keyed on the hero asset path. + * + * - `http://localhost:3001/img/projects/heroes/x.svg` → `https://cativo.dev/img/projects/heroes/x.svg` + * - `https://cativo.dev/img/projects/heroes/x.svg` → unchanged (idempotent) + * - Any value NOT containing the hero asset path → unchanged + * - `null`/empty → returned as-is + * + * This is what makes a locally hand-edited (`:3001`) DB match prod without a + * manual DB edit: re-runnable and safe against prod data (a no-op there). + */ +export function normalizeHeroImageUrl( + heroImage: string | null | undefined, +): string | null | undefined { + if (!heroImage) return heroImage; + const idx = heroImage.indexOf(HERO_PATH_SEGMENT); + if (idx === -1) return heroImage; + const pathAndAfter = heroImage.slice(idx); + return `${HERO_ASSET_ORIGIN}${pathAndAfter}`; +} + +/** + * Rewrites any project whose hero URL points at a non-canonical origin + * (e.g. a stale `localhost:3001` dev URL) to the canonical prod host. + * + * Requires a live DB connection (same as the sibling `database/seeder*` + * scripts, which the coverage config already excludes), so it's covered by + * running it against a real dev DB rather than a unit spec; the pure + * `normalizeHeroImageUrl` transform above carries the real test coverage. + */ +/* v8 ignore start */ +async function normalizeHeroUrls(): Promise { + const logger = new Logger('NormalizeHeroUrls'); + const dataSource = await AppDataSource.initialize(); + try { + const repository = dataSource.getRepository(Project); + const projects = await repository.find(); + let updated = 0; + + for (const project of projects) { + const next = normalizeHeroImageUrl(project.heroImage); + if (typeof next === 'string' && next !== project.heroImage) { + await repository.update(project.id, { heroImage: next }); + logger.log( + `Project #${project.id}: heroImage ${project.heroImage} → ${next}`, + ); + updated += 1; + } + } + + logger.log( + `Hero URL normalization complete — ${updated} of ${projects.length} project(s) updated.`, + ); + } finally { + await dataSource.destroy(); + } +} + +if (require.main === module) { + normalizeHeroUrls() + .then(() => process.exit(0)) + .catch(() => process.exit(1)); +} +/* v8 ignore stop */