Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
</div>

<!-- istanbul-badges-readme-start -->
[![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)
<!-- istanbul-badges-readme-end -->
[![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/)
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
13 changes: 7 additions & 6 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<User> {
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);
Expand Down
1 change: 1 addition & 0 deletions src/config/cors.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
42 changes: 42 additions & 0 deletions src/database/seeds/normalize-hero-urls.spec.ts
Original file line number Diff line number Diff line change
@@ -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('');
});
});
79 changes: 79 additions & 0 deletions src/database/seeds/normalize-hero-urls.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 */