diff --git a/server/src/nest/calendar/calendar.service.ts b/server/src/nest/calendar/calendar.service.ts index ee70ced64..1970c9bed 100644 --- a/server/src/nest/calendar/calendar.service.ts +++ b/server/src/nest/calendar/calendar.service.ts @@ -689,7 +689,9 @@ export class CalendarService { const timezones = new Map(); for (const [zone, yyyymmdd] of usedZones) timezones.set(zone, buildVTimezone(zone, yyyymmdd)); - const safeFilename = (trip.title || 'trek-trip').replace(/["\r\n]/g, '').replace(/[^\w\s.-]/g, '_'); + // \w + space/tab, not \s: JS \s admits U+3000 and friends — codepoints + // Node's header validation refuses, so they 500'd the export (#2165). + const safeFilename = (trip.title || 'trek-trip').replace(/["\r\n]/g, '').replace(/[^\w \t.-]/g, '_'); return { calName: esc(trip.title || 'TREK Trip'), filename: `${safeFilename}.ics`, diff --git a/server/src/nest/common/content-disposition.ts b/server/src/nest/common/content-disposition.ts new file mode 100644 index 000000000..733d3b185 --- /dev/null +++ b/server/src/nest/common/content-disposition.ts @@ -0,0 +1,35 @@ +/** + * Content-Disposition values per RFC 6266. Node validates header values against + * Latin-1 and throws ERR_INVALID_CHAR on any codepoint above 0xFF, so quoting a + * trip title like 沖縄 4泊5日 straight into the header 500'd every export + * (#2165). The quoted filename is folded to printable ASCII here, and when that + * folding loses anything the real name rides along RFC 5987-encoded as + * filename*, which browsers prefer. The result is printable ASCII by + * construction: setHeader cannot throw on it, and CR/LF or quotes in a name + * cannot break out of the quoted-string. A name that is already plain ASCII + * produces byte-for-byte the header the call sites used to build by hand. + */ +export function contentDisposition(filename: string, type: 'attachment' | 'inline'): string { + const name = filename + // Control characters (tab included) have no business in a filename on + // either side of the header, and an unpaired surrogate — a name cut + // mid-emoji — would make encodeURIComponent throw below. + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x1f\x7f]/g, ' ') + .replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? 0 ? ascii.slice(0, dot) : ascii; + const fallback = /[0-9A-Za-z]/.test(stem) ? ascii : `download${dot > 0 ? ascii.slice(dot) : ''}`; + + // RFC 5987 attr-char: encodeURIComponent is close but leaves ' ( ) * bare. + const encoded = encodeURIComponent(name).replace( + /['()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ); + return `${type}; filename="${fallback}"; filename*=UTF-8''${encoded}`; +} diff --git a/server/src/nest/feeds/feeds.controller.ts b/server/src/nest/feeds/feeds.controller.ts index 9bbc67649..e3ee458b3 100644 --- a/server/src/nest/feeds/feeds.controller.ts +++ b/server/src/nest/feeds/feeds.controller.ts @@ -11,6 +11,7 @@ import { } from '@nestjs/common'; import type { Request, Response } from 'express'; import { readEnv } from '../../app-config'; +import { contentDisposition } from '../common/content-disposition'; import { FeedsService } from './feeds.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; @@ -48,7 +49,7 @@ export class FeedsPublicController { return; } res.setHeader('Content-Type', 'text/calendar; charset=utf-8'); - res.setHeader('Content-Disposition', `inline; filename="${result.filename}"`); + res.setHeader('Content-Disposition', contentDisposition(result.filename, 'inline')); res.setHeader('Cache-Control', 'no-cache, no-store'); res.setHeader('X-Published-TTL', 'PT1H'); res.send(result.ics); diff --git a/server/src/nest/files/files-download.controller.ts b/server/src/nest/files/files-download.controller.ts index 2f96ab0a4..44b48eb46 100644 --- a/server/src/nest/files/files-download.controller.ts +++ b/server/src/nest/files/files-download.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, HttpException, Param, Req, Res } from '@nestjs/common' import type { Request, Response } from 'express'; import path from 'path'; import { FilesService } from './files.service'; +import { contentDisposition } from '../common/content-disposition'; import { Public } from '../auth/public.decorator'; import { StorageService } from '../storage/storage.service'; @@ -68,7 +69,7 @@ export class FilesDownloadController { name, res, walletMime - ? { contentType: walletMime, disposition: `inline; filename="${path.basename(file.original_name || name)}"` } + ? { contentType: walletMime, disposition: contentDisposition(path.basename(file.original_name || name), 'inline') } : undefined, ); } diff --git a/server/src/nest/places/gpx-export.helpers.ts b/server/src/nest/places/gpx-export.helpers.ts index d9fce662c..535000f09 100644 Binary files a/server/src/nest/places/gpx-export.helpers.ts and b/server/src/nest/places/gpx-export.helpers.ts differ diff --git a/server/src/nest/places/places.controller.ts b/server/src/nest/places/places.controller.ts index eb5775ac6..9e9a78548 100644 --- a/server/src/nest/places/places.controller.ts +++ b/server/src/nest/places/places.controller.ts @@ -18,6 +18,7 @@ import { import { FileInterceptor } from '@nestjs/platform-express'; import type { Response } from 'express'; import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write'; +import { contentDisposition } from '../common/content-disposition'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; import { memoryStorage } from 'multer'; import { hexColorSchema, placeImageUrlSchema, placeWebsiteSchema } from '@trek/shared'; @@ -214,7 +215,7 @@ export class PlacesController { throw new HttpException({ error: 'Nothing to export' }, 404); } res.setHeader('Content-Type', 'application/gpx+xml; charset=utf-8'); - res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`); + res.setHeader('Content-Disposition', contentDisposition(result.filename, 'attachment')); res.send(result.gpx); } diff --git a/server/src/nest/trips/trips.controller.ts b/server/src/nest/trips/trips.controller.ts index 43197a89f..eb940b8bb 100644 --- a/server/src/nest/trips/trips.controller.ts +++ b/server/src/nest/trips/trips.controller.ts @@ -18,6 +18,7 @@ import { } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write'; +import { contentDisposition } from '../common/content-disposition'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; import type { Request, Response } from 'express'; import type { Options } from 'multer'; @@ -273,7 +274,7 @@ export class TripsController { try { const { ics, filename } = this.calendar.exportICS(id); res.setHeader('Content-Type', 'text/calendar; charset=utf-8'); - res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.setHeader('Content-Disposition', contentDisposition(filename, 'attachment')); res.send(ics); } catch (e: unknown) { if (e instanceof NotFoundError) throw new HttpException({ error: e.message }, 404); diff --git a/server/tests/e2e/feeds.e2e.test.ts b/server/tests/e2e/feeds.e2e.test.ts index 25520e195..77a1ddfb4 100644 --- a/server/tests/e2e/feeds.e2e.test.ts +++ b/server/tests/e2e/feeds.e2e.test.ts @@ -250,6 +250,24 @@ describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => { expect(buildTripCalendar).toHaveBeenCalledWith(5); }); + // The @Public feed quoted cal.filename straight into Content-Disposition, so + // a non-ASCII calendar name was an unauthenticated 500 (#2165). + it('public trip feed: a non-ASCII filename folds to an ASCII header with filename*', async () => { + const gen = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(1)); + const token = gen.body.feed_url.match(/trip\/([0-9a-f-]+)\.ics$/)![1]; + + // ASCII parity first: the default sample calendar keeps the exact legacy header. + const plain = await request(server).get(`/api/feed/trip/${token}.ics`); + expect(plain.headers['content-disposition']).toBe('inline; filename="sample.ics"'); + + buildTripCalendar.mockImplementation(() => ({ ...sampleCalendar(), filename: '沖縄カレンダー.ics' })); + const res = await request(server).get(`/api/feed/trip/${token}.ics`); + expect(res.status).toBe(200); + expect(res.headers['content-disposition']).toBe( + 'inline; filename="download.ics"; filename*=UTF-8\'\'%E6%B2%96%E7%B8%84%E3%82%AB%E3%83%AC%E3%83%B3%E3%83%80%E3%83%BC.ics', + ); + }); + it('public trip feed: 404 for an unknown token', async () => { const res = await request(server).get('/api/feed/trip/00000000-0000-0000-0000-000000000000.ics'); expect(res.status).toBe(404); diff --git a/server/tests/e2e/places.e2e.test.ts b/server/tests/e2e/places.e2e.test.ts index c7bafbe54..62274f876 100644 --- a/server/tests/e2e/places.e2e.test.ts +++ b/server/tests/e2e/places.e2e.test.ts @@ -334,6 +334,21 @@ describe('Places e2e (real auth guard + temp SQLite)', () => { expect(res.text).toContain('1. Warm up'); }); + // The reported repro (#2165): a Japanese trip title crashed setHeader with + // ERR_INVALID_CHAR before the first body byte. Now the header folds to + // ASCII and carries the real name RFC 5987-encoded. + it('a non-ASCII trip title exports 200 with a filename* header instead of a 500 (#2165)', async () => { + db.prepare('INSERT INTO trips (id, title) VALUES (5, ?)').run('沖縄 4泊5日'); + db.prepare('INSERT INTO places (id, trip_id, name, lat, lng) VALUES (1, 5, ?, 26.217, 127.719)').run('首里城'); + + const res = await request(server).get('/api/trips/5/places/export.gpx').set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.headers['content-disposition']).toBe( + 'attachment; filename="__-4_5_.gpx"; filename*=UTF-8\'\'%E6%B2%96%E7%B8%84-4%E6%B3%8A5%E6%97%A5.gpx', + ); + expect(res.text).toContain('首里城'); + }); + it('narrows the document to the requested parts', async () => { seedTrip(); const res = await request(server) diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index 504f8857c..1a2916d56 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -71,7 +71,7 @@ const { db } = vi.hoisted(() => { status TEXT DEFAULT 'pending', reservation_time TEXT, reservation_end_time TEXT, location TEXT, confirmation_number TEXT, notes TEXT, url TEXT, accommodation_id TEXT, metadata TEXT, needs_review INTEGER DEFAULT 0, day_plan_position REAL, external_source TEXT, sync_enabled INTEGER, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); + ingest_state TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); tmp.exec('CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, day_number INTEGER, date TEXT);'); tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT, image_url TEXT, address TEXT, lat REAL, lng REAL, category_id INTEGER, description TEXT, @@ -233,6 +233,18 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => { }); }); + // Real CalendarService against the temp db: a title carrying U+3000 slipped + // through the old \s keep-class into setHeader and 500'd the export (#2165). + it('200 export.ics with a header-safe filename for a title full of ideographic whitespace', async () => { + const tripId = Number(db.prepare('INSERT INTO trips (user_id, title, start_date, end_date) VALUES (1, ?, ?, ?)') + .run('沖縄 4泊5日', '2026-05-01', '2026-05-05').lastInsertRowid); + const res = await request(server).get(`/api/trips/${tripId}/export.ics`).set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('text/calendar'); + expect(res.headers['content-disposition']).toBe('attachment; filename="___4_5_.ics"'); + expect(res.text).toContain('BEGIN:VCALENDAR'); + }); + it('200 bundle for an accessible trip (real member list)', async () => { const tripId = seedTrip('B'); const res = await request(server).get(`/api/trips/${tripId}/bundle`).set('Cookie', sessionCookie(1)); diff --git a/server/tests/unit/mcp/tools-places.test.ts b/server/tests/unit/mcp/tools-places.test.ts index fe006943f..2885b10c0 100644 --- a/server/tests/unit/mcp/tools-places.test.ts +++ b/server/tests/unit/mcp/tools-places.test.ts @@ -998,6 +998,21 @@ describe('Tool: export_trip_gpx', () => { }); }); + // Same gpxFilename as the REST route (#2165): the title's script survives in + // the filename now — the header folding is the controller's job, not ours. + it('keeps a non-ASCII trip title in the filename', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: '沖縄 4泊5日' }); + createPlace(testDb, trip.id, { name: '首里城', lat: 26.217, lng: 127.719 }); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'export_trip_gpx', arguments: { tripId: trip.id } }); + const data = parseToolResult(result) as { gpx: string; filename: string }; + expect(data.filename).toBe('沖縄-4泊5日.gpx'); + expect(data.gpx).toContain('首里城'); + }); + }); + it('writes each planned day as a route through its stops in order', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris Weekend' }); diff --git a/server/tests/unit/mcp/tools-trip-members.test.ts b/server/tests/unit/mcp/tools-trip-members.test.ts index 6057a4a30..4827781d2 100644 --- a/server/tests/unit/mcp/tools-trip-members.test.ts +++ b/server/tests/unit/mcp/tools-trip-members.test.ts @@ -491,6 +491,18 @@ describe('Tool: export_trip_ics', () => { expect(result.isError).toBe(true); }); }); + + // Same calendar filename as the REST route (#2165): U+3000 used to slip + // through the \s keep-class and crash the header there; it folds to _ now. + it('folds header-hostile whitespace out of the filename', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: '沖縄 4泊5日', start_date: '2025-06-01', end_date: '2025-06-05' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'export_trip_ics', arguments: { tripId: trip.id } }); + const data = parseToolResult(result) as { filename: string }; + expect(data.filename).toBe('___4_5_.ics'); + }); + }); }); // --------------------------------------------------------------------------- diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts index 8b15364c5..051d3250f 100644 --- a/server/tests/unit/nest/calendar.service.test.ts +++ b/server/tests/unit/nest/calendar.service.test.ts @@ -233,6 +233,22 @@ describe('exportICS', () => { expect(filename).toMatch(/My.Trip.2025\.ics/); }); + // JS \s admitted U+3000 (and \v/\f) into the filename, and Node's header + // validation then threw ERR_INVALID_CHAR on the export route (#2165). + it('TRIP-SVC-008b: ideographic whitespace in the title folds to _ in the filename', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: '沖縄 4泊5日' }); + + expect(svc.exportICS(trip.id).filename).toBe('___4_5_.ics'); + }); + + it('TRIP-SVC-008c: vertical tab and form feed fold too', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'A\vB\fC' }); + + expect(svc.exportICS(trip.id).filename).toBe('A_B_C.ics'); + }); + it('TRIP-SVC-009: reservation with end time includes DTEND', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); diff --git a/server/tests/unit/nest/content-disposition.test.ts b/server/tests/unit/nest/content-disposition.test.ts new file mode 100644 index 000000000..ad2877926 --- /dev/null +++ b/server/tests/unit/nest/content-disposition.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { contentDisposition } from '../../../src/nest/common/content-disposition'; + +// Node refuses header values with codepoints above 0xFF (ERR_INVALID_CHAR), so +// the builder must hand back printable ASCII no matter what the name holds — +// and stay byte-identical to the old hand-built header for plain ASCII names. +describe('contentDisposition (#2165)', () => { + it('keeps a plain ASCII name byte-identical to the hand-built header', () => { + expect(contentDisposition('Alpine-week.gpx', 'attachment')).toBe('attachment; filename="Alpine-week.gpx"'); + expect(contentDisposition('all-trips.ics', 'inline')).toBe('inline; filename="all-trips.ics"'); + // Spaces are fine inside a quoted-string; no reason to grow a filename*. + expect(contentDisposition('My Trip 2025.ics', 'attachment')).toBe('attachment; filename="My Trip 2025.ics"'); + expect(contentDisposition('', 'attachment')).toBe('attachment; filename=""'); + }); + + it('adds filename* only when the quoted fallback loses something', () => { + expect(contentDisposition('沖縄-4泊5日.gpx', 'attachment')).toBe( + 'attachment; filename="__-4_5_.gpx"; filename*=UTF-8\'\'%E6%B2%96%E7%B8%84-4%E6%B3%8A5%E6%97%A5.gpx', + ); + }); + + it('folds an accent in both its composed and combining forms', () => { + expect(contentDisposition('Café.gpx', 'attachment')).toBe( + 'attachment; filename="Caf_.gpx"; filename*=UTF-8\'\'Caf%C3%A9.gpx', + ); + // NFKD output: 'e' + U+0301 — the combining mark alone is already > 0xFF. + expect(contentDisposition('Café.gpx', 'attachment')).toBe( + 'attachment; filename="Cafe_.gpx"; filename*=UTF-8\'\'Cafe%CC%81.gpx', + ); + }); + + it('neutralizes CR/LF and quotes — nothing escapes the quoted-string', () => { + expect(contentDisposition('evil\r\nContent-Length: 0"; x="y.gpx', 'attachment')).toBe( + 'attachment; filename="evil Content-Length: 0_; x=_y.gpx"; filename*=UTF-8\'\'evil%20%20Content-Length%3A%200%22%3B%20x%3D%22y.gpx', + ); + }); + + it('folds a tab to a space instead of riding it into the header', () => { + expect(contentDisposition('a\tb.ics', 'attachment')).toBe( + 'attachment; filename="a b.ics"; filename*=UTF-8\'\'a%20b.ics', + ); + }); + + it('catches the JS-\\s stragglers like U+3000', () => { + expect(contentDisposition('a b.ics', 'inline')).toBe( + 'inline; filename="a_b.ics"; filename*=UTF-8\'\'a%E3%80%80b.ics', + ); + }); + + it("escapes the RFC 5987 stragglers encodeURIComponent leaves bare: ' ( ) *", () => { + expect(contentDisposition("日(1)'*.gpx", 'attachment')).toBe( + 'attachment; filename="_(1)\'*.gpx"; filename*=UTF-8\'\'%E6%97%A5%281%29%27%2A.gpx', + ); + }); + + it('falls back to download when the folding erased everything readable, keeping the extension', () => { + expect(contentDisposition('日本語.gpx', 'attachment')).toBe( + 'attachment; filename="download.gpx"; filename*=UTF-8\'\'%E6%97%A5%E6%9C%AC%E8%AA%9E.gpx', + ); + expect(contentDisposition('日本語', 'attachment')).toBe( + 'attachment; filename="download"; filename*=UTF-8\'\'%E6%97%A5%E6%9C%AC%E8%AA%9E', + ); + }); + + it('carries an emoji through filename* and survives a name cut mid-emoji', () => { + expect(contentDisposition('\u{1F5FE}.gpx', 'attachment')).toBe( + 'attachment; filename="download.gpx"; filename*=UTF-8\'\'%F0%9F%97%BE.gpx', + ); + // A lone surrogate would make encodeURIComponent throw "URI malformed" — + // exactly the crash class this helper exists to end. + expect(contentDisposition('\uD83D.gpx', 'attachment')).toBe( + 'attachment; filename="download.gpx"; filename*=UTF-8\'\'%EF%BF%BD.gpx', + ); + }); + + it('always yields a value Node accepts: printable ASCII, whatever comes in', () => { + const nasty = [ + '沖縄 4泊5日.gpx', + 'a
b
c.ics', + '"\\.pkpass', + '\x00\x1f\x7f.gpx', + '\u{1F9ED}'.repeat(40), + 'plain.txt', + '', + ]; + for (const name of nasty) { + expect(contentDisposition(name, 'attachment')).toMatch(/^[\x20-\x7e]+$/); + expect(contentDisposition(name, 'inline')).toMatch(/^[\x20-\x7e]+$/); + } + }); +}); diff --git a/server/tests/unit/nest/files.controller.test.ts b/server/tests/unit/nest/files.controller.test.ts index 2a3006180..f579bf874 100644 --- a/server/tests/unit/nest/files.controller.test.ts +++ b/server/tests/unit/nest/files.controller.test.ts @@ -279,6 +279,19 @@ describe('FilesDownloadController', () => { disposition: 'inline; filename="pass.pkpass"', }); }); + + // Uploads store original_name raw, so a Japanese boarding pass used to hand + // setHeader a value it throws on (#2165) — storage.service.ts documents the + // throw as reachable; now the header is ASCII with the real name in filename*. + it('folds a non-ASCII original name to ASCII + filename* instead of crashing setHeader (#2165)', async () => { + const storage = dstor(); + const s = dsvc({ getFileById: vi.fn().mockReturnValue({ filename: 'pass.pkpass', original_name: '搭乗券.pkpass' }) }); + await new FilesDownloadController(s, storage).download(req, res, '5', '9'); + expect(storage.sendToResponse).toHaveBeenCalledWith('files', 'pass.pkpass', res, { + contentType: 'application/vnd.apple.pkpass', + disposition: 'inline; filename="download.pkpass"; filename*=UTF-8\'\'%E6%90%AD%E4%B9%97%E5%88%B8.pkpass', + }); + }); }); describe('PhotosController', () => { diff --git a/server/tests/unit/nest/gpx-export.helpers.test.ts b/server/tests/unit/nest/gpx-export.helpers.test.ts index 5becba73c..ceabaa808 100644 --- a/server/tests/unit/nest/gpx-export.helpers.test.ts +++ b/server/tests/unit/nest/gpx-export.helpers.test.ts @@ -174,4 +174,17 @@ describe('gpxFilename', () => { expect(gpxFilename('///')).toBe('trip.gpx'); expect(gpxFilename('')).toBe('trip.gpx'); }); + + // Header safety moved into contentDisposition() (#2165) — the filename may + // keep the title's own script instead of tripping setHeader's Latin-1 check. + it('keeps a non-ASCII title as typed', () => { + expect(gpxFilename('沖縄 4泊5日')).toBe('沖縄-4泊5日.gpx'); + // No NFKD any more: the composed é stays composed instead of decaying into + // 'e' + a combining mark that only looked stripped. + expect(gpxFilename('Café')).toBe('Café.gpx'); + }); + + it('trims on codepoint boundaries, never through the middle of an emoji', () => { + expect(gpxFilename('\u{1F5FE}'.repeat(70))).toBe('\u{1F5FE}'.repeat(60) + '.gpx'); + }); }); diff --git a/server/tests/unit/nest/places.controller.test.ts b/server/tests/unit/nest/places.controller.test.ts index 5a86e25bc..446920591 100644 --- a/server/tests/unit/nest/places.controller.test.ts +++ b/server/tests/unit/nest/places.controller.test.ts @@ -147,6 +147,29 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou }); }); + describe('GET /export.gpx (#2165)', () => { + const makeRes = () => ({ setHeader: vi.fn(), send: vi.fn() }); + const exportQuery = {} as never; + + it('an ASCII filename keeps the exact legacy header', () => { + const res = makeRes(); + const s = svc({ exportGpx: vi.fn().mockReturnValue({ gpx: '', filename: 'Alpine-week.gpx' }) } as Partial); + new PlacesController(s, new RuntimeEnvService(), storageStub).exportGpx(user, '5', exportQuery, res as never); + expect(res.setHeader).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="Alpine-week.gpx"'); + expect(res.send).toHaveBeenCalledWith(''); + }); + + it('a non-ASCII filename no longer reaches setHeader raw: ASCII fallback + filename*', () => { + const res = makeRes(); + const s = svc({ exportGpx: vi.fn().mockReturnValue({ gpx: '', filename: '沖縄-4泊5日.gpx' }) } as Partial); + new PlacesController(s, new RuntimeEnvService(), storageStub).exportGpx(user, '5', exportQuery, res as never); + expect(res.setHeader).toHaveBeenCalledWith( + 'Content-Disposition', + 'attachment; filename="__-4_5_.gpx"; filename*=UTF-8\'\'%E6%B2%96%E7%B8%84-4%E6%B3%8A5%E6%97%A5.gpx', + ); + }); + }); + describe('POST /import/google-list + naver-list', () => { // The legacy 'URL is required' 400 is gone: placeImportListRequestSchema // pins `url`, so the ZodValidationPipe rejects a urlless body before the diff --git a/server/tests/unit/nest/trips.controller.test.ts b/server/tests/unit/nest/trips.controller.test.ts index 1d2acfa98..1857d43f7 100644 --- a/server/tests/unit/nest/trips.controller.test.ts +++ b/server/tests/unit/nest/trips.controller.test.ts @@ -386,6 +386,16 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { expect(res.send).toHaveBeenCalledWith('BEGIN:VCALENDAR'); }); + it('folds a non-ASCII filename into an ASCII header with filename* instead of crashing setHeader (#2165)', () => { + const res = { setHeader: vi.fn(), send: vi.fn() }; + const cal = { exportICS: vi.fn().mockReturnValue({ ics: 'BEGIN:VCALENDAR', filename: '沖縄.ics' }) }; + tc(svc(), cal).exportIcs(user, '9', res as never); + expect(res.setHeader).toHaveBeenCalledWith( + 'Content-Disposition', + 'attachment; filename="download.ics"; filename*=UTF-8\'\'%E6%B2%96%E7%B8%84.ics', + ); + }); + it('maps a NotFoundError from the export to 404 and re-throws others', () => { const nf = { exportICS: vi.fn().mockImplementation(() => { throw new NotFoundError('gone'); }) }; expect(thrown(() => tc(svc(), nf).exportIcs(user, '9', makeRes()))).toEqual({ status: 404, body: { error: 'gone' } });