{{ message }}
Repository navigation
Expand file tree
/
Copy pathproxy.test.ts
More file actions
231 lines (204 loc) · 8.7 KB
/
Copy pathproxy.test.ts
File metadata and controls
231 lines (204 loc) · 8.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
import { resetEnvMock, setEnv } from '@sim/testing/mocks/env.mock'
import { unstable_doesMiddlewareMatch } from 'next/experimental/testing/server'
import { NextRequest } from 'next/server'
import { afterAll, afterEach, describe, expect, it } from 'vitest'
import { config, proxy, resolveApiCorsPolicy } from '@/proxy'
setEnv({
NEXT_PUBLIC_APP_URL: 'https://app.sim.test',
SIM_MCP_URL: 'https://mcp.sim.test/mcp',
})
afterAll(resetEnvMock)
const EXPOSED_HEADERS =
'Retry-After, WWW-Authenticate, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-Request-Id, X-Run-Id'
function makeRequest(pathname: string, origin?: string): NextRequest {
return {
nextUrl: { pathname },
headers: {
get: (name: string) => (name.toLowerCase() === 'origin' ? (origin ?? null) : null),
},
} as unknown as NextRequest
}
describe('resolveApiCorsPolicy', () => {
it('serves OAuth2 routes with wildcard origin and no credentials', () => {
expect(resolveApiCorsPolicy(makeRequest('/api/auth/oauth2/token'))).toEqual({
origin: '*',
credentials: false,
methods: 'GET, POST, OPTIONS',
headers: 'Content-Type, Authorization, Accept',
exposeHeaders: EXPOSED_HEADERS,
})
})
it('serves OAuth discovery documents read-only with wildcard origin', () => {
expect(
resolveApiCorsPolicy(makeRequest('/api/auth/.well-known/oauth-authorization-server'))
).toEqual({
origin: '*',
credentials: false,
methods: 'GET, OPTIONS',
headers: 'Content-Type, Accept',
exposeHeaders: EXPOSED_HEADERS,
})
})
it('reflects origin for chat embeds with credentials enabled', () => {
const paths = ['/api/chat/abc', '/api/chat/abc/otp', '/api/chat/abc/sso']
for (const path of paths) {
const policy = resolveApiCorsPolicy(makeRequest(path, 'https://customer.example'))
expect(policy).toEqual({
origin: 'https://customer.example',
credentials: true,
methods: 'GET, POST, PUT, OPTIONS',
headers: 'Content-Type, X-Requested-With',
exposeHeaders: EXPOSED_HEADERS,
})
}
})
it('drops credentials on embed policy when Origin header is absent (CORS spec invariant)', () => {
const policy = resolveApiCorsPolicy(makeRequest('/api/chat/abc'))
expect(policy.origin).toBe('*')
expect(policy.credentials).toBe(false)
})
it('serves workflow execute with wildcard origin and execution identity header', () => {
const policy = resolveApiCorsPolicy(
makeRequest('/api/workflows/workflow-123/execute', 'https://other.example')
)
expect(policy.origin).toBe('*')
expect(policy.credentials).toBe(false)
expect(policy.methods).toContain('PUT')
expect(policy.headers).toContain('X-Execution-Id')
expect(policy.headers).toContain('X-Execution-Timeout-Seconds')
})
it('does not match the v2 execute rule for nested or runs paths', () => {
const nested = resolveApiCorsPolicy(
makeRequest('/api/v2/workflows/workflow-123/execute/extra', 'https://other.example')
)
expect(nested.origin).toBe('https://app.sim.test')
const runs = resolveApiCorsPolicy(
makeRequest('/api/v2/workflows/workflow-123/runs/run-1', 'https://other.example')
)
expect(runs.origin).toBe('https://app.sim.test')
})
it('returns default policy with APP_URL and credentials for other API routes', () => {
const policy = resolveApiCorsPolicy(makeRequest('/api/files/uploads'))
expect(policy).toEqual({
origin: 'https://app.sim.test',
credentials: true,
methods: 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS',
exposeHeaders: EXPOSED_HEADERS,
headers: expect.stringContaining('Authorization'),
})
})
it('exposes the response headers on every matched rule, so a new rule cannot drop them', () => {
const paths = [
'/api/auth/oauth2/token',
'/api/mcp/copilot',
'/api/chat/abc',
'/api/workflows/wf/execute',
'/api/v2/workflows/wf/execute',
'/api/files/uploads',
]
for (const path of paths) {
expect(resolveApiCorsPolicy(makeRequest(path)).exposeHeaders).toBe(EXPOSED_HEADERS)
}
})
it('never pairs wildcard origin with credentials (CORS spec invariant)', () => {
const paths = [
'/api/auth/oauth2/token',
'/api/mcp/copilot',
'/api/chat/abc',
'/api/workflows/wf/execute',
'/api/v2/workflows/wf/execute',
'/api/files/uploads',
]
for (const path of paths) {
const policy = resolveApiCorsPolicy(makeRequest(path))
if (policy.origin === '*') {
expect(policy.credentials).toBe(false)
}
}
})
})
describe('proxy on the dedicated MCP host', () => {
function mcpRequest(pathname: string, method = 'POST') {
return new NextRequest(`https://mcp.sim.test${pathname}`, {
method,
headers: { host: 'mcp.sim.test', origin: 'https://app.sim.test' },
})
}
it('answers the endpoint preflight with the API CORS policy', () => {
const response = proxy(mcpRequest('/mcp', 'OPTIONS'))
expect(response.status).toBe(204)
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://app.sim.test')
expect(response.headers.get('Access-Control-Allow-Headers')).toContain('Authorization')
})
it('serves nothing else on the MCP host', () => {
expect(proxy(mcpRequest('/login', 'GET')).status).toBe(404)
})
it.each(['', 'python-requests/2.32.3'])(
'allows ownership verification with an automated user agent: %s',
(userAgent) => {
const request = mcpRequest('/.well-known/openai-apps-challenge', 'GET')
if (userAgent) request.headers.set('user-agent', userAgent)
expect(proxy(request).status).toBe(200)
expect(proxy(mcpRequest('/.well-known/openai-apps-challenge/other', 'GET')).status).toBe(404)
}
)
})
describe('proxy indexing policy', () => {
afterEach(resetEnvFlagsMock)
const robotsTag = (pathname: string) =>
proxy(new NextRequest(`https://self-hosted.example${pathname}`)).headers.get('X-Robots-Tag')
it('keeps the marketing pages of a self-hosted deployment out of search results', () => {
setEnvFlags({ isHosted: false })
expect(robotsTag('/comparisons/n8n')).toBe('noindex, nofollow')
expect(robotsTag('/pricing')).toBe('noindex, nofollow')
})
it('keeps the marketing documents of a self-hosted deployment out of search results', () => {
setEnvFlags({ isHosted: false })
expect(robotsTag('/index.md')).toBe('noindex, nofollow')
expect(robotsTag('/llms.txt')).toBe('noindex, nofollow')
expect(robotsTag('/llms-full.txt')).toBe('noindex, nofollow')
expect(robotsTag('/changelog.xml')).toBe('noindex, nofollow')
})
it('leaves the marketing pages and documents of the hosted deployment indexable', () => {
setEnvFlags({ isHosted: true })
expect(robotsTag('/comparisons/n8n')).toBeNull()
expect(robotsTag('/pricing')).toBeNull()
expect(robotsTag('/index.md')).toBeNull()
expect(robotsTag('/llms.txt')).toBeNull()
expect(robotsTag('/llms-full.txt')).toBeNull()
expect(robotsTag('/changelog.xml')).toBeNull()
})
it('leaves a self-hosted non-landing public page indexable', () => {
setEnvFlags({ isHosted: false })
expect(robotsTag('/login')).toBeNull()
})
})
describe('proxy matcher', () => {
const matches = (path: string) =>
unstable_doesMiddlewareMatch({ config, url: `https://sim.test${path}` })
it('leaves the desktop raw upload routes unbuffered, so a whole file reaches the route', () => {
expect(matches('/api/desktop/tool/import')).toBe(false)
expect(matches('/api/desktop/tool/file')).toBe(false)
})
it('leaves the document snapshot persists unbuffered, so a whole snapshot reaches the route', () => {
expect(matches('/api/internal/file-doc/persist')).toBe(false)
expect(matches('/api/internal/project-file-doc/project-1/file-1/persist')).toBe(false)
})
it('runs for the marketing documents, so the indexing policy reaches them', () => {
expect(matches('/index.md')).toBe(true)
expect(matches('/llms.txt')).toBe(true)
expect(matches('/llms-full.txt')).toBe(true)
expect(matches('/changelog.xml')).toBe(true)
})
it('still runs for every other API route', () => {
expect(matches('/api/desktop/tool/claim')).toBe(true)
expect(matches('/api/desktop/tool/import/other')).toBe(true)
expect(matches('/api/workflows/wf-1/execute')).toBe(true)
expect(matches('/api/files/upload')).toBe(true)
expect(matches('/api/internal/project-file-doc/project-1/file-1/seed')).toBe(true)
expect(matches('/api/internal/project-file-doc/project-1/file-1/persist/other')).toBe(true)
expect(matches('/api/internal/file-doc/persist/other')).toBe(true)
expect(matches('/api/internal/file-doc/seed')).toBe(true)
})
})
You can’t perform that action at this time.
