diff --git a/popup/views/onboarding-view.js b/popup/views/onboarding-view.js index de190c6..5dae119 100644 --- a/popup/views/onboarding-view.js +++ b/popup/views/onboarding-view.js @@ -2,10 +2,44 @@ import { fetchGitHubRepoFromNpm } from '../../shared/api/npm-api.js'; import { OnboardingManager } from '../../shared/onboarding.js'; import { getToken, setToken } from '../../shared/storage-helpers.js'; import { createHeaders } from '../../shared/github-api.js'; +import { escapeHtml } from '../../shared/sanitize.js'; // Create onboarding manager instance const onboardingManager = new OnboardingManager(); +function getStatusMarkup(type, message) { + return `
${escapeHtml(message)}
`; +} + +function renderRepoSuggestion(repo) { + const rawOwner = repo?.owner?.login || 'unknown'; + const rawName = repo?.name || 'unknown'; + const owner = escapeHtml(rawOwner); + const name = escapeHtml(rawName); + const description = escapeHtml(repo?.description || `${repo?.language || 'Popular'} project`); + const language = escapeHtml(repo?.language || ''); + const repoFullName = `${rawOwner}/${rawName}`; + const stars = Number.isFinite(repo?.stargazers_count) + ? repo.stargazers_count.toLocaleString() + : ''; + + return ` +
+
+
+ ${owner}/${name} +
+
${description}
+
+ ${language ? `${language}` : ''} + ${stars ? `${stars}` : ''} +
+
+ +
+ `; +} + /** * Onboarding view functions for popup * Handles the multi-step onboarding wizard for first-time users @@ -170,13 +204,15 @@ async function renderTokenStep() { let statusHtml = ''; let buttonDisabled = ''; let buttonText = 'Validate'; + const safeToken = escapeHtml(tokenData?.token || ''); + const safeTokenUrl = escapeHtml(tokenUrl); if (tokenData && tokenData.validated && tokenData.username) { - statusHtml = `
✓ Token is valid! Logged in as ${tokenData.username}
`; + statusHtml = getStatusMarkup('success', `✓ Token is valid! Logged in as ${tokenData.username}`); buttonDisabled = 'disabled'; buttonText = 'Validated'; } else if (tokenData && tokenData.validated) { - statusHtml = '
✓ Token is valid!
'; + statusHtml = getStatusMarkup('success', '✓ Token is valid!'); buttonDisabled = 'disabled'; buttonText = 'Validated'; } @@ -189,7 +225,7 @@ async function renderTokenStep() {

Quick setup:

    -
  1. Create a GitHub token
  2. +
  3. Create a GitHub token
  4. Copy the generated token
  5. Paste it below
@@ -202,7 +238,7 @@ async function renderTokenStep() { placeholder="ghp_YourTokenHere" class="token-input" autocomplete="off" - value="${tokenData.token || ''}" + value="${safeToken}" >
@@ -238,21 +274,7 @@ export async function renderReposStep() {

Popular repositories:

${popularRepos && popularRepos.length > 0 ? - popularRepos.map(repo => ` -
-
-
- ${repo.owner.login}/${repo.name} -
-
${repo.description || `${repo.language || 'Popular'} project`}
-
- ${repo.language ? `${repo.language}` : ''} - ${repo.stargazers_count ? `${repo.stargazers_count.toLocaleString()}` : ''} -
-
- -
- `).join('') : + popularRepos.map(renderRepoSuggestion).join('') : '
Loading popular repositories...
' }
@@ -517,11 +539,11 @@ function setupTokenStepListeners() { validateBtn?.addEventListener('click', async () => { const token = tokenInput.value.trim(); if (!token) { - tokenStatus.innerHTML = '
Please enter a token
'; + tokenStatus.innerHTML = getStatusMarkup('error', 'Please enter a token'); return; } - tokenStatus.innerHTML = '
Validating token...
'; + tokenStatus.innerHTML = getStatusMarkup('loading', 'Validating token...'); try { // Test the token by making a simple API call @@ -535,7 +557,7 @@ function setupTokenStepListeners() { const userData = await response.json(); const username = userData.login; const tokenData = { token, validated: true, username }; - tokenStatus.innerHTML = `
✓ Token is valid! Logged in as ${username}
`; + tokenStatus.innerHTML = getStatusMarkup('success', `✓ Token is valid! Logged in as ${username}`); await onboardingManager.saveStepData('token', tokenData); // Persist the token first so any calls which read it // can rely on the token being present. This reduces the chance of @@ -550,10 +572,10 @@ function setupTokenStepListeners() { // Silently handle prefetch errors - not critical } } else { - tokenStatus.innerHTML = '
✗ Invalid token
'; + tokenStatus.innerHTML = getStatusMarkup('error', '✗ Invalid token'); } } catch (_error) { - tokenStatus.innerHTML = '
Error validating token
'; + tokenStatus.innerHTML = getStatusMarkup('error', 'Error validating token'); } }); } @@ -567,21 +589,7 @@ async function loadPopularRepos() { if (popularRepos && popularRepos.length > 0) { // Success: render the repos - repoSuggestions.innerHTML = popularRepos.map(repo => ` -
-
-
- ${repo.owner.login}/${repo.name} -
-
${repo.description || `${repo.language || 'Popular'} project`}
-
- ${repo.language ? `${repo.language}` : ''} - ${repo.stargazers_count ? `${repo.stargazers_count.toLocaleString()}` : ''} -
-
- -
- `).join(''); + repoSuggestions.innerHTML = popularRepos.map(renderRepoSuggestion).join(''); // Re-attach event listeners to new buttons attachRepoButtonListeners(); @@ -685,7 +693,7 @@ function setupReposStepListeners() { let repo = manualInput.value.trim(); if (!repo) return; - repoStatus.innerHTML = '
Validating repository...
'; + repoStatus.innerHTML = getStatusMarkup('loading', 'Validating repository...'); try { // Get token for API calls @@ -704,14 +712,14 @@ function setupReposStepListeners() { repo = npmResult.repo; manualInput.value = repo; // Update input to show GitHub repo } else { - repoStatus.innerHTML = `
${npmResult.error}
`; + repoStatus.innerHTML = getStatusMarkup('error', npmResult.error); return; } } // Validate owner/repo format if (!repo.includes('/') || repo.split('/').length !== 2 || !repo.split('/')[0] || !repo.split('/')[1]) { - repoStatus.innerHTML = '
Invalid format. Use: owner/repo, GitHub URL, or npm package
'; + repoStatus.innerHTML = getStatusMarkup('error', 'Invalid format. Use: owner/repo, GitHub URL, or npm package'); return; } @@ -746,19 +754,19 @@ function setupReposStepListeners() { await chrome.storage.sync.set({ watchedRepos: repos }); } manualInput.value = ''; - repoStatus.innerHTML = '
✓ Repository added
'; + repoStatus.innerHTML = getStatusMarkup('success', '✓ Repository added'); } else { if (response.status === 404) { - repoStatus.innerHTML = '
Repository not found on GitHub
'; + repoStatus.innerHTML = getStatusMarkup('error', 'Repository not found on GitHub'); } else if (response.status === 403) { - repoStatus.innerHTML = '
GitHub API rate limit exceeded. Try again later.
'; + repoStatus.innerHTML = getStatusMarkup('error', 'GitHub API rate limit exceeded. Try again later.'); } else { - repoStatus.innerHTML = `
Error validating repository (${response.status})
`; + repoStatus.innerHTML = getStatusMarkup('error', `Error validating repository (${response.status})`); } } } catch (error) { console.error('Error adding repository:', error); - repoStatus.innerHTML = '
Network error. Please check your connection.
'; + repoStatus.innerHTML = getStatusMarkup('error', 'Network error. Please check your connection.'); } }; diff --git a/tests/onboarding.test.js b/tests/onboarding.test.js index 8eadde5..b44dbf8 100644 --- a/tests/onboarding.test.js +++ b/tests/onboarding.test.js @@ -1,4 +1,5 @@ import { jest, describe, beforeEach, test, expect } from '@jest/globals'; +import { TextEncoder } from 'node:util'; // Simple mock storage to simulate chrome.storage.local for onboarding flows let _localStorage = {}; @@ -63,6 +64,26 @@ import { OnboardingManager } from '../shared/onboarding.js'; let _handleNextStep; let _renderReposStep; +let _renderOnboardingStep; + +async function renderTokenStep(stateOverrides = {}) { + _localStorage = { + onboarding_state: { + currentStep: 1, + completed: false, + skippedSteps: [], + data: {}, + ...stateOverrides + } + }; + + document.body.innerHTML = ` +
+ + `; + + await _renderOnboardingStep(); +} describe('Onboarding - token persistence', () => { beforeEach(async () => { @@ -80,11 +101,32 @@ describe('Onboarding - token persistence', () => { jest.clearAllMocks(); document.body.innerHTML = ''; + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { + getRandomValues: jest.fn((array) => { + array.fill(1); + return array; + }), + subtle: { + generateKey: jest.fn(async () => ({ mockKey: true })), + importKey: jest.fn(async () => ({ mockKey: true })), + exportKey: jest.fn(async () => new Uint8Array([1, 2, 3, 4]).buffer), + encrypt: jest.fn(async () => new Uint8Array([9, 8, 7]).buffer), + decrypt: jest.fn(async () => new TextEncoder().encode('decrypted-token').buffer) + } + } + }); + Object.defineProperty(globalThis, 'TextEncoder', { + configurable: true, + value: TextEncoder + }); // Reload modules to reset module-level onboardingManager cache jest.resetModules(); const module = await import('../popup/views/onboarding-view.js'); _handleNextStep = module.handleNextStep; _renderReposStep = module.renderReposStep; + _renderOnboardingStep = module.renderOnboardingStep; }); test('preserves validated flag when saving token and navigating next', async () => { @@ -196,6 +238,132 @@ describe('Onboarding - token persistence', () => { global.fetch = oldFetch; }); + test('renderReposStep escapes repository metadata before building HTML', async () => { + const manager = new OnboardingManager(); + const saved = [ + { + owner: { login: 'alice">' }, + name: 'fancy', + description: '', + language: 'JS'); + expect(html).not.toContain(''); + }); + + test('renderOnboardingStep escapes saved token values and usernames on the token step', async () => { + await renderTokenStep({ + data: { + token: { + token: 'ghp_test" autofocus="true', + validated: true, + username: '' + } + } + }); + + const onboardingHtml = document.getElementById('onboardingView').innerHTML; + const tokenInput = document.getElementById('tokenInput'); + const tokenStatus = document.getElementById('tokenStatus'); + + expect(tokenInput.value).toBe('ghp_test" autofocus="true'); + expect(tokenInput.outerHTML).toContain('"'); + expect(tokenStatus.textContent).toContain('Logged in as '); + expect(onboardingHtml).toContain('<img src=x onerror=alert(1)>'); + expect(onboardingHtml).not.toContain(''); + }); + + test('renderOnboardingStep shows validated status without username safely', async () => { + await renderTokenStep({ + data: { + token: { + token: 'ghp_token', + validated: true + } + } + }); + + const onboardingHtml = document.getElementById('onboardingView').innerHTML; + const tokenStatus = document.getElementById('tokenStatus'); + + expect(tokenStatus.textContent).toContain('✓ Token is valid!'); + expect(onboardingHtml).toContain('Validated'); + }); + + test('token step shows an error when validation is attempted with no token', async () => { + await renderTokenStep(); + + document.getElementById('validateTokenBtn').click(); + + expect(document.getElementById('tokenStatus').textContent).toBe('Please enter a token'); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + test('token step escapes invalid-token responses', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 401 + }); + + await renderTokenStep(); + + const tokenInput = document.getElementById('tokenInput'); + tokenInput.value = 'ghp_invalid'; + document.getElementById('validateTokenBtn').click(); + await Promise.resolve(); + + expect(document.getElementById('tokenStatus').textContent).toBe('✗ Invalid token'); + }); + + test('token step escapes network validation errors', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('network down')); + + await renderTokenStep(); + + const tokenInput = document.getElementById('tokenInput'); + tokenInput.value = 'ghp_network'; + document.getElementById('validateTokenBtn').click(); + await Promise.resolve(); + + expect(document.getElementById('tokenStatus').textContent).toBe('Error validating token'); + }); + + test('token step escapes successful validation messages', async () => { + global.fetch = jest.fn(async (url) => { + if (url === 'https://api.github.com/user') { + return { + ok: true, + json: async () => ({ login: '' }) + }; + } + + return { + ok: true, + json: async () => ({ items: [] }) + }; + }); + + await renderTokenStep(); + + const tokenInput = document.getElementById('tokenInput'); + tokenInput.value = 'ghp_valid'; + document.getElementById('validateTokenBtn').click(); + await Promise.resolve(); + await Promise.resolve(); + + const tokenStatus = document.getElementById('tokenStatus'); + expect(tokenStatus.textContent).toContain('Logged in as '); + expect(tokenStatus.innerHTML).toContain('<img src=x onerror=alert(1)>'); + }); + test('saves categories preferences during onboarding', async () => { // Set onboarding step to categories _localStorage = {