rsnext/test/e2e/app-dir/app-routes/app-custom-routes.test.ts
Sam Ko 0a697cf044
fix(route-handlers): make sure preflight has CORS headers (#63264)
## Why?

When we just have a POST route handler, it seems CORs is not working
properly. This is because we break out of the loop here
768a92b15b/packages/next/src/server/future/route-modules/app-route/helpers/auto-implement-methods.ts (L38)
when a `GET` isn't present before we add the correct headers and status
to the preflight `OPTION`.

- Closes https://github.com/vercel/next.js/issues/57999

Closes NEXT-2813

---------

Co-authored-by: JJ Kasper <jj@jjsweb.site>
2024-03-14 00:59:57 +00:00

675 lines
21 KiB
TypeScript

import { createNextDescribe } from 'e2e-utils'
import { check } from 'next-test-utils'
import { Readable } from 'stream'
import {
withRequestMeta,
getRequestMeta,
cookieWithRequestMeta,
} from './helpers'
const basePath = process.env.BASE_PATH ?? ''
createNextDescribe(
'app-custom-routes',
{
files: __dirname,
},
({ next, isNextDeploy, isNextDev, isNextStart }) => {
describe('works with api prefix correctly', () => {
it('statically generates correctly with no dynamic usage', async () => {
if (isNextStart) {
expect(
await next.readFile('.next/server/app/api/hello.json.body')
).toBeTruthy()
expect(
await next.readFile('.next/server/app/api/hello.json.meta')
).toBeTruthy()
}
expect(
JSON.parse(await next.render(basePath + '/api/hello.json'))
).toEqual({
pathname: '/api/hello.json',
})
})
it('does not statically generate with dynamic usage', async () => {
if (isNextStart) {
expect(
await next
.readFile('.next/server/app/api/dynamic.body')
.catch(() => '')
).toBeFalsy()
expect(
await next
.readFile('.next/server/app/api/dynamic.meta')
.catch(() => '')
).toBeFalsy()
}
expect(
JSON.parse(await next.render(basePath + '/api/dynamic'))
).toEqual({
pathname: '/api/dynamic',
query: {},
})
})
})
describe('works with generateStaticParams correctly', () => {
it.each([
'/static/first/data.json',
'/static/second/data.json',
'/static/three/data.json',
])('responds correctly on %s', async (path) => {
expect(JSON.parse(await next.render(basePath + path))).toEqual({
params: { slug: path.split('/', 3)[2] },
now: expect.any(Number),
})
if (isNextStart) {
await check(async () => {
expect(
await next.readFile(`.next/server/app/${path}.body`)
).toBeTruthy()
expect(
await next.readFile(`.next/server/app/${path}.meta`)
).toBeTruthy()
return 'success'
}, 'success')
}
})
it.each([
'/revalidate-1/first/data.json',
'/revalidate-1/second/data.json',
'/revalidate-1/three/data.json',
])('revalidates correctly on %s', async (path) => {
const data = JSON.parse(await next.render(basePath + path))
expect(data).toEqual({
params: { slug: path.split('/', 3)[2] },
now: expect.any(Number),
})
await check(async () => {
expect(data).not.toEqual(
JSON.parse(await next.render(basePath + path))
)
return 'success'
}, 'success')
if (isNextStart) {
await check(async () => {
expect(
await next.readFile(`.next/server/app/${path}.body`)
).toBeTruthy()
expect(
await next.readFile(`.next/server/app/${path}.meta`)
).toBeTruthy()
return 'success'
}, 'success')
}
})
})
describe('basic fetch request with a response', () => {
describe.each(['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])(
'made via a %s request',
(method) => {
it.each(['/basic/endpoint', '/basic/vercel/endpoint'])(
'responds correctly on %s',
async (path) => {
const res = await next.fetch(basePath + path, { method })
expect(res.status).toEqual(200)
expect(await res.text()).toContain('hello, world')
const meta = getRequestMeta(res.headers)
expect(meta.method).toEqual(method)
}
)
}
)
describe('route groups', () => {
it('routes to the correct handler', async () => {
const res = await next.fetch(basePath + '/basic/endpoint/nested')
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.pathname).toEqual('/basic/endpoint/nested')
})
})
describe('request', () => {
it('can read query parameters', async () => {
const res = await next.fetch(basePath + '/advanced/query?ping=pong')
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.ping).toEqual('pong')
})
it('can read query parameters (edge)', async () => {
const res = await next.fetch(
basePath + '/edge/advanced/query?ping=pong'
)
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.ping).toEqual('pong')
})
})
describe('response', () => {
// TODO-APP: re-enable when rewrites are supported again
it.skip('supports the NextResponse.rewrite() helper', async () => {
const res = await next.fetch(basePath + '/hooks/rewrite')
expect(res.status).toEqual(200)
// This is running in the edge runtime, so we expect not to see this
// header.
expect(res.headers.has('x-middleware-rewrite')).toBeFalse()
expect(await res.text()).toContain('hello, world')
})
it('supports the NextResponse.redirect() helper', async () => {
const res = await next.fetch(basePath + '/hooks/redirect/response', {
// "Manually" perform the redirect, we want to inspect the
// redirection response, so don't actually follow it.
redirect: 'manual',
})
expect(res.status).toEqual(307)
expect(res.headers.get('location')).toEqual('https://nextjs.org/')
expect(await res.text()).toBeEmpty()
})
it('supports the NextResponse.json() helper', async () => {
const meta = { ping: 'pong' }
const res = await next.fetch(basePath + '/hooks/json', {
headers: withRequestMeta(meta),
})
expect(res.status).toEqual(200)
expect(res.headers.get('content-type')).toEqual('application/json')
expect(await res.json()).toEqual(meta)
})
})
})
describe('body', () => {
// we can't stream a body to a function currently only stream response
if (!isNextDeploy) {
it('can handle handle a streaming request and streaming response', async () => {
const body = new Array(10).fill(JSON.stringify({ ping: 'pong' }))
let index = 0
const stream = new Readable({
read() {
if (index >= body.length) return this.push(null)
this.push(body[index] + '\n')
index++
},
})
const res = await next.fetch(basePath + '/advanced/body/streaming', {
method: 'POST',
body: stream,
})
expect(res.status).toEqual(200)
expect(await res.text()).toEqual(body.join('\n') + '\n')
})
}
it('can handle handle a streaming request and streaming response (edge)', async () => {
const body = new Array(10).fill(JSON.stringify({ ping: 'pong' }))
let index = 0
const stream = new Readable({
read() {
if (index >= body.length) return this.push(null)
this.push(body[index] + '\n')
index++
},
})
const res = await next.fetch(
basePath + '/edge/advanced/body/streaming',
{
method: 'POST',
body: stream,
}
)
expect(res.status).toEqual(200)
expect(await res.text()).toEqual(body.join('\n') + '\n')
})
it('can read a JSON encoded body', async () => {
const body = { ping: 'pong' }
const res = await next.fetch(basePath + '/advanced/body/json', {
method: 'POST',
body: JSON.stringify(body),
})
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.body).toEqual(body)
})
it('can read a JSON encoded body (edge)', async () => {
const body = { ping: 'pong' }
const res = await next.fetch(basePath + '/edge/advanced/body/json', {
method: 'POST',
body: JSON.stringify(body),
})
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.body).toEqual(body)
})
it('can read a JSON encoded body for DELETE requests', async () => {
const body = { name: 'foo' }
const res = await next.fetch(basePath + '/advanced/body/json', {
method: 'DELETE',
body: JSON.stringify(body),
})
expect(res.status).toEqual(200)
expect(await res.text()).toEqual('delete foo')
})
it('can read a JSON encoded body for OPTIONS requests', async () => {
const body = { name: 'bar' }
const res = await next.fetch(basePath + '/advanced/body/json', {
method: 'OPTIONS',
body: JSON.stringify(body),
})
expect(res.status).toEqual(200)
expect(await res.text()).toEqual('options bar')
})
// we can't stream a body to a function currently only stream response
if (!isNextDeploy) {
it('can read a streamed JSON encoded body', async () => {
const body = { ping: 'pong' }
const encoded = JSON.stringify(body)
let index = 0
const stream = new Readable({
async read() {
if (index >= encoded.length) return this.push(null)
this.push(encoded[index])
index++
},
})
const res = await next.fetch(basePath + '/advanced/body/json', {
method: 'POST',
body: stream,
})
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.body).toEqual(body)
})
}
it('can read a streamed JSON encoded body (edge)', async () => {
const body = { ping: 'pong' }
const encoded = JSON.stringify(body)
let index = 0
const stream = new Readable({
async read() {
if (index >= encoded.length) return this.push(null)
this.push(encoded[index])
index++
},
})
const res = await next.fetch(basePath + '/edge/advanced/body/json', {
method: 'POST',
body: stream,
})
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.body).toEqual(body)
})
it('can read the text body', async () => {
const body = 'hello, world'
const res = await next.fetch(basePath + '/advanced/body/text', {
method: 'POST',
body,
})
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.body).toEqual(body)
})
it('can read the text body (edge)', async () => {
const body = 'hello, world'
const res = await next.fetch(basePath + '/edge/advanced/body/text', {
method: 'POST',
body,
})
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.body).toEqual(body)
})
})
describe('context', () => {
it('provides params to routes with dynamic parameters', async () => {
const res = await next.fetch(basePath + '/basic/vercel/endpoint')
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.params).toEqual({ tenantID: 'vercel' })
})
it('provides params to routes with catch-all routes', async () => {
const res = await next.fetch(
basePath + '/basic/vercel/some/other/resource'
)
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.params).toEqual({
tenantID: 'vercel',
resource: ['some', 'other', 'resource'],
})
})
it('does not provide params to routes without dynamic parameters', async () => {
const res = await next.fetch(basePath + '/basic/endpoint')
expect(res.ok).toBeTrue()
const meta = getRequestMeta(res.headers)
expect(meta.params).toEqual(null)
})
})
describe('hooks', () => {
describe('headers', () => {
it('gets the correct values', async () => {
const res = await next.fetch(basePath + '/hooks/headers', {
headers: withRequestMeta({ ping: 'pong' }),
})
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.ping).toEqual('pong')
})
})
describe('cookies', () => {
it('gets the correct values', async () => {
const res = await next.fetch(basePath + '/hooks/cookies', {
headers: cookieWithRequestMeta({ ping: 'pong' }),
})
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.ping).toEqual('pong')
})
})
describe('req.cookies', () => {
it('gets the correct values', async () => {
const res = await next.fetch(basePath + '/hooks/cookies/req', {
headers: cookieWithRequestMeta({ ping: 'pong' }),
})
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
expect(meta.ping).toEqual('pong')
})
})
describe('cookies().has()', () => {
it('gets the correct values', async () => {
const res = await next.fetch(basePath + '/hooks/cookies/has')
expect(res.status).toEqual(200)
expect(await res.json()).toEqual({ hasCookie: true })
})
})
describe('redirect', () => {
it('can respond correctly', async () => {
const res = await next.fetch(basePath + '/hooks/redirect', {
// "Manually" perform the redirect, we want to inspect the
// redirection response, so don't actually follow it.
redirect: 'manual',
})
expect(res.status).toEqual(307)
expect(res.headers.get('location')).toEqual('https://nextjs.org/')
expect(await res.text()).toBeEmpty()
})
})
describe('permanentRedirect', () => {
it('can respond correctly', async () => {
const res = await next.fetch(basePath + '/hooks/permanent-redirect', {
// "Manually" perform the redirect, we want to inspect the
// redirection response, so don't actually follow it.
redirect: 'manual',
})
expect(res.status).toEqual(308)
expect(res.headers.get('location')).toEqual('https://nextjs.org/')
expect(await res.text()).toBeEmpty()
})
})
describe('notFound', () => {
it('can respond correctly in nodejs', async () => {
const res = await next.fetch(basePath + '/hooks/not-found')
expect(res.status).toEqual(404)
expect(await res.text()).toBeEmpty()
})
it('can respond correctly in edge', async () => {
const res = await next.fetch(basePath + '/hooks/not-found/edge')
expect(res.status).toEqual(404)
expect(await res.text()).toBeEmpty()
})
})
})
describe('error conditions', () => {
it('responds with 400 (Bad Request) when the requested method is not a valid HTTP method', async () => {
const res = await next.fetch(basePath + '/status/405', {
method: 'HEADER',
})
expect(res.status).toEqual(400)
expect(await res.text()).toBeEmpty()
})
it('responds with 405 (Method Not Allowed) when method is not implemented', async () => {
const res = await next.fetch(basePath + '/status/405', {
method: 'POST',
})
expect(res.status).toEqual(405)
expect(await res.text()).toBeEmpty()
})
it('responds with 500 (Internal Server Error) when the handler throws an error', async () => {
const res = await next.fetch(basePath + '/status/500')
expect(res.status).toEqual(500)
expect(await res.text()).toBeEmpty()
})
it('responds with 500 (Internal Server Error) when the handler calls NextResponse.next()', async () => {
const error =
'https://nextjs.org/docs/messages/next-response-next-in-app-route-handler'
// Precondition. We shouldn't have seen this before. This ensures we're
// testing that the specific route throws this error in the console.
expect(next.cliOutput).not.toContain(error)
const res = await next.fetch(basePath + '/status/500/next')
expect(res.status).toEqual(500)
expect(await res.text()).toBeEmpty()
if (!isNextDeploy) {
await check(() => {
expect(next.cliOutput).toContain(error)
return 'yes'
}, 'yes')
}
})
})
describe('automatic implementations', () => {
it('implements HEAD on routes with GET already implemented', async () => {
const res = await next.fetch(basePath + '/methods/head', {
method: 'HEAD',
})
expect(res.status).toEqual(200)
expect(await res.text()).toBeEmpty()
})
it('implements OPTIONS on routes', async () => {
const res = await next.fetch(basePath + '/methods/options', {
method: 'OPTIONS',
})
expect(res.status).toEqual(204)
expect(await res.text()).toBeEmpty()
expect(res.headers.get('allow')).toEqual('OPTIONS, POST')
})
})
describe('edge functions', () => {
it('returns response using edge runtime', async () => {
const res = await next.fetch(basePath + '/edge')
expect(res.status).toEqual(200)
expect(await res.text()).toContain('hello, world')
})
it('returns a response when headers are accessed', async () => {
const meta = { ping: 'pong' }
const res = await next.fetch(basePath + '/edge/headers', {
headers: withRequestMeta(meta),
})
expect(res.status).toEqual(200)
expect(await res.json()).toEqual(meta)
})
})
describe('dynamic = "force-static"', () => {
it('strips search, headers, and domain from request', async () => {
const res = await next.fetch(basePath + '/dynamic?query=true', {
headers: {
accept: 'application/json',
cookie: 'session=true',
},
})
const url = 'http://localhost:3000/dynamic'
expect(res.status).toEqual(200)
expect(await res.json()).toEqual({
nextUrl: {
href: url,
search: '',
searchParams: null,
clone: url,
},
req: {
url,
headers: null,
},
headers: null,
cookies: null,
})
})
})
describe('customized metadata routes', () => {
it('should work if conflict with metadata routes convention', async () => {
const res = await next.fetch(basePath + '/robots.txt')
expect(res.status).toEqual(200)
expect(await res.text()).toBe(
'User-agent: *\nAllow: /\n\nSitemap: https://www.example.com/sitemap.xml'
)
})
})
if (isNextDev) {
describe('invalid exports', () => {
beforeAll(async () => {
await next.patchFile(
'app/default/route.ts',
`\
export { GET as default } from '../../handlers/hello'
`
)
})
afterAll(async () => {
await next.deleteFile('app/default/route.ts')
})
it('should print an error when exporting a default handler in dev', async () => {
await check(async () => {
const res = await next.fetch(basePath + '/default')
// Ensure we get a 405 (Method Not Allowed) response when there is no
// exported handler for the GET method.
expect(res.status).toEqual(405)
expect(next.cliOutput).toMatch(
/Detected default export in '.+\/route\.ts'\. Export a named export for each HTTP method instead\./
)
expect(next.cliOutput).toMatch(
/No HTTP methods exported in '.+\/route\.ts'\. Export a named export for each HTTP method\./
)
return 'yes'
}, 'yes')
})
})
}
describe('no response returned', () => {
it('should print an error when no response is returned', async () => {
await next.fetch(basePath + '/no-response', { method: 'POST' })
await check(() => {
expect(next.cliOutput).toMatch(
/No response is returned from route handler '.+\/route\.ts'\. Ensure you return a `Response` or a `NextResponse` in all branches of your handler\./
)
return 'yes'
}, 'yes')
})
})
describe('no bundle error', () => {
it('should not print bundling warning about React', async () => {
const cliOutput = next.cliOutput
expect(cliOutput).not.toContain('Attempted import error')
})
})
}
)