From a690ee7af0f8be03d3c3dcee10752142dc5d65f7 Mon Sep 17 00:00:00 2001 From: vibhor-aggr <108814060+vibhor-aggr@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:19:49 +0530 Subject: [PATCH 1/2] fix: try extensions for dotted basenames --- src/send.ts | 2 +- test/fixtures/hello.world.txt | 1 + test/index.test.ts | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/hello.world.txt diff --git a/src/send.ts b/src/send.ts index 3947298..4d30a86 100644 --- a/src/send.ts +++ b/src/send.ts @@ -79,7 +79,7 @@ export async function send( encodingExt = '.gz'; } - if (extensions && !path.basename(filePath).includes('.')) { + if (extensions && !(await isPathExists(filePath))) { for (let ext of extensions) { if (typeof ext !== 'string') throw new TypeError( diff --git a/test/fixtures/hello.world.txt b/test/fixtures/hello.world.txt new file mode 100644 index 0000000..cc628cc --- /dev/null +++ b/test/fixtures/hello.world.txt @@ -0,0 +1 @@ +world diff --git a/test/index.test.ts b/test/index.test.ts index 60097af..834f082 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -690,6 +690,21 @@ describe('send(ctx, file)', () => { await request(server).get('/').expect(200); }); }); + + describe('when trying to get a file without extension with matching .extensions sufficed with a dot in the basename', () => { + it('should 200', async () => { + const app = new Koa(); + + app.use(async (ctx) => { + await send(ctx, 'test/fixtures/hello.world', { + extensions: ['txt'], + }); + }); + + server = app.listen(); + await request(server).get('/').expect(200).expect('world\n'); + }); + }); }); it('should set the Content-Type', async () => { From b50208369cdbd3494a11f102092a8b6d5f692652 Mon Sep 17 00:00:00 2001 From: vibhor-aggr <108814060+vibhor-aggr@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:38:44 +0530 Subject: [PATCH 2/2] fix: surface access errors in path checks --- src/send.utils.ts | 5 +++-- test/index.test.ts | 24 +++++++++++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/send.utils.ts b/src/send.utils.ts index 1145996..8fe48eb 100644 --- a/src/send.utils.ts +++ b/src/send.utils.ts @@ -15,8 +15,9 @@ export async function isPathExists(targetPath: string) { try { await asyncFs.access(targetPath); return true; - } catch { - return false; + } catch (err) { + if ((err as {code?: string}).code === 'ENOENT') return false; + throw err; } } diff --git a/test/index.test.ts b/test/index.test.ts index 834f082..482bc5b 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,3 +1,4 @@ +import asyncFs from 'node:fs/promises'; import path from 'node:path'; import Koa from 'koa'; @@ -5,6 +6,27 @@ import request from 'supertest'; import { decompress } from 'brotli'; import { send } from '../src'; +import { isPathExists } from '../src/send.utils'; + +describe('isPathExists(targetPath)', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns false when access reports ENOENT', async () => { + const error = Object.assign(new Error('missing'), { code: 'ENOENT' }); + jest.spyOn(asyncFs, 'access').mockRejectedValueOnce(error); + + await expect(isPathExists('missing')).resolves.toBe(false); + }); + + it('rethrows non-ENOENT access errors', async () => { + const error = Object.assign(new Error('denied'), { code: 'EACCES' }); + jest.spyOn(asyncFs, 'access').mockRejectedValueOnce(error); + + await expect(isPathExists('denied')).rejects.toBe(error); + }); +}); describe('send(ctx, file)', () => { let server: ReturnType['listen']>; @@ -691,7 +713,7 @@ describe('send(ctx, file)', () => { }); }); - describe('when trying to get a file without extension with matching .extensions sufficed with a dot in the basename', () => { + describe('when trying to get a file without extension with matching .extensions suffixed with a dot in the basename', () => { it('should 200', async () => { const app = new Koa();