Skip to content

Commit 48c0865

Browse files
committed
test: add resolveProxySecret and verifyProxyRequest tests
- resolveProxySecret: config precedence, env var fallback, .env auto-gen, existing key detection, newline handling, read-only FS fallback, process.env population (10 tests) - verifyProxyRequest: URL signature mode, page token mode, expired tokens, tampered sigs, empty secret, any-params page token, dual-mode precedence (8 tests) Total: 54 tests across sign.test.ts and resolve-proxy-secret.test.ts
1 parent eee5ffc commit 48c0865

2 files changed

Lines changed: 178 additions & 0 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5+
import { resolveProxySecret } from '../../packages/script/src/module'
6+
7+
const ENV_KEY = 'NUXT_SCRIPTS_PROXY_SECRET'
8+
9+
describe('resolveProxySecret', () => {
10+
let testDir: string
11+
let savedEnv: string | undefined
12+
13+
beforeEach(() => {
14+
testDir = join(tmpdir(), `nuxt-scripts-test-${Date.now()}`)
15+
mkdirSync(testDir, { recursive: true })
16+
savedEnv = process.env[ENV_KEY]
17+
delete process.env[ENV_KEY]
18+
})
19+
20+
afterEach(() => {
21+
rmSync(testDir, { recursive: true, force: true })
22+
if (savedEnv !== undefined)
23+
process.env[ENV_KEY] = savedEnv
24+
else
25+
delete process.env[ENV_KEY]
26+
})
27+
28+
it('returns config secret with highest priority', () => {
29+
process.env[ENV_KEY] = 'env-secret'
30+
const result = resolveProxySecret(testDir, true, 'config-secret')
31+
expect(result).toEqual({ secret: 'config-secret', ephemeral: false, source: 'config' })
32+
})
33+
34+
it('falls back to env var when no config secret', () => {
35+
process.env[ENV_KEY] = 'env-secret'
36+
const result = resolveProxySecret(testDir, true)
37+
expect(result).toEqual({ secret: 'env-secret', ephemeral: false, source: 'env' })
38+
})
39+
40+
it('returns undefined in prod when no secret is available', () => {
41+
const result = resolveProxySecret(testDir, false)
42+
expect(result).toBeUndefined()
43+
})
44+
45+
it('returns undefined when autoGenerate is false even in dev', () => {
46+
const result = resolveProxySecret(testDir, true, undefined, false)
47+
expect(result).toBeUndefined()
48+
})
49+
50+
it('auto-generates and writes to .env in dev when file does not exist', () => {
51+
const result = resolveProxySecret(testDir, true)
52+
expect(result).toBeDefined()
53+
expect(result!.source).toBe('dotenv-generated')
54+
expect(result!.ephemeral).toBe(false)
55+
expect(result!.secret).toHaveLength(64) // 32 bytes hex
56+
57+
const envContent = readFileSync(join(testDir, '.env'), 'utf-8')
58+
expect(envContent).toContain(`${ENV_KEY}=${result!.secret}`)
59+
expect(envContent).toContain('# Generated by @nuxt/scripts')
60+
})
61+
62+
it('appends to existing .env in dev', () => {
63+
writeFileSync(join(testDir, '.env'), 'OTHER_VAR=value\n')
64+
const result = resolveProxySecret(testDir, true)
65+
expect(result!.source).toBe('dotenv-generated')
66+
67+
const envContent = readFileSync(join(testDir, '.env'), 'utf-8')
68+
expect(envContent).toContain('OTHER_VAR=value')
69+
expect(envContent).toContain(`${ENV_KEY}=`)
70+
})
71+
72+
it('returns existing secret from .env without generating a new one', () => {
73+
writeFileSync(join(testDir, '.env'), `${ENV_KEY}=existing-secret-value\n`)
74+
const result = resolveProxySecret(testDir, true)
75+
expect(result).toEqual({ secret: 'existing-secret-value', ephemeral: false, source: 'dotenv-generated' })
76+
77+
// Should not have written a second line
78+
const envContent = readFileSync(join(testDir, '.env'), 'utf-8')
79+
const matches = envContent.match(new RegExp(ENV_KEY, 'g'))
80+
expect(matches).toHaveLength(1)
81+
})
82+
83+
it('populates process.env after generating a new secret', () => {
84+
resolveProxySecret(testDir, true)
85+
expect(process.env[ENV_KEY]).toBeDefined()
86+
expect(process.env[ENV_KEY]).toHaveLength(64)
87+
})
88+
89+
it('falls back to in-memory when .env dir is read-only', () => {
90+
// Use a non-existent deeply nested path that can't be written
91+
const result = resolveProxySecret('/proc/nonexistent/path', true)
92+
expect(result).toBeDefined()
93+
expect(result!.source).toBe('memory-generated')
94+
expect(result!.ephemeral).toBe(true)
95+
expect(result!.secret).toHaveLength(64)
96+
})
97+
98+
it('adds newline before appending when .env does not end with newline', () => {
99+
writeFileSync(join(testDir, '.env'), 'OTHER_VAR=value')
100+
resolveProxySecret(testDir, true)
101+
const envContent = readFileSync(join(testDir, '.env'), 'utf-8')
102+
// Should have a newline between existing content and new key
103+
expect(envContent).toMatch(/value\n.*NUXT_SCRIPTS_PROXY_SECRET=/)
104+
})
105+
})

test/unit/sign.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,32 @@
1+
import type { H3Event } from 'h3'
12
import { describe, expect, it } from 'vitest'
23
import {
34
buildSignedProxyUrl,
45
canonicalizeQuery,
56
constantTimeEqual,
67
generateProxyToken,
78
PAGE_TOKEN_MAX_AGE,
9+
PAGE_TOKEN_PARAM,
10+
PAGE_TOKEN_TS_PARAM,
811
SIG_LENGTH,
912
SIG_PARAM,
1013
signProxyUrl,
14+
verifyProxyRequest,
1115
verifyProxyToken,
1216
} from '../../packages/script/src/runtime/server/utils/sign'
1317

18+
/** Create a minimal mock H3Event with a path and query params. */
19+
function mockEvent(url: string): H3Event {
20+
const parsed = new URL(url, 'http://localhost')
21+
const query: Record<string, string> = {}
22+
for (const [k, v] of parsed.searchParams.entries())
23+
query[k] = v
24+
return {
25+
path: parsed.pathname + parsed.search,
26+
_query: query,
27+
} as unknown as H3Event
28+
}
29+
1430
const SECRET = 'test-secret-9f2c8b4e7a1d6f3c5b9e8a2d4f7c1b6e'
1531

1632
describe('canonicalizeQuery', () => {
@@ -215,3 +231,60 @@ describe('verifyProxyToken', () => {
215231
expect(verifyProxyToken(token, ts, 'wrong-secret')).toBe(false)
216232
})
217233
})
234+
235+
describe('verifyProxyRequest', () => {
236+
it('verifies a valid URL signature (mode 1)', () => {
237+
const url = buildSignedProxyUrl('/_scripts/proxy/x', { center: 'Sydney' }, SECRET)
238+
const event = mockEvent(url)
239+
expect(verifyProxyRequest(event, SECRET)).toBe(true)
240+
})
241+
242+
it('rejects a tampered URL signature', () => {
243+
const url = buildSignedProxyUrl('/_scripts/proxy/x', { center: 'Sydney' }, SECRET)
244+
const tampered = url.replace(/sig=[0-9a-f]+/, 'sig=0000000000000000')
245+
const event = mockEvent(tampered)
246+
expect(verifyProxyRequest(event, SECRET)).toBe(false)
247+
})
248+
249+
it('rejects a request with no sig and no page token', () => {
250+
const event = mockEvent('/_scripts/proxy/x?center=Sydney')
251+
expect(verifyProxyRequest(event, SECRET)).toBe(false)
252+
})
253+
254+
it('returns false when secret is empty', () => {
255+
const url = buildSignedProxyUrl('/_scripts/proxy/x', { center: 'Sydney' }, SECRET)
256+
const event = mockEvent(url)
257+
expect(verifyProxyRequest(event, '')).toBe(false)
258+
})
259+
260+
it('verifies a valid page token (mode 2)', () => {
261+
const ts = Math.floor(Date.now() / 1000)
262+
const token = generateProxyToken(SECRET, ts)
263+
const event = mockEvent(`/_scripts/proxy/x?center=Sydney&${PAGE_TOKEN_PARAM}=${token}&${PAGE_TOKEN_TS_PARAM}=${ts}`)
264+
expect(verifyProxyRequest(event, SECRET)).toBe(true)
265+
})
266+
267+
it('rejects an expired page token', () => {
268+
const ts = Math.floor(Date.now() / 1000) - PAGE_TOKEN_MAX_AGE - 100
269+
const token = generateProxyToken(SECRET, ts)
270+
const event = mockEvent(`/_scripts/proxy/x?center=Sydney&${PAGE_TOKEN_PARAM}=${token}&${PAGE_TOKEN_TS_PARAM}=${ts}`)
271+
expect(verifyProxyRequest(event, SECRET)).toBe(false)
272+
})
273+
274+
it('allows page token with different query params than original (any-params mode)', () => {
275+
const ts = Math.floor(Date.now() / 1000)
276+
const token = generateProxyToken(SECRET, ts)
277+
// Token was generated without any query context, so it works with any params
278+
const event = mockEvent(`/_scripts/proxy/x?center=Melbourne&zoom=10&${PAGE_TOKEN_PARAM}=${token}&${PAGE_TOKEN_TS_PARAM}=${ts}`)
279+
expect(verifyProxyRequest(event, SECRET)).toBe(true)
280+
})
281+
282+
it('prefers URL signature over page token when both are present', () => {
283+
const ts = Math.floor(Date.now() / 1000)
284+
const pageToken = generateProxyToken(SECRET, ts)
285+
// Build a signed URL and also add a page token
286+
const signedUrl = buildSignedProxyUrl('/_scripts/proxy/x', { center: 'Sydney' }, SECRET)
287+
const event = mockEvent(`${signedUrl}&${PAGE_TOKEN_PARAM}=${pageToken}&${PAGE_TOKEN_TS_PARAM}=${ts}`)
288+
expect(verifyProxyRequest(event, SECRET)).toBe(true)
289+
})
290+
})

0 commit comments

Comments
 (0)