From a1f2f9cff888bf31f9fe084a8334c8bc2a62454e Mon Sep 17 00:00:00 2001 From: Bennett Garcia Date: Wed, 5 Aug 2026 09:45:56 -0400 Subject: [PATCH 1/2] fix: use real respondents in survey demos --- api/server.js | 120 +++------------------ api/test/security.test.js | 119 ++++++++------------ dashboard/src/components/SendDemoDialog.js | 1 + 3 files changed, 55 insertions(+), 185 deletions(-) diff --git a/api/server.js b/api/server.js index 5c0f609..b6565bc 100644 --- a/api/server.js +++ b/api/server.js @@ -344,10 +344,6 @@ function buildDashboardUrl(path) { } const DEMO_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; -const DEMO_RESPONDENT_CHOICES = Array.from({ length: 100 }, (_, index) => { - const number = String(index + 1).padStart(3, '0'); - return `Demo Person ${number} (demo-person-${number}@example.com)`; -}); function createDemoToken(surveyId, surveyName, now = Date.now()) { const payload = Buffer.from(JSON.stringify({ @@ -382,89 +378,6 @@ function verifyDemoToken(token, now = Date.now()) { } } -function sanitizeSurveyForDemo(value) { - const namedDefinitions = new Map(); - const peopleChoiceSources = new Set(); - - const inspect = (node, inheritedTagbox = false) => { - if (Array.isArray(node)) { - node.forEach((entry) => inspect(entry, inheritedTagbox)); - return; - } - if (!node || typeof node !== 'object') return; - if (typeof node.name === 'string') { - const definitions = namedDefinitions.get(node.name) || []; - definitions.push(node); - namedDefinitions.set(node.name, definitions); - } - const isTagbox = inheritedTagbox || node.type === 'tagbox' || node.cellType === 'tagbox'; - if (isTagbox && typeof node.choicesFromQuestion === 'string') { - peopleChoiceSources.add(node.choicesFromQuestion); - } - Object.entries(node).forEach(([key, nestedValue]) => { - inspect(nestedValue, node.cellType === 'tagbox' && key === 'columns'); - }); - }; - inspect(value); - - // Follow chained choicesFromQuestion references so an indirect source cannot - // leak persisted respondent values into an externally forwarded demo. - const pendingSources = [...peopleChoiceSources]; - while (pendingSources.length > 0) { - const sources = namedDefinitions.get(pendingSources.pop()) || []; - sources.forEach((source) => { - if ( - typeof source.choicesFromQuestion === 'string' - && !peopleChoiceSources.has(source.choicesFromQuestion) - ) { - peopleChoiceSources.add(source.choicesFromQuestion); - pendingSources.push(source.choicesFromQuestion); - } - }); - } - - const sanitize = (node, inheritedTagbox = false) => { - if (Array.isArray(node)) { - return node.map((entry) => sanitize(entry, inheritedTagbox)); - } - if (!node || typeof node !== 'object') return node; - - const isTagbox = inheritedTagbox || node.type === 'tagbox' || node.cellType === 'tagbox'; - const sanitized = Object.fromEntries( - Object.entries(node).map(([key, nestedValue]) => [ - key, - sanitize(nestedValue, node.cellType === 'tagbox' && key === 'columns'), - ]) - ); - - // Legacy schemas may contain remote choice URLs that current validation - // rejects. Never disclose or call those URLs from a public demo link. - delete sanitized.choicesByUrl; - - if (isTagbox) { - sanitized.choices = []; - sanitized.choicesLazyLoadEnabled = true; - sanitized.choicesLazyLoadPageSize = Number(node.choicesLazyLoadPageSize) > 0 - ? node.choicesLazyLoadPageSize - : 25; - sanitized.allowAddNewTag = false; - delete sanitized.choicesFromQuestion; - delete sanitized.choicesFromQuestionMode; - delete sanitized.defaultValue; - delete sanitized.defaultValueExpression; - } else if (typeof node.name === 'string' && peopleChoiceSources.has(node.name)) { - sanitized.choices = [...DEMO_RESPONDENT_CHOICES]; - delete sanitized.choicesFromQuestion; - delete sanitized.choicesFromQuestionMode; - delete sanitized.defaultValue; - delete sanitized.defaultValueExpression; - } - return sanitized; - }; - - return sanitize(value); -} - app.use(express.json()); app.use(cors({ @@ -2899,27 +2812,22 @@ app.get('/api/names', respondentRateLimiter, async (req, res) => { if (demoToken && (!demoClaims || demoClaims.surveyName !== surveyName)) { return res.status(403).json({ message: 'This demo link is invalid or has expired.' }); } - // Demo recipients may be outside the organization. Supply synthetic choices - // so people/tagbox questions remain testable without exposing respondent PII. if (demoClaims) { - const normalizedFilter = String(filter).toLowerCase(); - const filteredChoices = DEMO_RESPONDENT_CHOICES.filter((choice) => - choice.toLowerCase().includes(normalizedFilter) + const activeSurvey = await pool.query( + 'SELECT 1 FROM Survey WHERE id = $1 AND name = $2 AND archived_at IS NULL', + [demoClaims.surveyId, demoClaims.surveyName] ); - const safeSkip = Math.max(0, Number.parseInt(skip, 10) || 0); - const safeTake = Math.min(100, Math.max(1, Number.parseInt(take, 10) || 10)); - return res.status(200).json({ - names: filteredChoices.slice(safeSkip, safeSkip + safeTake), - total: filteredChoices.length, - }); + if (activeSurvey.rows.length === 0) { + return res.status(404).json({ message: 'Survey not found.' }); + } } - const validation = await validateRespondentToken(surveyName, userId); - if (!validation.ok) { + const validation = demoClaims ? null : await validateRespondentToken(surveyName, userId); + if (validation && !validation.ok) { return res.status(validation.status).json({ message: validation.message }); } - const surveyId = validation.respondent.survey_id; + const surveyId = demoClaims?.surveyId || validation.respondent.survey_id; const client = await pool.connect(); try { @@ -2927,14 +2835,14 @@ app.get('/api/names', respondentRateLimiter, async (req, res) => { SELECT r.name, r.contact_info, COUNT(*) OVER() AS total_count FROM Respondent r WHERE ${legacySurveyPredicate('r')} - AND r.uuid != $3 + AND ($3::text IS NULL OR r.uuid != $3) AND (r.name ILIKE $4 OR r.contact_info ILIKE $4) ORDER BY r.name OFFSET $5 LIMIT $6; `; - const values = [surveyId, surveyName, userId, `%${filter}%`, skip, take]; + const values = [surveyId, surveyName, demoClaims ? null : userId, `%${filter}%`, skip, take]; const result = await client.query(query, values); const filteredNames = result.rows.map(formatRespondentChoice); @@ -3055,10 +2963,7 @@ app.get('/api/questions', respondentRateLimiter, async (req, res) => { return res.status(404).json({ message: 'Survey not found.' }); } - const questions = demoClaims - ? sanitizeSurveyForDemo(result.rows[0].questions) - : result.rows[0].questions; - res.status(200).json({ title: result.rows[0].title, questions }); + res.status(200).json({ title: result.rows[0].title, questions: result.rows[0].questions }); } catch (error) { console.error('Error fetching survey questions:', error); res.status(500).json({ message: 'Failed to fetch survey questions.' }); @@ -3435,7 +3340,6 @@ module.exports = { buildDashboardUrl, createDemoToken, verifyDemoToken, - sanitizeSurveyForDemo, READ_SURVEY_ROLES, ANALYST_ROLES, EDITOR_ROLES, diff --git a/api/test/security.test.js b/api/test/security.test.js index d380d3c..4c5ef88 100644 --- a/api/test/security.test.js +++ b/api/test/security.test.js @@ -29,7 +29,6 @@ const { buildDashboardUrl, createDemoToken, verifyDemoToken, - sanitizeSurveyForDemo, READ_SURVEY_ROLES, ANALYST_ROLES, EDITOR_ROLES, @@ -1367,84 +1366,46 @@ test('demo links are signed, survey-bound, and expire without database state', ( assert.equal(verifyDemoToken(token, issuedAt + (24 * 60 * 60 * 1000)), null); }); -test('demo schema sanitization recursively replaces legacy people choices', () => { - const privateChoices = ['Private Person (private@example.com)']; - const schema = { - pages: [{ - elements: [{ - type: 'panel', - elements: [{ type: 'tagbox', name: 'nested', choices: privateChoices }], - }, { - type: 'paneldynamic', - templateElements: [{ - type: 'matrixdropdown', - cellType: 'tagbox', - columns: [{ name: 'person', choicesFromQuestion: 'matrix_source', defaultValue: privateChoices }], - }], - }, { - type: 'dropdown', name: 'private_source', choicesFromQuestion: 'second_source', defaultValue: privateChoices[0], - }, { - type: 'tagbox', name: 'from_source', choicesFromQuestion: 'private_source', defaultValue: privateChoices, - }, { - type: 'dropdown', name: 'matrix_source', choices: privateChoices, defaultValue: privateChoices[0], - }, { - type: 'dropdown', name: 'second_source', choices: privateChoices, - }, { - type: 'text', name: 'private_source', choicesByUrl: { url: 'https://user:secret@private.example/choices' }, - }], - }], - }; - - const sanitized = sanitizeSurveyForDemo(schema); - const elements = sanitized.pages[0].elements; - const nestedTagbox = elements[0].elements[0]; - const matrixTagbox = elements[1].templateElements[0].columns[0]; - const sourceQuestion = elements[2]; - const sourceTagbox = elements[3]; - const matrixSourceQuestion = elements[4]; - const secondSourceQuestion = elements[5]; - const duplicateSourceName = elements[6]; - for (const question of [nestedTagbox, matrixTagbox, sourceTagbox]) { - assert.deepEqual(question.choices, []); - assert.equal(question.choicesLazyLoadEnabled, true); - assert.equal(question.allowAddNewTag, false); - assert.equal(question.choicesFromQuestion, undefined); - assert.equal(question.defaultValue, undefined); - } - for (const source of [sourceQuestion, matrixSourceQuestion, secondSourceQuestion, duplicateSourceName]) { - assert.equal(source.choices.length, 100); - assert.equal(source.choices[0], 'Demo Person 001 (demo-person-001@example.com)'); - assert.equal(source.choices.includes(privateChoices[0]), false); - assert.equal(source.defaultValue, undefined); - assert.equal(source.choicesByUrl, undefined); - } - assert.deepEqual(schema.pages[0].elements[0].elements[0].choices, privateChoices, 'persisted schema is not mutated'); -}); - -test('signed demo links load survey questions but cannot be used for a different survey', async (t) => { +test('signed demo links load configured questions and real respondents but cannot be used for a different survey', async (t) => { const originalConnect = pool.connect; - t.after(() => { pool.connect = originalConnect; }); + const originalQuery = pool.query; + t.after(() => { + pool.connect = originalConnect; + pool.query = originalQuery; + }); const surveyId = '11111111-1111-4111-8111-111111111111'; const token = createDemoToken(surveyId, 'Survey A'); let queryCount = 0; + pool.query = async (sql, values) => { + assert.match(sql, /SELECT 1 FROM Survey/); + assert.deepEqual(values, [surveyId, 'Survey A']); + return { rows: [{ '?column?': 1 }] }; + }; pool.connect = async () => ({ query: async (sql, values) => { queryCount += 1; - assert.match(sql, /WHERE \(id = \$1/); - assert.deepEqual(values, [surveyId, 'Survey A']); - return { rows: [{ - title: 'Configured title', - questions: { elements: [ - { type: 'text', name: 'q1' }, - { - type: 'tagbox', - name: 'people', - choicesLazyLoadEnabled: true, - choices: ['Private Person (private@example.com)'], - }, - ] }, - }] }; + if (/SELECT questions, title/.test(sql)) { + assert.deepEqual(values, [surveyId, 'Survey A']); + return { rows: [{ + title: 'Configured title', + questions: { elements: [ + { type: 'text', name: 'q1' }, + { + type: 'tagbox', + name: 'people', + choicesLazyLoadEnabled: true, + choices: ['Configured Person (configured@example.com)'], + }, + ] }, + }] }; + } + assert.match(sql, /FROM Respondent r/); + assert.deepEqual(values, [surveyId, 'Survey A', null, '%%', 0, '2']); + return { rows: [ + { name: 'Real Person One', contact_info: 'one@example.com', total_count: '2' }, + { name: 'Real Person Two', contact_info: 'two@example.com', total_count: '2' }, + ] }; }, release() {}, }); @@ -1452,21 +1413,25 @@ test('signed demo links load survey questions but cannot be used for a different const valid = await request(app).get('/api/questions').query({ surveyName: 'Survey A', demoToken: token }); assert.equal(valid.status, 200); assert.equal(valid.body.title, 'Configured title'); - assert.deepEqual(valid.body.questions.elements[1].choices, [], 'demo schemas scrub persisted people choices'); + assert.deepEqual( + valid.body.questions.elements[1].choices, + ['Configured Person (configured@example.com)'], + 'demo links preserve the configured survey schema' + ); const names = await request(app).get('/api/names').query({ surveyName: 'Survey A', demoToken: token, take: 2 }); assert.equal(names.status, 200); assert.deepEqual(names.body, { names: [ - 'Demo Person 001 (demo-person-001@example.com)', - 'Demo Person 002 (demo-person-002@example.com)', + 'Real Person One (one@example.com)', + 'Real Person Two (two@example.com)', ], - total: 100, - }, 'demo links use synthetic choices instead of exposing respondent PII'); + total: 2, + }); const wrongSurvey = await request(app).get('/api/questions').query({ surveyName: 'Survey B', demoToken: token }); assert.equal(wrongSurvey.status, 403); - assert.equal(queryCount, 1); + assert.equal(queryCount, 2); }); test('dashboard/admin endpoints require authentication', async () => { diff --git a/dashboard/src/components/SendDemoDialog.js b/dashboard/src/components/SendDemoDialog.js index dee493b..8238152 100644 --- a/dashboard/src/components/SendDemoDialog.js +++ b/dashboard/src/components/SendDemoDialog.js @@ -73,6 +73,7 @@ const SendDemoDialog = ({ open, onClose, onSubmit, surveyName, loading = false } Send a no-results demo of “{surveyName}” using its configured email text and survey. + The demo includes real respondent names and email addresses, so send it only to a trusted recipient. Date: Wed, 5 Aug 2026 10:00:32 -0400 Subject: [PATCH 2/2] fix: harden real respondent demo choices --- api/server.js | 154 +++++++++++++++----- api/test/security.test.js | 66 +++++++-- network-survey/src/SurveyComponent.test.jsx | 120 +++++++++++++++ 3 files changed, 298 insertions(+), 42 deletions(-) create mode 100644 network-survey/src/SurveyComponent.test.jsx diff --git a/api/server.js b/api/server.js index b6565bc..6ff483b 100644 --- a/api/server.js +++ b/api/server.js @@ -378,6 +378,82 @@ function verifyDemoToken(token, now = Date.now()) { } } +function prepareSurveyForDemo(value) { + const namedDefinitions = new Map(); + const peopleChoiceSources = new Set(); + + const inspect = (node, inheritedTagbox = false) => { + if (Array.isArray(node)) { + node.forEach((entry) => inspect(entry, inheritedTagbox)); + return; + } + if (!node || typeof node !== 'object') return; + if (typeof node.name === 'string') { + const definitions = namedDefinitions.get(node.name) || []; + definitions.push(node); + namedDefinitions.set(node.name, definitions); + } + const isTagbox = inheritedTagbox || node.type === 'tagbox' || node.cellType === 'tagbox'; + if (isTagbox && typeof node.choicesFromQuestion === 'string') { + peopleChoiceSources.add(node.choicesFromQuestion); + } + Object.entries(node).forEach(([key, nestedValue]) => { + inspect(nestedValue, node.cellType === 'tagbox' && key === 'columns'); + }); + }; + inspect(value); + + const pendingSources = [...peopleChoiceSources]; + while (pendingSources.length > 0) { + const sources = namedDefinitions.get(pendingSources.pop()) || []; + sources.forEach((source) => { + if ( + typeof source.choicesFromQuestion === 'string' + && !peopleChoiceSources.has(source.choicesFromQuestion) + ) { + peopleChoiceSources.add(source.choicesFromQuestion); + pendingSources.push(source.choicesFromQuestion); + } + }); + } + + const prepare = (node, inheritedTagbox = false) => { + if (Array.isArray(node)) { + return node.map((entry) => prepare(entry, inheritedTagbox)); + } + if (!node || typeof node !== 'object') return node; + + const isTagbox = inheritedTagbox || node.type === 'tagbox' || node.cellType === 'tagbox'; + const isPeopleSource = typeof node.name === 'string' && peopleChoiceSources.has(node.name); + const prepared = Object.fromEntries( + Object.entries(node).map(([key, nestedValue]) => [ + key, + prepare(nestedValue, node.cellType === 'tagbox' && key === 'columns'), + ]) + ); + + // Current schema validation rejects remote choice URLs, but legacy surveys + // may still contain private endpoints or embedded credentials. + delete prepared.choicesByUrl; + + if (isTagbox || isPeopleSource) { + prepared.choices = []; + prepared.choicesLazyLoadEnabled = true; + prepared.choicesLazyLoadPageSize = Number(node.choicesLazyLoadPageSize) > 0 + ? Math.min(Number(node.choicesLazyLoadPageSize), 100) + : 25; + prepared.allowAddNewTag = false; + delete prepared.choicesFromQuestion; + delete prepared.choicesFromQuestionMode; + delete prepared.defaultValue; + delete prepared.defaultValueExpression; + } + return prepared; + }; + + return prepare(value); +} + app.use(express.json()); app.use(cors({ @@ -2807,30 +2883,41 @@ function formatRespondentChoice(respondent) { // GET API endpoint for lazy loading the names list app.get('/api/names', respondentRateLimiter, async (req, res) => { const { skip = 0, take = 10, filter = '', surveyName = '', userId = '', demoToken = '' } = req.query; - - const demoClaims = demoToken ? verifyDemoToken(demoToken) : null; - if (demoToken && (!demoClaims || demoClaims.surveyName !== surveyName)) { - return res.status(403).json({ message: 'This demo link is invalid or has expired.' }); - } - if (demoClaims) { - const activeSurvey = await pool.query( - 'SELECT 1 FROM Survey WHERE id = $1 AND name = $2 AND archived_at IS NULL', - [demoClaims.surveyId, demoClaims.surveyName] - ); - if (activeSurvey.rows.length === 0) { - return res.status(404).json({ message: 'Survey not found.' }); + const parsedSkip = Number(skip); + const parsedTake = Number(take); + if ( + !Number.isInteger(parsedSkip) + || parsedSkip < 0 + || !Number.isInteger(parsedTake) + || parsedTake < 1 + ) { + return res.status(400).json({ message: 'Invalid pagination parameters.' }); + } + const safeTake = Math.min(parsedTake, 100); + + let client; + try { + const demoClaims = demoToken ? verifyDemoToken(demoToken) : null; + if (demoToken && (!demoClaims || demoClaims.surveyName !== surveyName)) { + return res.status(403).json({ message: 'This demo link is invalid or has expired.' }); + } + if (demoClaims) { + const activeSurvey = await pool.query( + 'SELECT 1 FROM Survey WHERE id = $1 AND name = $2 AND archived_at IS NULL', + [demoClaims.surveyId, demoClaims.surveyName] + ); + if (activeSurvey.rows.length === 0) { + return res.status(404).json({ message: 'Survey not found.' }); + } } - } - const validation = demoClaims ? null : await validateRespondentToken(surveyName, userId); - if (validation && !validation.ok) { - return res.status(validation.status).json({ message: validation.message }); - } + const validation = demoClaims ? null : await validateRespondentToken(surveyName, userId); + if (validation && !validation.ok) { + return res.status(validation.status).json({ message: validation.message }); + } - const surveyId = demoClaims?.surveyId || validation.respondent.survey_id; - const client = await pool.connect(); - - try { + const surveyId = demoClaims?.surveyId || validation.respondent.survey_id; + client = await pool.connect(); const query = ` SELECT r.name, r.contact_info, COUNT(*) OVER() AS total_count FROM Respondent r @@ -2842,26 +2929,20 @@ app.get('/api/names', respondentRateLimiter, async (req, res) => { LIMIT $6; `; - const values = [surveyId, surveyName, demoClaims ? null : userId, `%${filter}%`, skip, take]; + const values = [surveyId, surveyName, demoClaims ? null : userId, `%${filter}%`, parsedSkip, safeTake]; const result = await client.query(query, values); - const filteredNames = result.rows.map(formatRespondentChoice); - const total = result.rows.length > 0 ? Number(result.rows[0].total_count) : 0; res.status(200).json({ names: filteredNames, total: Number.isFinite(total) && total >= 0 ? total : filteredNames.length }); - } catch (error) { console.error('Error fetching names:', error); - res.status(500).json({ - error: 'Failed to fetch names', - message: error.message - }); + res.status(500).json({ error: 'Failed to fetch names' }); } finally { - client.release(); + client?.release(); } }); @@ -2948,9 +3029,10 @@ app.get('/api/questions', respondentRateLimiter, async (req, res) => { } const surveyId = demoClaims?.surveyId || validation.respondent.survey_id; - const client = await pool.connect(); + let client; try { + client = await pool.connect(); const query = ` SELECT questions, title FROM Survey @@ -2963,12 +3045,17 @@ app.get('/api/questions', respondentRateLimiter, async (req, res) => { return res.status(404).json({ message: 'Survey not found.' }); } - res.status(200).json({ title: result.rows[0].title, questions: result.rows[0].questions }); + res.status(200).json({ + title: result.rows[0].title, + questions: demoClaims + ? prepareSurveyForDemo(result.rows[0].questions) + : result.rows[0].questions, + }); } catch (error) { console.error('Error fetching survey questions:', error); res.status(500).json({ message: 'Failed to fetch survey questions.' }); } finally { - client.release(); + client?.release(); } }); @@ -3340,6 +3427,7 @@ module.exports = { buildDashboardUrl, createDemoToken, verifyDemoToken, + prepareSurveyForDemo, READ_SURVEY_ROLES, ANALYST_ROLES, EDITOR_ROLES, diff --git a/api/test/security.test.js b/api/test/security.test.js index 4c5ef88..e2ac485 100644 --- a/api/test/security.test.js +++ b/api/test/security.test.js @@ -29,6 +29,7 @@ const { buildDashboardUrl, createDemoToken, verifyDemoToken, + prepareSurveyForDemo, READ_SURVEY_ROLES, ANALYST_ROLES, EDITOR_ROLES, @@ -1366,6 +1367,46 @@ test('demo links are signed, survey-bound, and expire without database state', ( assert.equal(verifyDemoToken(token, issuedAt + (24 * 60 * 60 * 1000)), null); }); +test('demo survey preparation uses roster-backed choices without exposing legacy remote URLs', () => { + const persisted = { + pages: [{ elements: [ + { + type: 'tagbox', + name: 'people', + choices: ['Stale Person (stale@example.com)'], + choicesByUrl: { url: 'https://user:secret@private.example/choices' }, + defaultValue: ['Stale Person (stale@example.com)'], + }, + { + type: 'dropdown', + name: 'people_source', + choices: ['Stale Source (source@example.com)'], + }, + { + type: 'tagbox', + name: 'people_from_source', + choicesFromQuestion: 'people_source', + }, + ] }], + }; + + const prepared = prepareSurveyForDemo(persisted); + const [tagbox, source, sourcedTagbox] = prepared.pages[0].elements; + for (const question of [tagbox, source, sourcedTagbox]) { + assert.deepEqual(question.choices, []); + assert.equal(question.choicesLazyLoadEnabled, true); + assert.equal(question.allowAddNewTag, false); + assert.equal(question.choicesFromQuestion, undefined); + assert.equal(question.defaultValue, undefined); + } + assert.equal(tagbox.choicesByUrl, undefined); + assert.deepEqual( + persisted.pages[0].elements[0].choices, + ['Stale Person (stale@example.com)'], + 'persisted survey schema is not mutated' + ); +}); + test('signed demo links load configured questions and real respondents but cannot be used for a different survey', async (t) => { const originalConnect = pool.connect; const originalQuery = pool.query; @@ -1390,18 +1431,21 @@ test('signed demo links load configured questions and real respondents but canno return { rows: [{ title: 'Configured title', questions: { elements: [ - { type: 'text', name: 'q1' }, + { + type: 'text', + name: 'q1', + choicesByUrl: { url: 'https://user:secret@private.example/choices' }, + }, { type: 'tagbox', name: 'people', - choicesLazyLoadEnabled: true, choices: ['Configured Person (configured@example.com)'], }, ] }, }] }; } assert.match(sql, /FROM Respondent r/); - assert.deepEqual(values, [surveyId, 'Survey A', null, '%%', 0, '2']); + assert.deepEqual(values, [surveyId, 'Survey A', null, '%%', 0, 100]); return { rows: [ { name: 'Real Person One', contact_info: 'one@example.com', total_count: '2' }, { name: 'Real Person Two', contact_info: 'two@example.com', total_count: '2' }, @@ -1413,13 +1457,11 @@ test('signed demo links load configured questions and real respondents but canno const valid = await request(app).get('/api/questions').query({ surveyName: 'Survey A', demoToken: token }); assert.equal(valid.status, 200); assert.equal(valid.body.title, 'Configured title'); - assert.deepEqual( - valid.body.questions.elements[1].choices, - ['Configured Person (configured@example.com)'], - 'demo links preserve the configured survey schema' - ); + assert.equal(valid.body.questions.elements[0].choicesByUrl, undefined); + assert.deepEqual(valid.body.questions.elements[1].choices, []); + assert.equal(valid.body.questions.elements[1].choicesLazyLoadEnabled, true); - const names = await request(app).get('/api/names').query({ surveyName: 'Survey A', demoToken: token, take: 2 }); + const names = await request(app).get('/api/names').query({ surveyName: 'Survey A', demoToken: token, take: 1000 }); assert.equal(names.status, 200); assert.deepEqual(names.body, { names: [ @@ -1429,6 +1471,12 @@ test('signed demo links load configured questions and real respondents but canno total: 2, }); + const invalidPagination = await request(app) + .get('/api/names') + .query({ surveyName: 'Survey A', demoToken: token, skip: -1 }); + assert.equal(invalidPagination.status, 400); + assert.equal(invalidPagination.body.message, 'Invalid pagination parameters.'); + const wrongSurvey = await request(app).get('/api/questions').query({ surveyName: 'Survey B', demoToken: token }); assert.equal(wrongSurvey.status, 403); assert.equal(queryCount, 2); diff --git a/network-survey/src/SurveyComponent.test.jsx b/network-survey/src/SurveyComponent.test.jsx new file mode 100644 index 0000000..f544a84 --- /dev/null +++ b/network-survey/src/SurveyComponent.test.jsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const surveyState = vi.hoisted(() => ({ model: null })); + +vi.mock('survey-core', () => { + class FakeModel { + constructor(json) { + this.json = json; + this.onCompleting = { add: (handler) => { this.completingHandler = handler; } }; + this.onChoicesLazyLoad = { add: (handler) => { this.lazyLoadHandler = handler; } }; + this.onAfterRenderQuestion = { add: (handler) => { this.renderHandler = handler; } }; + surveyState.model = this; + } + + getAllQuestions() { + return []; + } + + dispose() {} + } + + return { + Model: FakeModel, + Serializer: { addClass: vi.fn() }, + Question: class {}, + }; +}); + +vi.mock('survey-react-ui', () => ({ + Survey: () =>
, +})); + +vi.mock('@network-survey/frontend-shared', () => ({ + applyProductionSurveyTheme: vi.fn(), + PRODUCTION_SURVEY_CLASS_NAME: 'survey-runtime', + buildApiUrl: (pathname, queryParams = {}) => { + const query = new URLSearchParams( + Object.entries(queryParams).filter(([, value]) => value !== null && value !== undefined) + ); + const queryString = query.toString(); + return `${pathname}${queryString ? `?${queryString}` : ''}`; + }, +})); + +vi.mock('@network-survey/frontend-react', () => ({ + DraggableRankingQuestion: () => null, +})); + +vi.mock('./tagboxSearchPlaceholder', () => ({ + disposeTagboxSearchPlaceholder: vi.fn(), + restoreTagboxSearchPlaceholder: vi.fn(), +})); + +import SurveyComponent from './SurveyComponent'; + +class MockXMLHttpRequest { + static requests = []; + + open(method, url) { + this.method = method; + this.url = url; + } + + setRequestHeader() {} + + send() { + MockXMLHttpRequest.requests.push(this.url); + this.status = 200; + this.response = JSON.stringify( + this.url.includes('/questions') + ? { title: 'Demo survey', questions: { elements: [{ type: 'tagbox', name: 'people' }] } } + : { names: ['Real Person (real@example.com)'], total: 1 } + ); + this.onload(); + } +} + +describe('SurveyComponent demo mode', () => { + beforeEach(() => { + surveyState.model = null; + MockXMLHttpRequest.requests = []; + vi.stubGlobal('XMLHttpRequest', MockXMLHttpRequest); + vi.stubGlobal('fetch', vi.fn()); + window.history.replaceState({}, '', '/?surveyName=Survey%20A&demoToken=signed-demo-token'); + }); + + it('loads real roster choices and completes without posting a response', async () => { + render(); + + await waitFor(() => expect(screen.getByTestId('survey-form')).toBeInTheDocument()); + expect(screen.getByText(/will not save any answers or results/i)).toBeInTheDocument(); + + const setItems = vi.fn(); + act(() => { + surveyState.model.lazyLoadHandler(null, { + skip: 0, + take: 25, + filter: '', + setItems, + }); + }); + + expect(MockXMLHttpRequest.requests.at(-1)).toContain('/names'); + expect(MockXMLHttpRequest.requests.at(-1)).toContain('demoToken=signed-demo-token'); + expect(setItems).toHaveBeenCalledWith( + [{ value: 'Real Person (real@example.com)', text: 'Real Person (real@example.com)' }], + 1 + ); + + const completionOptions = {}; + act(() => { + surveyState.model.completingHandler({ data: { people: ['Real Person (real@example.com)'] } }, completionOptions); + }); + + expect(completionOptions.allowComplete).toBeUndefined(); + expect(fetch).not.toHaveBeenCalled(); + }); +});