Skip to content

Commit 5382dfe

Browse files
authored
fix: correct inverted CORS guard in bootstrapRemoteTemplate (#547)
1 parent 3d1fb4d commit 5382dfe

File tree

2 files changed

+228
-3
lines changed

2 files changed

+228
-3
lines changed
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import {type Output} from '@sanity/cli-core'
2+
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
3+
4+
import {bootstrapRemoteTemplate} from '../bootstrapRemoteTemplate.js'
5+
6+
const mocks = vi.hoisted(() => ({
7+
applyEnvVariables: vi.fn(),
8+
checkIfNeedsApiToken: vi.fn(),
9+
createCorsOrigin: vi.fn(),
10+
createToken: vi.fn(),
11+
detectFrameworkRecord: vi.fn(),
12+
downloadAndExtractRepo: vi.fn(),
13+
getDefaultPortForFramework: vi.fn(),
14+
getGitHubRawContentUrl: vi.fn(),
15+
getMonoRepo: vi.fn(),
16+
mkdir: vi.fn(),
17+
spinner: vi.fn(),
18+
tryApplyPackageName: vi.fn(),
19+
tryGitInit: vi.fn(),
20+
updateInitialTemplateMetadata: vi.fn(),
21+
validateTemplate: vi.fn(),
22+
}))
23+
24+
vi.mock('node:fs/promises', () => ({mkdir: mocks.mkdir}))
25+
26+
vi.mock('@sanity/template-validator', () => ({
27+
getMonoRepo: mocks.getMonoRepo,
28+
GitHubFileReader: vi.fn(),
29+
validateTemplate: mocks.validateTemplate,
30+
}))
31+
32+
vi.mock('@vercel/frameworks', () => ({frameworks: []}))
33+
34+
vi.mock('@vercel/fs-detectors', () => ({
35+
detectFrameworkRecord: mocks.detectFrameworkRecord,
36+
LocalFileSystemDetector: vi.fn(),
37+
}))
38+
39+
vi.mock('@sanity/cli-core/ux', () => ({
40+
logSymbols: {success: '✔'},
41+
spinner: mocks.spinner,
42+
}))
43+
44+
vi.mock('../../../services/cors.js', () => ({createCorsOrigin: mocks.createCorsOrigin}))
45+
vi.mock('../../../services/tokens.js', () => ({createToken: mocks.createToken}))
46+
vi.mock('../../../util/frameworkPort.js', () => ({
47+
getDefaultPortForFramework: mocks.getDefaultPortForFramework,
48+
}))
49+
50+
vi.mock('../remoteTemplate.js', () => ({
51+
applyEnvVariables: mocks.applyEnvVariables,
52+
checkIfNeedsApiToken: mocks.checkIfNeedsApiToken,
53+
downloadAndExtractRepo: mocks.downloadAndExtractRepo,
54+
getGitHubRawContentUrl: mocks.getGitHubRawContentUrl,
55+
tryApplyPackageName: mocks.tryApplyPackageName,
56+
}))
57+
58+
vi.mock('../git.js', () => ({tryGitInit: mocks.tryGitInit}))
59+
60+
vi.mock('../updateInitialTemplateMetadata.js', () => ({
61+
updateInitialTemplateMetadata: mocks.updateInitialTemplateMetadata,
62+
}))
63+
64+
const mockOutput = {log: vi.fn()} as unknown as Output
65+
66+
const baseOpts = {
67+
output: mockOutput,
68+
outputPath: '/tmp/test-bootstrap',
69+
packageName: 'test-project',
70+
repoInfo: {
71+
branch: 'main',
72+
filePath: '',
73+
name: 'test-template',
74+
username: 'sanity-io',
75+
},
76+
variables: {
77+
autoUpdates: false,
78+
dataset: 'production',
79+
projectId: 'test-project-id',
80+
},
81+
}
82+
83+
describe('bootstrapRemoteTemplate', () => {
84+
beforeEach(() => {
85+
mocks.applyEnvVariables.mockResolvedValue(undefined)
86+
mocks.checkIfNeedsApiToken.mockResolvedValue(false)
87+
mocks.createCorsOrigin.mockResolvedValue({})
88+
mocks.createToken.mockResolvedValue({key: 'test-token'})
89+
mocks.detectFrameworkRecord.mockResolvedValue(null)
90+
mocks.downloadAndExtractRepo.mockResolvedValue(undefined)
91+
mocks.getDefaultPortForFramework.mockReturnValue(3000)
92+
mocks.getGitHubRawContentUrl.mockReturnValue(
93+
'https://raw.githubusercontent.com/sanity-io/test-template/main/',
94+
)
95+
mocks.getMonoRepo.mockResolvedValue(null)
96+
mocks.mkdir.mockResolvedValue(undefined)
97+
mocks.spinner.mockReturnValue({start: vi.fn().mockReturnThis(), succeed: vi.fn()})
98+
mocks.tryApplyPackageName.mockResolvedValue(undefined)
99+
mocks.updateInitialTemplateMetadata.mockResolvedValue(undefined)
100+
mocks.validateTemplate.mockResolvedValue({isValid: true})
101+
})
102+
103+
afterEach(() => {
104+
vi.resetAllMocks()
105+
})
106+
107+
describe('CORS origin setup', () => {
108+
test('adds CORS origin for a framework port that is not the Sanity default (3333)', async () => {
109+
await bootstrapRemoteTemplate(baseOpts)
110+
111+
expect(mocks.createCorsOrigin).toHaveBeenCalledOnce()
112+
expect(mocks.createCorsOrigin).toHaveBeenCalledWith({
113+
allowCredentials: true,
114+
origin: 'http://localhost:3000',
115+
projectId: 'test-project-id',
116+
})
117+
})
118+
119+
test('does not add CORS origin for the Sanity default port (3333)', async () => {
120+
mocks.getDefaultPortForFramework.mockReturnValue(3333)
121+
122+
await bootstrapRemoteTemplate(baseOpts)
123+
124+
expect(mocks.createCorsOrigin).not.toHaveBeenCalled()
125+
})
126+
127+
test('does not add CORS origin twice when multiple packages resolve to the same port', async () => {
128+
mocks.getMonoRepo.mockResolvedValue(['app', 'studio'])
129+
mocks.getDefaultPortForFramework.mockReturnValue(3000)
130+
131+
await bootstrapRemoteTemplate(baseOpts)
132+
133+
expect(mocks.createCorsOrigin).toHaveBeenCalledOnce()
134+
})
135+
136+
test('adds distinct CORS origins for packages on different ports', async () => {
137+
mocks.getMonoRepo.mockResolvedValue(['frontend', 'backend'])
138+
mocks.getDefaultPortForFramework
139+
.mockReturnValueOnce(3000) // frontend
140+
.mockReturnValueOnce(8080) // backend
141+
142+
await bootstrapRemoteTemplate(baseOpts)
143+
144+
expect(mocks.createCorsOrigin).toHaveBeenCalledTimes(2)
145+
expect(mocks.createCorsOrigin).toHaveBeenCalledWith(
146+
expect.objectContaining({origin: 'http://localhost:3000'}),
147+
)
148+
expect(mocks.createCorsOrigin).toHaveBeenCalledWith(
149+
expect.objectContaining({origin: 'http://localhost:8080'}),
150+
)
151+
})
152+
153+
test('logs newly added CORS origins but not the pre-seeded default port', async () => {
154+
await bootstrapRemoteTemplate(baseOpts)
155+
156+
const logCalls = vi.mocked(mockOutput.log).mock.calls.flat()
157+
expect(logCalls.some((msg) => msg.includes('localhost:3000'))).toBe(true)
158+
expect(logCalls.some((msg) => msg.includes('localhost:3333'))).toBe(false)
159+
})
160+
161+
test('logs nothing for CORS when the only port is the pre-seeded default (3333)', async () => {
162+
mocks.getDefaultPortForFramework.mockReturnValue(3333)
163+
164+
await bootstrapRemoteTemplate(baseOpts)
165+
166+
const logCalls = vi.mocked(mockOutput.log).mock.calls.flat()
167+
expect(logCalls.some((msg) => msg.includes('CORS origins added'))).toBe(false)
168+
})
169+
})
170+
171+
describe('template validation', () => {
172+
test('throws when the remote template fails validation', async () => {
173+
mocks.validateTemplate.mockResolvedValueOnce({
174+
errors: ['Missing sanity.config.ts', 'Missing package.json'],
175+
isValid: false,
176+
})
177+
178+
await expect(bootstrapRemoteTemplate(baseOpts)).rejects.toThrow(
179+
'Missing sanity.config.ts\nMissing package.json',
180+
)
181+
})
182+
183+
test('does not proceed to download when validation fails', async () => {
184+
mocks.validateTemplate.mockResolvedValueOnce({
185+
errors: ['Missing sanity.config.ts'],
186+
isValid: false,
187+
})
188+
189+
await expect(bootstrapRemoteTemplate(baseOpts)).rejects.toThrow()
190+
191+
expect(mocks.downloadAndExtractRepo).not.toHaveBeenCalled()
192+
})
193+
})
194+
195+
describe('API token creation', () => {
196+
test('creates a read token when the template requires one', async () => {
197+
mocks.checkIfNeedsApiToken.mockImplementation((_path: string, type: string) =>
198+
Promise.resolve(type === 'read'),
199+
)
200+
201+
await bootstrapRemoteTemplate(baseOpts)
202+
203+
expect(mocks.createToken).toHaveBeenCalledOnce()
204+
expect(mocks.createToken).toHaveBeenCalledWith(expect.objectContaining({roleName: 'viewer'}))
205+
})
206+
207+
test('creates a write token when the template requires one', async () => {
208+
mocks.checkIfNeedsApiToken.mockImplementation((_path: string, type: string) =>
209+
Promise.resolve(type === 'write'),
210+
)
211+
212+
await bootstrapRemoteTemplate(baseOpts)
213+
214+
expect(mocks.createToken).toHaveBeenCalledOnce()
215+
expect(mocks.createToken).toHaveBeenCalledWith(expect.objectContaining({roleName: 'editor'}))
216+
})
217+
218+
test('does not create any tokens when the template requires none', async () => {
219+
await bootstrapRemoteTemplate(baseOpts)
220+
221+
expect(mocks.createToken).not.toHaveBeenCalled()
222+
})
223+
})
224+
})

packages/@sanity/cli/src/actions/init/bootstrapRemoteTemplate.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export async function bootstrapRemoteTemplate(opts: BootstrapRemoteOptions): Pro
102102
})
103103

104104
const port = getDefaultPortForFramework(packageFramework?.slug)
105-
if (corsAdded.includes(port)) {
105+
if (!corsAdded.includes(port)) {
106106
debug('Setting CORS origin to http://localhost:%d', port)
107107
await createCorsOrigin({
108108
allowCredentials: true,
@@ -128,9 +128,10 @@ export async function bootstrapRemoteTemplate(opts: BootstrapRemoteOptions): Pro
128128
await updateInitialTemplateMetadata(variables.projectId, `external-${name}`)
129129

130130
spin.succeed()
131-
if (corsAdded.length > 0) {
131+
const newlyAddedPorts = corsAdded.slice(1)
132+
if (newlyAddedPorts.length > 0) {
132133
output.log(
133-
`${logSymbols.success} CORS origins added (${corsAdded.map((p) => `localhost:${p}`).join(', ')})`,
134+
`${logSymbols.success} CORS origins added (${newlyAddedPorts.map((p) => `localhost:${p}`).join(', ')})`,
134135
)
135136
}
136137
if (readToken) output.log(`${logSymbols.success} API token generated (${READ_TOKEN_LABEL})`)

0 commit comments

Comments
 (0)