Skip to content

Commit 264e3b8

Browse files
HugoGresseclaude
andcommitted
Address review: export votes hardening + 404 enumeration fix
- Default a missing vote `plus` to 0 instead of emitting "(undefined)". - Convert fetch failures (network error, non-2xx, invalid JSON) into a 400 with a clear message instead of an opaque 500. - Fetch session vote docs concurrently (Promise.all) and pre-index vote items by id, avoiding sequential awaits and repeated .find() on large events. - Org-key path: a non-existent project and a wrong-org project now return the identical 404 "Event not found", so event ids stay non-enumerable. Tests cover the unified 404, the plus default, and the fetch-error 400. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4484e0b commit 264e3b8

3 files changed

Lines changed: 121 additions & 45 deletions

File tree

functions/src/api/routes/events/getVotes.spec.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@ const seededProject = {
1414
voteItems: [{ id: 'q1', name: 'Quality' }],
1515
_collections: {
1616
sessionVotes: [
17-
{ id: 'session1', q1: { uidA: { text: 'Great talk', plus: 2 } } },
17+
{
18+
id: 'session1',
19+
// uidB has no `plus` — must default to 0, not "(undefined)".
20+
q1: {
21+
uidA: { text: 'Great talk', plus: 2 },
22+
uidB: { text: 'Loved it' },
23+
},
24+
},
1825
],
1926
},
2027
}
@@ -32,10 +39,10 @@ const openFeedbackJson = {
3239
speakers: { spk1: { name: 'Alice' } },
3340
}
3441

35-
const stubFetch = (json: unknown = openFeedbackJson) =>
42+
const stubFetch = (json: unknown = openFeedbackJson, ok = true, status = 200) =>
3643
vi.stubGlobal(
3744
'fetch',
38-
vi.fn(async () => ({ json: async () => json }))
45+
vi.fn(async () => ({ ok, status, json: async () => json }))
3946
)
4047

4148
// Stub the private-subcollection key resolution used by authenticateRequest:
@@ -76,7 +83,7 @@ const expectedRow = {
7683
speakersName: 'Alice',
7784
tags: 'frontend',
7885
trackTitle: 'Track A',
79-
Quality: 'Great talk (2)',
86+
Quality: 'Great talk (2), Loved it (0)',
8087
}
8188

8289
describe('/events/:projectId/votes', () => {
@@ -158,6 +165,34 @@ describe('/events/:projectId/votes', () => {
158165
})
159166

160167
expect(response.statusCode).toBe(404)
168+
expect(JSON.parse(response.body).error).toBe('Event not found')
169+
})
170+
171+
it('returns the same 404 for a non-existent event (not enumerable)', async () => {
172+
mockOrgKey({ id: 'org_123' })
173+
174+
const response = await fastify.inject({
175+
method: 'GET',
176+
url: '/events/proj_missing/votes',
177+
headers: { 'x-api-key': 'oforg_test-key' },
178+
})
179+
180+
expect(response.statusCode).toBe(404)
181+
// Identical message to the wrong-org case above: ids stay opaque.
182+
expect(JSON.parse(response.body).error).toBe('Event not found')
183+
})
184+
185+
it('returns 400 when the public data URL responds with an error', async () => {
186+
stubFetch(openFeedbackJson, false, 502)
187+
mockProjectKey()
188+
189+
const response = await fastify.inject({
190+
method: 'GET',
191+
url: '/events/proj_123/votes',
192+
headers: { 'x-api-key': 'ofproj_test-key' },
193+
})
194+
195+
expect(response.statusCode).toBe(400)
161196
})
162197

163198
it('returns 400 when the event has no public data URL', async () => {

functions/src/api/routes/events/getVotes.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,21 @@ export const getEventVotesRoute: FastifyPluginAsync = async (server) => {
5454
}
5555
project = request.project as unknown as ExportableProject
5656
} else if (request.organization) {
57-
const resolved = await ProjectDao.getProjectFromId(
58-
server.firebase,
59-
projectId
60-
)
57+
// Resolve the event and confirm it belongs to this org. A
58+
// missing project and a wrong-org project must look identical
59+
// (same 404 message) so event ids stay non-enumerable.
60+
let resolved
61+
try {
62+
resolved = await ProjectDao.getProjectFromId(
63+
server.firebase,
64+
projectId
65+
)
66+
} catch (error) {
67+
if (error instanceof NotFoundError) {
68+
throw new NotFoundError('Event not found')
69+
}
70+
throw error
71+
}
6172
if (resolved.organizationId !== request.organization.id) {
6273
throw new NotFoundError('Event not found')
6374
}

functions/src/api/services/exportEventVotes.ts

Lines changed: 67 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ const voteValueToString = (voteResult: unknown): string => {
4848
voteResult as Record<string, { text?: string; plus?: number }>
4949
)
5050
.filter((v) => !!v?.text)
51-
.map((v) => `${v.text} (${v.plus})`)
51+
.map((v) => `${v.text} (${v.plus ?? 0})`)
5252
.join(', ')
5353
.replaceAll('\n', ' ')
5454
}
@@ -73,51 +73,81 @@ export const buildEventVotesExport = async (
7373
}
7474

7575
const db = getFirestore(firebaseApp)
76-
const data = (await fetch(jsonUrl).then((res) =>
77-
res.json()
78-
)) as OpenFeedbackData
76+
const data = await fetchEventData(jsonUrl)
7977

8078
const sessions = Object.keys(data.sessions || {}).map((id) => ({
8179
id,
8280
...data.sessions[id],
8381
}))
84-
const voteItems = project.voteItems || []
82+
// Index vote items by id once instead of re-scanning per session.
83+
const voteItemsById = new Map(
84+
(project.voteItems || []).map((item) => [item.id, item])
85+
)
8586

86-
const rows: EventVoteRow[] = []
87-
for (const session of sessions) {
88-
const sessionVotes = (await db
89-
.collection('projects')
90-
.doc(project.id)
91-
.collection('sessionVotes')
92-
.doc(session.id)
93-
.get()
94-
.then((doc) => doc.data())) as Record<string, unknown> | undefined
87+
// Fetch every session's votes concurrently; sequential awaits would be slow
88+
// and risk route timeouts on events with many sessions. Promise.all keeps
89+
// the original session order.
90+
return Promise.all(
91+
sessions.map(async (session) => {
92+
const sessionVotes = (await db
93+
.collection('projects')
94+
.doc(project.id)
95+
.collection('sessionVotes')
96+
.doc(session.id)
97+
.get()
98+
.then((doc) => doc.data())) as
99+
| Record<string, unknown>
100+
| undefined
95101

96-
const speakersName = (session.speakers || [])
97-
.map((speakerId) => data.speakers?.[speakerId]?.name)
98-
.filter((name): name is string => !!name)
99-
.join(', ')
102+
const speakersName = (session.speakers || [])
103+
.map((speakerId) => data.speakers?.[speakerId]?.name)
104+
.filter((name): name is string => !!name)
105+
.join(', ')
100106

101-
const voteColumns = Object.keys(sessionVotes || {}).reduce<
102-
Record<string, string>
103-
>((acc, key) => {
104-
const voteItem = voteItems.find((item) => item.id === key)
105-
if (voteItem?.name) {
106-
acc[voteItem.name] = voteValueToString(sessionVotes![key])
107-
}
108-
return acc
109-
}, {})
107+
const voteColumns = Object.keys(sessionVotes || {}).reduce<
108+
Record<string, string>
109+
>((acc, key) => {
110+
const voteItem = voteItemsById.get(key)
111+
if (voteItem?.name) {
112+
acc[voteItem.name] = voteValueToString(sessionVotes![key])
113+
}
114+
return acc
115+
}, {})
110116

111-
rows.push({
112-
sessionId: session.id,
113-
title: session.title,
114-
speakers: (session.speakers || []).join(', '),
115-
speakersName,
116-
tags: (session.tags || []).join(', '),
117-
trackTitle: session.trackTitle,
118-
...voteColumns,
117+
return {
118+
sessionId: session.id,
119+
title: session.title,
120+
speakers: (session.speakers || []).join(', '),
121+
speakersName,
122+
tags: (session.tags || []).join(', '),
123+
trackTitle: session.trackTitle,
124+
...voteColumns,
125+
}
119126
})
120-
}
127+
)
128+
}
121129

122-
return rows
130+
// Load the event's public sessions/speakers JSON, turning a bad/expired URL or
131+
// unparseable body into a 400 instead of an opaque 500.
132+
const fetchEventData = async (jsonUrl: string): Promise<OpenFeedbackData> => {
133+
let response: Response
134+
try {
135+
response = await fetch(jsonUrl)
136+
} catch {
137+
throw new BadRequestError(
138+
'Could not reach the event public data URL (config.jsonUrl).'
139+
)
140+
}
141+
if (!response.ok) {
142+
throw new BadRequestError(
143+
`The event public data URL returned ${response.status}.`
144+
)
145+
}
146+
try {
147+
return (await response.json()) as OpenFeedbackData
148+
} catch {
149+
throw new BadRequestError(
150+
'The event public data URL did not return valid JSON.'
151+
)
152+
}
123153
}

0 commit comments

Comments
 (0)