Skip to content

Commit ee5cdeb

Browse files
committed
fix(docs): validate feedback request authority and exercise retries
1 parent 1d62b32 commit ee5cdeb

3 files changed

Lines changed: 118 additions & 2 deletions

File tree

‎.github/workflows/docs-negotiation.yml‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,9 +226,16 @@ jobs:
226226
if: always() && steps.build.outcome == 'success'
227227
run: |
228228
mkdir -p apps/docs/test-results
229+
FEEDBACK_TLS_DIR=$(mktemp -d)
230+
export DOCS_FEEDBACK_TEST_CERT="$FEEDBACK_TLS_DIR/cert.pem"
231+
export DOCS_FEEDBACK_TEST_KEY="$FEEDBACK_TLS_DIR/key.pem"
232+
openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj '/CN=127.0.0.1' -addext 'subjectAltName=IP:127.0.0.1' -keyout "$DOCS_FEEDBACK_TEST_KEY" -out "$DOCS_FEEDBACK_TEST_CERT" 2>/dev/null
233+
export NODE_EXTRA_CA_CERTS="$DOCS_FEEDBACK_TEST_CERT"
234+
export DOCS_FEEDBACK_WEBHOOK_URL=https://127.0.0.1:3443/feedback
235+
export DOCS_FEEDBACK_WEBHOOK_TOKEN=$(openssl rand -hex 32)
229236
bun run --cwd apps/docs start --port 3001 >apps/docs/test-results/docs-server.log 2>&1 &
230237
DOCS_SERVER_PID=$!
231-
trap 'kill "$DOCS_SERVER_PID" 2>/dev/null || true' EXIT
238+
trap 'kill "$DOCS_SERVER_PID" 2>/dev/null || true; rm -rf "$FEEDBACK_TLS_DIR"' EXIT
232239
STATUS=0
233240
bun run --cwd apps/docs test:negotiation:e2e || STATUS=1
234241
bun run --cwd apps/docs scripts/test-docs-interfaces-e2e.ts || STATUS=1

‎apps/docs/app/api/feedback/route.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ const MAX_BODY_BYTES = 12_288
1717
export async function POST(request: Request) {
1818
const headers = { 'Cache-Control': 'no-store' }
1919
const fail = (error: string, status: number) => Response.json({ error }, { status, headers })
20-
if (request.headers.get('origin') !== new URL(request.url).origin) {
20+
const requestUrl = new URL(request.url)
21+
const host = request.headers.get('host')
22+
if (!host || request.headers.get('origin') !== `${requestUrl.protocol}//${host}`) {
2123
return fail('Feedback must be submitted from this site.', 403)
2224
}
2325
if (request.headers.get('content-type')?.split(';')[0].trim() !== 'application/json') {

‎apps/docs/scripts/test-navigation-e2e.ts‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { createLogger } from '@sim/logger'
1515
import { getErrorMessage } from '@sim/utils/errors'
1616
import { sleep } from '@sim/utils/helpers'
17+
import { toRecord } from '@sim/utils/object'
1718

1819
const logger = createLogger('DocsNavigationE2E', { logLevel: 'INFO' })
1920
const origin = process.env.DOCS_E2E_BASE_URL
@@ -162,6 +163,112 @@ async function verifyClipboard(
162163
let browser: Browser | undefined
163164
try {
164165
browser = await (engine === 'webkit' ? webkit : chromium).launch()
166+
const feedbackBrowser = browser
167+
await check('Feedback forwards votes and preserves unchanged retry identity', async () => {
168+
const cert = process.env.DOCS_FEEDBACK_TEST_CERT
169+
const key = process.env.DOCS_FEEDBACK_TEST_KEY
170+
const token = process.env.DOCS_FEEDBACK_WEBHOOK_TOKEN
171+
if (!cert || !key || !token) throw new Error('Feedback HTTPS fixture configuration is required')
172+
const received: Array<{ authorization: string | null; body: Record<string, unknown> }> = []
173+
let accept = false
174+
const intake = Bun.serve({
175+
hostname: '127.0.0.1',
176+
port: 3443,
177+
tls: { cert: await readFile(cert), key: await readFile(key) },
178+
async fetch(request) {
179+
if (received.length >= 8) return new Response(null, { status: 429 })
180+
received.push({
181+
authorization: request.headers.get('authorization'),
182+
body: toRecord(await request.json()),
183+
})
184+
return Response.json(
185+
{ message: accept ? 'Webhook processed' : 'Unavailable' },
186+
{ status: accept ? 200 : 503 }
187+
)
188+
},
189+
})
190+
let context: BrowserContext | undefined
191+
try {
192+
context = await feedbackBrowser.newContext()
193+
const page = await context.newPage()
194+
const started = Date.now()
195+
await open(page, '/introduction')
196+
await page.getByRole('button', { name: 'No, this could be improved', exact: true }).click()
197+
const comment = page.getByLabel('What could be better? (optional)', { exact: true })
198+
await comment.fill(' Explain deployment prerequisites. ')
199+
const send = page.getByRole('button', { name: 'Send feedback', exact: true })
200+
for (let attempt = 0; attempt < 2; attempt++) {
201+
const response = page.waitForResponse(
202+
(response) => new URL(response.url()).pathname === '/api/feedback'
203+
)
204+
await send.click()
205+
expect((await response).status()).toBe(502)
206+
await expect(page.getByRole('alert')).toContainText('Could not send feedback.')
207+
await expect(comment).toHaveValue(' Explain deployment prerequisites. ')
208+
await expect(send).toBeEnabled()
209+
}
210+
expect(received).toHaveLength(2)
211+
expect(received[1].body.submission_id).toBe(received[0].body.submission_id)
212+
expect(received[0].body.comment).toBe('Explain deployment prerequisites.')
213+
accept = true
214+
await comment.fill(' Explain deployment permissions. ')
215+
const accepted = page.waitForResponse(
216+
(response) => new URL(response.url()).pathname === '/api/feedback'
217+
)
218+
await send.click()
219+
expect((await accepted).status()).toBe(202)
220+
await expect(
221+
page.getByRole('status').filter({ hasText: 'Thanks for your feedback.' })
222+
).toBeVisible()
223+
expect(received).toHaveLength(3)
224+
expect(received[2].body.submission_id).not.toBe(received[1].body.submission_id)
225+
expect(received[2].body).toMatchObject({
226+
page: '/introduction',
227+
helpful: false,
228+
comment: 'Explain deployment permissions.',
229+
})
230+
await page
231+
.getByRole('navigation', { name: 'Page navigation' })
232+
.getByRole('link', { name: 'Next Getting started' })
233+
.click()
234+
const positive = page.waitForResponse(
235+
(response) => new URL(response.url()).pathname === '/api/feedback'
236+
)
237+
await page.getByRole('button', { name: 'Yes, this was helpful', exact: true }).click()
238+
expect((await positive).status()).toBe(202)
239+
await expect(
240+
page.getByRole('status').filter({ hasText: 'Thanks for your feedback.' })
241+
).toBeVisible()
242+
expect(received).toHaveLength(4)
243+
expect(received[3].body).toMatchObject({
244+
page: '/getting-started',
245+
helpful: true,
246+
comment: '',
247+
})
248+
expect(received[3].body.submission_id).not.toBe(received[2].body.submission_id)
249+
for (const request of received) {
250+
expect(request.authorization).toBe(`Bearer ${token}`)
251+
expect(Object.keys(request.body).sort()).toEqual([
252+
'comment',
253+
'helpful',
254+
'page',
255+
'received_at',
256+
'submission_id',
257+
])
258+
expect(request.body.submission_id).toMatch(/^[0-9a-f-]{36}$/)
259+
expect(typeof request.body.received_at).toBe('string')
260+
const timestamp = Date.parse(String(request.body.received_at))
261+
expect(timestamp).toBeGreaterThanOrEqual(started)
262+
expect(timestamp).toBeLessThanOrEqual(Date.now())
263+
}
264+
} finally {
265+
try {
266+
await context?.close()
267+
} finally {
268+
await intake.stop(true)
269+
}
270+
}
271+
})
165272
const noScript = await browser.newContext({
166273
javaScriptEnabled: false,
167274
viewport: { width: 1440, height: 1000 },

0 commit comments

Comments
 (0)