diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 771e9a2..d5164f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,34 +7,79 @@ on: branches: [ main, master ] jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '18' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run linter + run: npm run lint + + typecheck: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '18' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run type checker + run: npm run typecheck + test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6 - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '18' - cache: 'npm' + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '18' + cache: 'npm' - - name: Install dependencies - run: npm ci + - name: Install dependencies + run: npm ci - - name: Run linter - run: npm run lint + - name: Run tests with coverage + run: npm test -- --coverage --coverageReporters=text --coverageReporters=lcov - - name: Run type checker - run: npm run typecheck + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 - - name: Run tests with coverage - run: npm test -- --coverage --coverageReporters=text --coverageReporters=lcov + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '18' + cache: 'npm' - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} + - name: Install dependencies + run: npm ci - - name: Validate extension build - run: npm run build + - name: Validate extension build + run: npm run build diff --git a/PRIVACY.md b/PRIVACY.md index b879d80..021aedb 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -12,9 +12,10 @@ GitHub Devwatch is a Chrome extension for monitoring activity on GitHub reposito GitHub Devwatch collects and stores the following data **locally on your device only**: -1. **GitHub Personal Access Token** +1. **GitHub OAuth Session** + - Created when you connect GitHub through the built-in device-flow sign-in - Stored by the extension in Chrome storage - - Current builds encrypt the token before writing it to local storage and keep a decrypted session copy while the extension is running + - Current builds encrypt the auth session before writing it to local storage and keep a decrypted session copy while the extension is running - Used only to authenticate with GitHub's API - Not sent to third-party services operated by this project - Never shared with anyone @@ -46,16 +47,16 @@ GitHub Devwatch collects and stores the following data **locally on your device All data collected is used exclusively to provide the extension's functionality: -- Your GitHub token authenticates API requests to GitHub +- Your GitHub connection authenticates API requests to GitHub - Your repository list determines which repositories to monitor - Your settings customize how the extension behaves - Activity data is displayed in the extension popup for your review ## Data Storage -- The extension uses Chrome storage APIs for settings, cached activity, and token handling +- The extension uses Chrome storage APIs for settings, cached activity, and GitHub sign-in handling - Settings and repository lists can optionally sync across your Chrome browsers if you use Chrome Sync -- Token handling uses local and session storage rather than Chrome sync +- GitHub sign-in data uses local and session storage rather than Chrome sync - You can clear all data at any time by uninstalling the extension or using Chrome's "Clear extension data" feature ## Third-Party Services @@ -65,7 +66,7 @@ All data collected is used exclusively to provide the extension's functionality: This extension communicates with GitHub's API (api.github.com) to fetch repository activity. When you use this extension: - API requests are made directly from your browser to GitHub -- Requests include your GitHub Personal Access Token for authentication +- Requests include your GitHub OAuth access token for authentication - GitHub's privacy policy and terms of service apply to these interactions - See GitHub's privacy policy at: https://docs.github.com/en/site-policy/privacy-policies/github-privacy-statement @@ -90,7 +91,7 @@ GitHub Devwatch does **NOT**: The extension requests the following Chrome permissions: -- **storage**: To save your settings, token, and activity data locally +- **storage**: To save your settings, GitHub sign-in state, and activity data locally - **alarms**: To periodically check for new repository activity - **notifications**: To show you browser notifications for new activity - **Host permission for api.github.com**: To fetch repository activity from GitHub's API @@ -104,14 +105,14 @@ You have complete control over your data: - **View Your Data**: All settings are visible in the extension's options page - **Delete Your Data**: Uninstall the extension to remove all data, or use the "Clear All Data" option in settings - **Export Your Data**: Use the backup/restore feature to export your settings -- **Revoke Access**: Remove or regenerate your GitHub Personal Access Token at any time via GitHub's settings +- **Revoke Access**: Disconnect locally in DevWatch, and revoke the OAuth app in GitHub's authorized applications settings at any time ## Security Current builds include several concrete safeguards: - All API requests use HTTPS -- The token is encrypted before it is persisted locally +- The GitHub auth session is encrypted before it is persisted locally - The codebase includes input sanitization and GitHub URL validation checks - Extension pages use a Content Security Policy diff --git a/README.md b/README.md index b8daeb1..121767e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Monitor pull requests, issues, and releases across multiple GitHub repositories ## Key Features -- **Guided Setup** - Built-in setup flow for token creation and repository selection +- **Guided Setup** - Built-in GitHub sign-in flow and repository selection - **Browser Notifications** - Get notified about new PRs, issues, and releases - **Multi-Repo Monitoring** - Watch up to 50 repositories from one interface - **Configurable Updates** - Check every 5, 15, 30, or 60 minutes @@ -30,7 +30,7 @@ Monitor pull requests, issues, and releases across multiple GitHub repositories 3. Grant permissions when prompted 4. Follow the guided setup wizard on first launch -**GitHub Token Permissions**: You'll need a [Personal Access Token](https://github.com/settings/tokens/new) with `repo` (for private repos) or `public_repo` (for public only). +**GitHub Sign-In Permissions**: DevWatch uses GitHub OAuth device flow and requests `repo` plus `read:user` so it can monitor private repositories and show the connected account in the UI. ### Manual Installation (For Development) @@ -53,7 +53,7 @@ cd devwatch-github ### First-Time Setup The built-in setup flow walks you through: -1. Create a GitHub token +1. Connect your GitHub account 2. Add repositories to watch 3. Choose activity types (PRs, Issues, Releases) @@ -74,7 +74,7 @@ The built-in setup flow walks you through: Filter by type (All/PRs/Issues/Releases), search activities, refresh manually, or browse the archive. Click any item to open in GitHub. ### Settings Page -Manage your GitHub token, watched repositories, activity filters, check interval, notifications, and theme. Export/import settings for backup. +Manage your GitHub connection, watched repositories, activity filters, check interval, notifications, and theme. Export/import settings for backup.
Settings page for configuring repositories @@ -101,7 +101,7 @@ That said, this project has not gone through a formal accessibility audit or doc ## Privacy & Security Notes -The extension talks directly to GitHub's API and does not use a separate analytics or sync backend. It stores settings and cached activity in Chrome extension storage, and the current build encrypts the GitHub token before persisting it locally while keeping a decrypted session copy available at runtime. +The extension talks directly to GitHub's API and does not use a separate analytics or sync backend. It stores settings and cached activity in Chrome extension storage, and the current build encrypts the GitHub auth session before persisting it locally while keeping a decrypted session copy available at runtime. - **Direct network access** - Requests go to `api.github.com`, plus `registry.npmjs.org` only when you use package-name lookup - **Scoped browser permissions** - The manifest asks for `storage`, `alarms`, and `notifications` diff --git a/SECURITY.md b/SECURITY.md index 044cb0e..a8193f9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,7 +15,7 @@ I'll respond within 48 hours and work with you to understand and address the iss Things I want to know about: - XSS vulnerabilities or ways to inject malicious code -- Token leakage or insecure storage +- OAuth session leakage or insecure storage - Ways to access other users' data - Privilege escalation - Dependencies with known CVEs @@ -32,8 +32,8 @@ These are better suited for regular issues: The extension includes several concrete protections, but this project has not been through a formal external security audit. -### Token Storage -- GitHub tokens are encrypted before they are written to local extension storage +### GitHub Sign-In Storage +- GitHub auth sessions are encrypted before they are written to local extension storage - A decrypted copy may be cached in session storage while the extension is running - Never transmitted to third-party servers @@ -49,7 +49,7 @@ The extension includes several concrete protections, but this project has not be ### API Security - All requests use HTTPS -- Tokens are included in headers, never in URLs +- OAuth access tokens are included in headers, never in URLs - Rate limiting is respected to prevent abuse ## Supported Versions diff --git a/background.js b/background.js index f687fb5..24dc655 100644 --- a/background.js +++ b/background.js @@ -1,5 +1,5 @@ import { createHeaders, handleApiResponse, mapActivity, filterActivitiesByDate } from './shared/github-api.js'; -import { getSyncItems, getLocalItems, setLocalItem, getExcludedRepos, getToken, getFilteringSettings } from './shared/storage-helpers.js'; +import { getSyncItems, getLocalItems, setLocalItem, getExcludedRepos, getAccessToken, getFilteringSettings, getWatchedRepos } from './shared/storage-helpers.js'; import { extractRepoName } from './shared/repository-utils.js'; import { safelyOpenUrl } from './shared/security.js'; @@ -73,11 +73,10 @@ if (typeof chrome !== 'undefined' && chrome.notifications) { async function checkGitHubActivity() { try { - // Get token from secure local storage - const githubToken = await getToken(); + const githubToken = await getAccessToken(); - const { watchedRepos, lastCheck, filters, notifications, mutedRepos, snoozedRepos, unmutedRepos } = await getSyncItems([ - 'watchedRepos', + const watchedRepos = await getWatchedRepos(); + const { lastCheck, filters, notifications, mutedRepos, snoozedRepos, unmutedRepos } = await getSyncItems([ 'lastCheck', 'filters', 'notifications', @@ -87,7 +86,7 @@ async function checkGitHubActivity() { ]); if (!githubToken) { - console.warn('[DevWatch] No GitHub token found. Please add a token in settings.'); + console.warn('[DevWatch] No GitHub connection found. Please connect GitHub in settings.'); return; } @@ -287,7 +286,7 @@ async function fetchRepoActivity(repo, token, since, filters) { // Store error for user display but don't crash let userMessage = 'Unable to fetch repository activity'; if (error.message.includes('401')) { - userMessage = 'Authentication failed. Please check your GitHub token.'; + userMessage = 'GitHub sign-in expired or was revoked. Reconnect GitHub in settings.'; } else if (error.message.includes('403')) { userMessage = 'Access denied or rate limit exceeded.'; } else if (error.message.includes('404')) { diff --git a/manifest.json b/manifest.json index 3b9504f..1c7478f 100644 --- a/manifest.json +++ b/manifest.json @@ -10,7 +10,8 @@ "notifications" ], "host_permissions": [ - "https://api.github.com/*" + "https://api.github.com/*", + "https://github.com/*" ], "background": { "service_worker": "background.js", @@ -33,6 +34,6 @@ "128": "icons/icon128.png" }, "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://api.github.com https://registry.npmjs.org; img-src 'self' https: data:; default-src 'self'; style-src 'self'" + "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://api.github.com https://github.com https://registry.npmjs.org; img-src 'self' https: data:; default-src 'self'; style-src 'self'" } } diff --git a/options/controllers/export-import-controller.js b/options/controllers/export-import-controller.js index 5740b07..1267cad 100644 --- a/options/controllers/export-import-controller.js +++ b/options/controllers/export-import-controller.js @@ -1,16 +1,18 @@ import { NotificationManager } from '../../shared/ui/notification-manager.js'; +import { getWatchedRepos, setWatchedRepos } from '../../shared/storage-helpers.js'; const notifications = NotificationManager.getInstance(); export async function exportSettings() { try { const syncData = await chrome.storage.sync.get(null); + const watchedRepos = await getWatchedRepos(); const exportData = { version: '1.0.0', exportedAt: new Date().toISOString(), settings: { - watchedRepos: syncData.watchedRepos || [], + watchedRepos, mutedRepos: syncData.mutedRepos || [], pinnedRepos: syncData.pinnedRepos || [], filters: syncData.filters || { prs: true, issues: true, releases: true }, @@ -53,7 +55,7 @@ export async function handleImportFile(event, loadSettingsCallback) { } const confirmed = confirm( - 'This will replace your current settings (except GitHub token). Continue?' + 'This will replace your current settings (except your GitHub connection). Continue?' ); if (!confirmed) { @@ -63,8 +65,8 @@ export async function handleImportFile(event, loadSettingsCallback) { } const settings = importData.settings; + await setWatchedRepos(settings.watchedRepos || []); await chrome.storage.sync.set({ - watchedRepos: settings.watchedRepos || [], mutedRepos: settings.mutedRepos || [], pinnedRepos: settings.pinnedRepos || [], filters: settings.filters || { prs: true, issues: true, releases: true }, diff --git a/options/controllers/import-controller.js b/options/controllers/import-controller.js index d355295..4f2dd3f 100644 --- a/options/controllers/import-controller.js +++ b/options/controllers/import-controller.js @@ -1,4 +1,5 @@ -import { getToken } from '../../shared/storage-helpers.js'; +import { STORAGE_CONFIG } from '../../shared/config.js'; +import { getAccessToken, getSyncItem, setWatchedRepos } from '../../shared/storage-helpers.js'; import { createHeaders } from '../../shared/github-api.js'; import { escapeHtml, unescapeHtml } from '../../shared/sanitize.js'; import { formatDateVerbose } from '../../shared/utils.js'; @@ -61,8 +62,27 @@ function formatNumber(num) { return num.toString(); } +function getRepoFullName(repo) { + if (typeof repo === 'string') { + return repo; + } + + return repo?.fullName || ''; +} + +function normalizeImportedRepo(repo) { + return { + fullName: repo.fullName, + description: repo.description || 'No description provided', + language: repo.language || 'Unknown', + stars: repo.stars || 0, + updatedAt: repo.updatedAt || new Date().toISOString(), + addedAt: new Date().toISOString() + }; +} + export async function openImportModal(type, watchedRepos) { - const token = await getToken(); + const token = await getAccessToken(); if (!token) { return; } @@ -99,7 +119,9 @@ export async function openImportModal(type, watchedRepos) { const repos = await fetchReposFromGitHub(type, token); const alreadyAdded = new Set( - (watchedRepos || []).map(r => r.fullName.toLowerCase()) + (watchedRepos || []) + .map(repo => getRepoFullName(repo).toLowerCase()) + .filter(Boolean) ); importModalState.repos = repos.map(repo => ({ @@ -162,7 +184,7 @@ async function fetchReposFromGitHub(type, token) { if (!response.ok) { if (response.status === 401) { - throw new Error('Invalid GitHub token'); + throw new Error('GitHub sign-in expired or was revoked'); } else if (response.status === 403) { throw new Error('Rate limit exceeded or insufficient permissions'); } else { @@ -207,6 +229,12 @@ export function closeImportModal() { const modal = document.getElementById('importModal'); modal.classList.remove('show'); document.getElementById('importRepoSearch').value = ''; + const selectAllCheckbox = document.getElementById('selectAllImport'); + if (selectAllCheckbox) { + selectAllCheckbox.checked = false; + } + document.getElementById('selectedCount').textContent = '0'; + document.getElementById('confirmImportBtn').disabled = true; modal.removeEventListener('keydown', handleModalFocusTrap); @@ -311,14 +339,36 @@ export async function importSelectedRepos(watchedRepos, onReposAdded) { return; } - const reposWithTimestamp = reposToImport.map(repo => ({ - ...repo, - addedAt: new Date().toISOString() - })); + const existingRepoNames = new Set( + (watchedRepos || []) + .map(repo => getRepoFullName(repo).toLowerCase()) + .filter(Boolean) + ); + const uniqueReposToImport = reposToImport.filter(repo => !existingRepoNames.has(repo.fullName.toLowerCase())); + + if (uniqueReposToImport.length === 0) { + return; + } + + const allowUnlimitedRepos = await getSyncItem('allowUnlimitedRepos', false); + + if (!allowUnlimitedRepos) { + const remainingSlots = Math.max(STORAGE_CONFIG.MAX_WATCHED_REPOS - existingRepoNames.size, 0); + + if (uniqueReposToImport.length > remainingSlots) { + throw new Error( + `Import would exceed the ${STORAGE_CONFIG.MAX_WATCHED_REPOS} repository limit. Select ${remainingSlots} or fewer repositories, or enable "Unlimited Repositories" in Advanced settings.` + ); + } + } - watchedRepos.push(...reposWithTimestamp); + const nextWatchedRepos = [ + ...watchedRepos, + ...uniqueReposToImport.map(normalizeImportedRepo) + ]; - await chrome.storage.sync.set({ watchedRepos }); + await setWatchedRepos(nextWatchedRepos); + watchedRepos.splice(0, watchedRepos.length, ...nextWatchedRepos); if (onReposAdded) { onReposAdded(); diff --git a/options/controllers/token-controller.js b/options/controllers/token-controller.js index 3d3d6bf..07fd82f 100644 --- a/options/controllers/token-controller.js +++ b/options/controllers/token-controller.js @@ -1,138 +1,164 @@ -import { createHeaders } from '../../shared/github-api.js'; -import { clearToken as clearStoredToken } from '../../shared/storage-helpers.js'; +import { completeGitHubDeviceAuth } from '../../shared/auth.js'; +import { + clearAuthSession, + getAuthSession, + setAuthSession +} from '../../shared/storage-helpers.js'; +import { OAUTH_CONFIG } from '../../shared/config.js'; import { NotificationManager } from '../../shared/ui/notification-manager.js'; const notifications = NotificationManager.getInstance(); -export async function clearToken() { - if (!confirm('Are you sure you want to clear your GitHub token?')) { - return false; - } - - document.getElementById('githubToken').value = ''; - document.getElementById('tokenStatus').textContent = ''; - document.getElementById('tokenStatus').className = 'token-status'; - document.getElementById('clearTokenBtn').style.display = 'none'; - +function setRepoAccessState(isConnected) { const repoInput = document.getElementById('repoInput'); - repoInput.disabled = true; - repoInput.placeholder = 'Enter a valid GitHub token to add repositories'; - document.getElementById('addRepoBtn').disabled = true; - document.getElementById('repoHelpText').textContent = 'Add a valid GitHub token above to start adding repositories'; + const addRepoBtn = document.getElementById('addRepoBtn'); + const repoHelpText = document.getElementById('repoHelpText'); + const importSection = document.getElementById('importReposSection'); + + repoInput.disabled = !isConnected; + repoInput.placeholder = isConnected + ? 'e.g., react, facebook/react, or GitHub URL' + : 'Connect GitHub to add repositories'; + addRepoBtn.disabled = !isConnected; + repoHelpText.textContent = isConnected + ? 'Add repositories to monitor (npm package, owner/repo, or GitHub URL)' + : 'Connect GitHub above to start adding repositories'; + importSection.classList.toggle('hidden', !isConnected); + importSection.style.display = isConnected ? 'block' : 'none'; +} - document.getElementById('importReposSection').style.display = 'none'; +function setDeviceCode(userCode = '') { + const deviceCodeInput = document.getElementById('githubToken'); + const deviceCodeSection = document.getElementById('deviceCodeSection'); - await clearStoredToken(); + if (!deviceCodeInput || !deviceCodeSection) { + return; + } - notifications.info('GitHub token cleared successfully'); - return true; + deviceCodeInput.value = userCode; + deviceCodeSection.classList.toggle('hidden', !userCode); + deviceCodeSection.style.display = userCode ? 'block' : 'none'; } -export async function validateToken(token, toastManager, options = {}) { +function setStatus(message = '', statusClass = '') { const statusEl = document.getElementById('tokenStatus'); - const shouldApplyResult = options.shouldApplyResult ?? (() => true); - try { - const response = await fetch('https://api.github.com/user', { - headers: createHeaders(token) - }); + if (!statusEl) { + return; + } - if (response.ok) { - const user = await response.json(); - if (!shouldApplyResult()) { - return { isValid: true, user: user.login }; - } + statusEl.textContent = message; + statusEl.className = `token-status${statusClass ? ` ${statusClass}` : ''}`; +} - statusEl.textContent = `✓ Valid (${user.login})`; - statusEl.className = 'token-status valid'; - document.getElementById('clearTokenBtn').style.display = 'block'; - - const repoInput = document.getElementById('repoInput'); - repoInput.disabled = false; - repoInput.placeholder = 'e.g., react, facebook/react, or GitHub URL'; - document.getElementById('addRepoBtn').disabled = false; - document.getElementById('repoHelpText').textContent = 'Add repositories to monitor (npm package, owner/repo, or GitHub URL)'; - - const importSection = document.getElementById('importReposSection'); - importSection.classList.remove('hidden'); - importSection.style.display = 'block'; - - if (!toastManager.lastValidToken || toastManager.lastValidToken !== token) { - if (toastManager.isManualTokenEntry) { - notifications.success(`GitHub token validated successfully for user: ${user.login}`); - } - toastManager.lastValidToken = token; - } +export function applyStoredConnection(authSession, options = {}) { + const connectBtn = document.getElementById('connectGitHubBtn'); + const clearBtn = document.getElementById('clearTokenBtn'); + const isConnected = Boolean(authSession?.accessToken); + const username = authSession?.username; - toastManager.isManualTokenEntry = false; - return { isValid: true, user: user.login }; - } else if (response.status === 401) { - if (!shouldApplyResult()) { - return { isValid: false, reason: 'invalid' }; - } + setRepoAccessState(isConnected); + setDeviceCode(options.userCode || ''); - statusEl.textContent = '✗ Invalid token'; - statusEl.className = 'token-status invalid'; - document.getElementById('clearTokenBtn').style.display = 'none'; + if (connectBtn) { + connectBtn.disabled = false; + connectBtn.textContent = isConnected ? 'Reconnect GitHub' : 'Connect GitHub'; + } - const repoInput = document.getElementById('repoInput'); - repoInput.disabled = true; - repoInput.placeholder = 'Enter a valid GitHub token to add repositories'; - document.getElementById('addRepoBtn').disabled = true; - document.getElementById('repoHelpText').textContent = 'Invalid token. Please check your GitHub token and try again.'; + if (clearBtn) { + clearBtn.style.display = isConnected ? 'block' : 'none'; + } - const importSection = document.getElementById('importReposSection'); - importSection.classList.add('hidden'); - importSection.style.display = 'none'; + if (options.statusMessage) { + setStatus(options.statusMessage, options.statusClass); + return; + } - if (!toastManager.lastInvalidToken || toastManager.lastInvalidToken !== token) { - notifications.error('Invalid GitHub token. Please check your token and try again.'); - toastManager.lastInvalidToken = token; - } - return { isValid: false, reason: 'invalid' }; - } else { - if (!shouldApplyResult()) { - return { isValid: false, reason: 'http', status: response.status }; - } + if (isConnected) { + setStatus( + username ? `Connected as ${username}` : 'GitHub is connected', + 'valid' + ); + } else { + setStatus('', ''); + } +} - statusEl.textContent = `✗ Error (${response.status})`; - statusEl.className = 'token-status invalid'; - document.getElementById('clearTokenBtn').style.display = 'none'; +export async function clearToken() { + if (!confirm('Disconnect GitHub from DevWatch?')) { + return false; + } + + await clearAuthSession(); + + applyStoredConnection(null); + notifications.info('GitHub disconnected'); + return true; +} + +function getErrorMessage(error) { + switch (error?.code) { + case 'client_id_missing': + return 'GitHub OAuth client ID is not configured for this build yet.'; + case 'access_denied': + return 'GitHub sign-in was cancelled before access was granted.'; + case 'expired_token': + return 'The GitHub sign-in code expired. Start again to reconnect.'; + case 'aborted': + return 'GitHub sign-in was cancelled.'; + default: + return 'GitHub sign-in failed. Try again in a moment.'; + } +} + +export async function connectGitHub(_toastManager) { + const previousSession = await getAuthSession(); + const connectBtn = document.getElementById('connectGitHubBtn'); + let nextButtonLabel = previousSession?.accessToken ? 'Reconnect GitHub' : 'Connect GitHub'; - const repoInput = document.getElementById('repoInput'); - repoInput.disabled = true; - repoInput.placeholder = 'Enter a valid GitHub token to add repositories'; - document.getElementById('addRepoBtn').disabled = true; - document.getElementById('repoHelpText').textContent = 'GitHub API error. Please try again later.'; + if (connectBtn) { + connectBtn.disabled = true; + connectBtn.textContent = 'Waiting for GitHub...'; + } - const importSection = document.getElementById('importReposSection'); - importSection.classList.add('hidden'); - importSection.style.display = 'none'; + setRepoAccessState(Boolean(previousSession?.accessToken)); + setStatus('Starting GitHub sign-in...', 'checking'); - if (!toastManager.lastApiError || toastManager.lastApiError !== response.status) { - notifications.error(`GitHub API error (${response.status}). Please try again later.`); - toastManager.lastApiError = response.status; + try { + const result = await completeGitHubDeviceAuth({ + onCode: ({ userCode }) => { + setDeviceCode(userCode || ''); + setStatus(`Enter ${userCode} on GitHub to finish connecting.`, 'checking'); } - return { isValid: false, reason: 'http', status: response.status }; - } - } catch (_error) { - if (!shouldApplyResult()) { - return { isValid: false, reason: 'network' }; - } + }); - statusEl.textContent = '✗ Network error'; - statusEl.className = 'token-status invalid'; - document.getElementById('clearTokenBtn').style.display = 'none'; + await setAuthSession(result.authSession); - const repoInput = document.getElementById('repoInput'); - repoInput.disabled = true; - repoInput.placeholder = 'Enter a valid GitHub token to add repositories'; - document.getElementById('addRepoBtn').disabled = true; - document.getElementById('repoHelpText').textContent = 'Network error. Please check your connection and try again.'; + applyStoredConnection(result.authSession); + nextButtonLabel = 'Reconnect GitHub'; + notifications.success(`Connected to GitHub as ${result.user.login}`); + return { isValid: true, user: result.user.login, authSession: result.authSession }; + } catch (error) { + applyStoredConnection(previousSession, { + statusMessage: getErrorMessage(error), + statusClass: 'invalid' + }); - document.getElementById('importReposSection').style.display = 'none'; + if (previousSession?.accessToken) { + notifications.warning(getErrorMessage(error)); + } else { + notifications.error(getErrorMessage(error)); + } - notifications.error('Network error while validating token. Please check your connection and try again.'); - return { isValid: false, reason: 'network' }; + return { isValid: false, reason: error?.code || 'auth_failed' }; + } finally { + if (connectBtn) { + connectBtn.disabled = false; + connectBtn.textContent = nextButtonLabel; + } } } + +export function getDisconnectHelpUrl() { + return OAUTH_CONFIG.AUTHORIZED_APPS_URL; +} diff --git a/options/options.css b/options/options.css index 7d20bb8..e5b35a6 100644 --- a/options/options.css +++ b/options/options.css @@ -2199,6 +2199,27 @@ body.dark-mode .notification-toggle input:checked + .toggle-slider { flex: 1; } +.auth-button-row { + flex-wrap: wrap; +} + +#deviceCodeSection { + flex-direction: column; + align-items: stretch; +} + +.device-code-label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); +} + +#deviceCodeSection input { + letter-spacing: 0.12em; + font-weight: 600; + text-align: center; +} + .token-status { display: block; font-size: 12px; diff --git a/options/options.html b/options/options.html index d0704b0..e6471d8 100644 --- a/options/options.html +++ b/options/options.html @@ -65,44 +65,47 @@

Getting Started

1
-
-

Create a GitHub Token

-

This allows the extension to fetch your repository activity securely.

-
-
- -
- -
- - -
- -
- -

- → Create a token with recommended permissions or - manually create one with repo and read:user scopes. -

- -
-
-

- - Security: Your token is encrypted with AES-GCM encryption and stored securely on your device. It's never transmitted to third parties. -

-
-
-

+

+

Connect GitHub

+

Sign in once so DevWatch can check the repositories you want to follow.

+
+
+ +
+ +
+ + +
+ + +
+ +

+ Click Connect GitHub, approve access on GitHub, and come back here once the browser tab says you can close it. +

+ +
+
+

- Expiration: Tokens expire after 90 days by default. You'll need to create a new token when yours expires, or select "No expiration" when creating your token (not recommended for security). -

-
-
+ + + Security: Your GitHub sign-in session is encrypted with AES-GCM and stored locally on your device. +

+
+
+

+ + Permissions: DevWatch requests repo and read:user access so it can read repository activity and identify your account. +

+
+
@@ -153,7 +156,7 @@

View Help & Changelog

Add New Repository

-

Add a valid GitHub token above to start adding repositories

+

Connect GitHub above to start adding repositories

@@ -611,7 +614,7 @@

Clear All Data

Reset to Defaults

-

Reset all settings to default values. Your token and repositories will be cleared.

+

Reset all settings to default values. Your GitHub connection and repositories will be cleared.

@@ -250,7 +379,7 @@ async function renderTokenStep() { - Your token is encrypted with AES-GCM encryption and stored securely on your device. It's only used for GitHub API access and never shared. + Your GitHub sign-in session is encrypted with AES-GCM encryption and stored securely on your device. It's only used for GitHub API access and never shared.

`; } @@ -494,27 +623,11 @@ export async function setupOnboardingStepListeners(currentStep, loadActivitiesCa exitOnboarding(loadActivitiesCallback); }); - // Token validation logic if (currentStep === 'token') { - const tokenInput = document.getElementById('tokenInput'); - - // Initial validation state - const validateTokenInput = () => { - const token = tokenInput?.value?.trim(); - const isValid = token && token.length > 10 && (token.startsWith('ghp_') || token.startsWith('github_pat_') || token.length >= 20); - - if (nextBtn) { - nextBtn.disabled = !isValid; - } - - return isValid; - }; - - // Add input listener for real-time validation - tokenInput?.addEventListener('input', validateTokenInput); - - // Initial validation - validateTokenInput(); + const tokenData = await onboardingManager.getStepData('token'); + if (nextBtn) { + nextBtn.disabled = !tokenData?.validated; + } } // Step-specific listeners @@ -533,36 +646,97 @@ export async function setupOnboardingStepListeners(currentStep, loadActivitiesCa function setupTokenStepListeners() { const tokenInput = document.getElementById('tokenInput'); + const copyTokenCodeBtn = document.getElementById('copyTokenCodeBtn'); const validateBtn = document.getElementById('validateTokenBtn'); const tokenStatus = document.getElementById('tokenStatus'); + const nextBtn = document.getElementById('nextBtn'); + const tokenElements = { tokenInput, validateBtn, tokenStatus, nextBtn }; - validateBtn?.addEventListener('click', async () => { - const token = tokenInput.value.trim(); - if (!token) { - tokenStatus.innerHTML = getStatusMarkup('error', 'Please enter a token'); + tokenInput?.addEventListener('click', () => { + tokenInput.select(); + }); + + tokenInput?.addEventListener('focus', () => { + tokenInput.select(); + }); + + copyTokenCodeBtn?.addEventListener('click', async () => { + const userCode = tokenInput?.value?.trim(); + if (!userCode) { return; } - tokenStatus.innerHTML = getStatusMarkup('loading', 'Validating token...'); + try { + const copied = await copyTextToClipboard(userCode); + if (copied) { + tokenStatus.innerHTML = getStatusMarkup('success', `Copied ${userCode}. Paste it into GitHub to finish connecting.`); + } + } catch (_error) { + tokenStatus.innerHTML = getStatusMarkup('error', 'Could not copy the code automatically. Select it manually.'); + } + }); + + // Resume the device flow if the popup was closed while GitHub was waiting + // for approval in another tab. + void (async () => { + const existingTokenData = await onboardingManager.getStepData('token'); + if (!existingTokenData?.validated && existingTokenData?.pendingDeviceAuth) { + tokenInput.value = existingTokenData.userCode || existingTokenData.pendingDeviceAuth.userCode || ''; + if (copyTokenCodeBtn) { + copyTokenCodeBtn.disabled = !tokenInput.value; + } + + try { + await completePendingDeviceAuth(existingTokenData, tokenElements, { + showCheckingStatus: true + }); + } catch (error) { + tokenStatus.innerHTML = getStatusMarkup('error', + error?.code === 'client_id_missing' + ? 'GitHub OAuth client ID is not configured for this build.' + : error?.code === 'access_denied' + ? 'GitHub sign-in was cancelled' + : error?.code === 'expired_token' + ? 'GitHub sign-in expired. Start again.' + : 'GitHub sign-in failed' + ); + validateBtn.disabled = false; + validateBtn.textContent = 'Connect GitHub'; + } + } + })(); + + validateBtn?.addEventListener('click', async () => { + validateBtn.disabled = true; + tokenStatus.innerHTML = getStatusMarkup('loading', 'Starting GitHub sign-in...'); try { - // Test the token by making a simple API call - const response = await fetch('https://api.github.com/user', { - headers: { - 'Authorization': `token ${token}` - } + const deviceCodeData = await requestGitHubDeviceCode(); + const pendingDeviceAuth = createPendingDeviceAuthState(deviceCodeData); + + tokenInput.value = deviceCodeData.userCode || ''; + if (copyTokenCodeBtn) { + copyTokenCodeBtn.disabled = !tokenInput.value; + } + tokenStatus.innerHTML = getStatusMarkup('loading', `Enter ${deviceCodeData.userCode} on GitHub to finish connecting.`); + await onboardingManager.saveStepData('token', { + userCode: deviceCodeData.userCode, + validated: false, + authType: 'oauth_device', + pendingDeviceAuth }); - if (response.ok) { - const userData = await response.json(); - const username = userData.login; - const tokenData = { token, validated: true, 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 - // unauthenticated fetches or hitting rate limits when prefetching. - await setToken(token); + openGitHubDevicePage(deviceCodeData); + const result = await completePendingDeviceAuth({ + userCode: deviceCodeData.userCode, + validated: false, + authType: 'oauth_device', + pendingDeviceAuth + }, tokenElements, { + showCheckingStatus: false + }); + + if (result) { try { const popular = await onboardingManager.getPopularRepos(); if (Array.isArray(popular) && popular.length > 0) { @@ -571,11 +745,19 @@ function setupTokenStepListeners() { } catch (_prefetchError) { // Silently handle prefetch errors - not critical } - } else { - tokenStatus.innerHTML = getStatusMarkup('error', '✗ Invalid token'); } - } catch (_error) { - tokenStatus.innerHTML = getStatusMarkup('error', 'Error validating token'); + } catch (error) { + tokenStatus.innerHTML = getStatusMarkup('error', + error?.code === 'client_id_missing' + ? 'GitHub OAuth client ID is not configured for this build.' + : error?.code === 'access_denied' + ? 'GitHub sign-in was cancelled' + : error?.code === 'expired_token' + ? 'GitHub sign-in expired. Start again.' + : 'GitHub sign-in failed' + ); + validateBtn.disabled = false; + validateBtn.textContent = 'Connect GitHub'; } }); } @@ -619,16 +801,17 @@ function attachRepoButtonListeners() { try { // Fetch full repo metadata from GitHub API - const token = await getToken(); - const headers = createHeaders(token); + const token = await getAccessToken(); + const headers = token + ? createHeaders(token) + : { 'Accept': 'application/vnd.github.v3+json' }; const response = await fetch(`https://api.github.com/repos/${repo}`, { headers }); if (response.ok) { const data = await response.json(); // Save repo to storage with full metadata - const result = await chrome.storage.sync.get(['watchedRepos']); - const repos = result.watchedRepos || []; + const repos = await getWatchedRepos(); // Check if repo already exists const repoExists = repos.some(r => r.fullName === repo); @@ -644,7 +827,7 @@ function attachRepoButtonListeners() { updatedAt: data.updated_at, addedAt: new Date().toISOString() }); - await chrome.storage.sync.set({ watchedRepos: repos }); + await setWatchedRepos(repos); } // Show success state @@ -697,7 +880,7 @@ function setupReposStepListeners() { try { // Get token for API calls - const githubToken = await getToken(); + const githubToken = await getAccessToken(); // Parse GitHub URL if provided const urlMatch = repo.match(/github\.com\/([^/]+\/[^/]+)/); @@ -724,21 +907,16 @@ function setupReposStepListeners() { } // Validate repo exists on GitHub - const headers = { - 'Accept': 'application/vnd.github.v3+json' - }; - - if (githubToken) { - headers['Authorization'] = `token ${githubToken}`; - } + const headers = githubToken + ? createHeaders(githubToken) + : { 'Accept': 'application/vnd.github.v3+json' }; const response = await fetch(`https://api.github.com/repos/${repo}`, { headers }); if (response.ok) { const data = await response.json(); - const result = await chrome.storage.sync.get(['watchedRepos']); - const repos = result.watchedRepos || []; + const repos = await getWatchedRepos(); const repoExists = repos.some(r => r.fullName === repo); if (!repoExists) { repos.push({ @@ -751,7 +929,7 @@ function setupReposStepListeners() { updatedAt: data.updated_at, addedAt: new Date().toISOString() }); - await chrome.storage.sync.set({ watchedRepos: repos }); + await setWatchedRepos(repos); } manualInput.value = ''; repoStatus.innerHTML = getStatusMarkup('success', '✓ Repository added'); @@ -827,36 +1005,15 @@ export async function handleNextStep() { // Save step data before proceeding switch (currentStep) { case 'token': { - const tokenInput = document.getElementById('tokenInput'); - const token = tokenInput?.value?.trim(); - - if (!token) { - // Show error and prevent navigation + const existing = await onboardingManager.getStepData('token') || {}; + if (!existing.validated) { const tokenStatus = document.getElementById('tokenStatus'); if (tokenStatus) { - tokenStatus.textContent = 'Please enter a GitHub token to continue.'; + tokenStatus.textContent = 'Connect GitHub to continue.'; tokenStatus.className = 'token-status error'; } - tokenInput?.focus(); - return; // Prevent navigation - } - - // Preserve existing validation status when saving token data - // This ensures that if the token was previously validated, returning to - // the token step will still show the success message. - const existing = await onboardingManager.getStepData('token') || {}; - await onboardingManager.saveStepData('token', { ...existing, token }); - // If token was validated, prefetch popular repos so step 2 shows them quickly - const validated = existing.validated; - if (validated) { - try { - const popular = await onboardingManager.getPopularRepos(); - if (Array.isArray(popular) && popular.length > 0) { - await onboardingManager.saveStepData('popularRepos', popular); - } - } catch (_prefetchError) { - // Silently fail - user can still manually search for repos - } + document.getElementById('validateTokenBtn')?.focus(); + return; } break; } diff --git a/shared/auth.js b/shared/auth.js new file mode 100644 index 0000000..7cf070d --- /dev/null +++ b/shared/auth.js @@ -0,0 +1,282 @@ +import { API_CONFIG, OAUTH_CONFIG } from './config.js'; + +function getStorageValue(area, key) { + return new Promise((resolve) => { + area.get([key], (result) => resolve(result?.[key] ?? null)); + }); +} + +function buildFormBody(params) { + const body = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + body.set(key, value); + } + }); + + return body.toString(); +} + +function getOAuthHeaders() { + return { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded' + }; +} + +async function parseOAuthResponse(response) { + const text = await response.text(); + + if (!text) { + return {}; + } + + try { + return JSON.parse(text); + } catch (_error) { + return Object.fromEntries(new URLSearchParams(text).entries()); + } +} + +function createOAuthError(message, code, details = {}) { + const error = new Error(message); + error.code = code; + Object.assign(error, details); + return error; +} + +function waitFor(ms, signal) { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + + function onAbort() { + cleanup(); + reject(createOAuthError('GitHub sign-in was cancelled.', 'aborted')); + } + + function cleanup() { + clearTimeout(timeoutId); + signal?.removeEventListener('abort', onAbort); + } + + if (signal?.aborted) { + cleanup(); + reject(createOAuthError('GitHub sign-in was cancelled.', 'aborted')); + return; + } + + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +function normalizeScopes(scopeValue = '') { + return String(scopeValue) + .split(/[,\s]+/) + .map(scope => scope.trim()) + .filter(Boolean); +} + +function getScopeString(scopes = OAUTH_CONFIG.SCOPES) { + return scopes.join(' '); +} + +function isConfiguredClientId(clientId) { + return Boolean(clientId) && clientId !== OAUTH_CONFIG.CLIENT_ID; +} + +async function getGitHubOAuthClientId() { + const storageKey = OAUTH_CONFIG.CLIENT_ID_STORAGE_KEY; + + if (chrome?.storage?.local) { + const localValue = await getStorageValue(chrome.storage.local, storageKey); + if (typeof localValue === 'string' && localValue.trim()) { + return localValue.trim(); + } + } + + if (chrome?.storage?.sync) { + const syncValue = await getStorageValue(chrome.storage.sync, storageKey); + if (typeof syncValue === 'string' && syncValue.trim()) { + return syncValue.trim(); + } + } + + return OAUTH_CONFIG.CLIENT_ID; +} + +async function requireGitHubOAuthClientId() { + const clientId = await getGitHubOAuthClientId(); + + if (!isConfiguredClientId(clientId)) { + throw createOAuthError( + 'GitHub OAuth client ID is not configured. Add one before testing sign-in.', + 'client_id_missing', + { storageKey: OAUTH_CONFIG.CLIENT_ID_STORAGE_KEY } + ); + } + + return clientId; +} + +export function createOAuthHeaders(accessToken) { + return { + 'Authorization': `Bearer ${accessToken}`, + 'Accept': 'application/vnd.github.v3+json' + }; +} + +export function createGitHubAuthSession(tokenData, user) { + const now = new Date().toISOString(); + + return { + authType: 'oauth_device', + accessToken: tokenData.accessToken, + tokenType: tokenData.tokenType || 'bearer', + scopes: tokenData.scopes || [], + username: user?.login || '', + userId: user?.id || null, + grantedAt: now, + expiresIn: tokenData.expiresIn ?? null, + refreshToken: tokenData.refreshToken ?? null, + refreshTokenExpiresIn: tokenData.refreshTokenExpiresIn ?? null + }; +} + +export async function requestGitHubDeviceCode() { + const clientId = await requireGitHubOAuthClientId(); + + const response = await fetch(OAUTH_CONFIG.DEVICE_CODE_URL, { + method: 'POST', + headers: getOAuthHeaders(), + body: buildFormBody({ + client_id: clientId, + scope: getScopeString() + }) + }); + + const data = await parseOAuthResponse(response); + + if (!response.ok) { + throw createOAuthError( + data.error_description || 'GitHub sign-in could not be started.', + data.error || 'device_code_failed', + { status: response.status } + ); + } + + return { + deviceCode: data.device_code, + userCode: data.user_code, + verificationUri: data.verification_uri || OAUTH_CONFIG.DEVICE_VERIFY_URL, + verificationUriComplete: data.verification_uri_complete || null, + expiresIn: data.expires_in, + interval: data.interval ?? 5 + }; +} + +export function openGitHubDevicePage(deviceCodeData) { + const targetUrl = deviceCodeData.verificationUriComplete || deviceCodeData.verificationUri || OAUTH_CONFIG.DEVICE_VERIFY_URL; + + if (chrome?.tabs?.create) { + chrome.tabs.create({ url: targetUrl }); + } +} + +export async function pollForGitHubAccessToken(deviceCodeData, options = {}) { + const clientId = await requireGitHubOAuthClientId(); + const { signal, onPoll } = options; + const expiresAt = deviceCodeData.expiresAt ?? (Date.now() + ((deviceCodeData.expiresIn ?? 900) * 1000)); + let intervalMs = (deviceCodeData.interval ?? 5) * 1000; + + while (Date.now() < expiresAt) { + onPoll?.({ + expiresAt, + intervalMs, + remainingMs: Math.max(0, expiresAt - Date.now()) + }); + + const response = await fetch(OAUTH_CONFIG.ACCESS_TOKEN_URL, { + method: 'POST', + headers: getOAuthHeaders(), + body: buildFormBody({ + client_id: clientId, + device_code: deviceCodeData.deviceCode, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code' + }), + signal + }); + + const data = await parseOAuthResponse(response); + + if (data.access_token) { + return { + accessToken: data.access_token, + tokenType: data.token_type || 'bearer', + scopes: normalizeScopes(data.scope), + refreshToken: data.refresh_token || null, + refreshTokenExpiresIn: data.refresh_token_expires_in ?? null, + expiresIn: data.expires_in ?? null + }; + } + + switch (data.error) { + case 'authorization_pending': + await waitFor(intervalMs, signal); + continue; + case 'slow_down': + intervalMs += 5000; + await waitFor(intervalMs, signal); + continue; + case 'access_denied': + throw createOAuthError('GitHub sign-in was denied.', 'access_denied'); + case 'expired_token': + throw createOAuthError('GitHub sign-in code expired. Start again to reconnect.', 'expired_token'); + default: + throw createOAuthError( + data.error_description || 'GitHub sign-in failed while waiting for approval.', + data.error || 'poll_failed', + { status: response.status } + ); + } + } + + throw createOAuthError('GitHub sign-in code expired. Start again to reconnect.', 'expired_token'); +} + +export async function fetchGitHubUser(accessToken) { + const response = await fetch(`${API_CONFIG.GITHUB_API_BASE}/user`, { + headers: createOAuthHeaders(accessToken) + }); + + if (!response.ok) { + throw createOAuthError('Could not load the GitHub account after sign-in.', 'user_fetch_failed', { + status: response.status + }); + } + + return response.json(); +} + +export async function completeGitHubDeviceAuth(options = {}) { + const deviceCodeData = await requestGitHubDeviceCode(); + + options.onCode?.(deviceCodeData); + openGitHubDevicePage(deviceCodeData); + + const tokenData = await pollForGitHubAccessToken(deviceCodeData, { + signal: options.signal, + onPoll: options.onPoll + }); + const user = await fetchGitHubUser(tokenData.accessToken); + + return { + deviceCodeData, + tokenData, + user, + authSession: createGitHubAuthSession(tokenData, user) + }; +} diff --git a/shared/config.js b/shared/config.js index c338a0c..7641fcb 100644 --- a/shared/config.js +++ b/shared/config.js @@ -16,6 +16,17 @@ export const API_CONFIG = { MAX_REPOS_PER_REQUEST: 100 }; +// OAuth Configuration +export const OAUTH_CONFIG = { + CLIENT_ID: 'YOUR_GITHUB_OAUTH_CLIENT_ID', + CLIENT_ID_STORAGE_KEY: 'githubOAuthClientId', + SCOPES: ['repo', 'read:user'], + DEVICE_CODE_URL: 'https://github.com/login/device/code', + ACCESS_TOKEN_URL: 'https://github.com/login/oauth/access_token', + DEVICE_VERIFY_URL: 'https://github.com/login/device', + AUTHORIZED_APPS_URL: 'https://github.com/settings/applications' +}; + // Rate Limiting Configuration export const RATE_LIMIT_CONFIG = { DEFAULT_CHECK_INTERVAL: 15, // minutes @@ -77,12 +88,12 @@ export const FEATURES = { // Error Messages export const ERROR_MESSAGES = { NETWORK_ERROR: 'Network connection error. Please check your internet connection.', - AUTH_FAILED: 'Authentication failed. Please check your GitHub token.', + AUTH_FAILED: 'GitHub sign-in expired or was revoked. Reconnect GitHub and try again.', RATE_LIMITED: 'Rate limit exceeded. Please wait before making more requests.', NOT_FOUND: 'Repository not found or access denied.', FORBIDDEN: 'Access denied. Please check your permissions.', SERVER_ERROR: 'GitHub API is experiencing issues. Please try again later.', - INVALID_TOKEN: 'Invalid GitHub token format.', + INVALID_TOKEN: 'GitHub sign-in is no longer valid. Reconnect GitHub and try again.', CORS_ERROR: 'CORS error. Please check your browser settings.', STORAGE_ERROR: 'Storage error. Please check your browser settings.', VALIDATION_ERROR: 'Invalid input. Please check your repository format.', @@ -92,7 +103,6 @@ export const ERROR_MESSAGES = { // Validation Patterns export const VALIDATION_PATTERNS = { REPOSITORY_NAME: /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/, - GITHUB_TOKEN: /^ghp_[a-zA-Z0-9]{36}$/, USERNAME: /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,38})[a-zA-Z0-9]$/, URL: /^https?:\/\/.+/, EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ @@ -129,6 +139,7 @@ export const DEV_CONFIG = { // Export all configurations as a single object for easy access export const CONFIG = { API: API_CONFIG, + OAUTH: OAUTH_CONFIG, RATE_LIMIT: RATE_LIMIT_CONFIG, STORAGE: STORAGE_CONFIG, UI: UI_CONFIG, @@ -139,4 +150,4 @@ export const CONFIG = { VALIDATION_PATTERNS, DEFAULTS, DEV: DEV_CONFIG -}; \ No newline at end of file +}; diff --git a/shared/crypto-utils.js b/shared/crypto-utils.js index 33b29f8..5665577 100644 --- a/shared/crypto-utils.js +++ b/shared/crypto-utils.js @@ -1,5 +1,5 @@ /** - * Cryptographic utilities for secure token storage + * Cryptographic utilities for secure auth session storage * Uses Web Crypto API (AES-GCM) */ diff --git a/shared/error-handler.js b/shared/error-handler.js index 319e154..77d247f 100644 --- a/shared/error-handler.js +++ b/shared/error-handler.js @@ -29,9 +29,9 @@ const ERROR_MESSAGES = { action: 'Try Again' }, [ERROR_TYPES.AUTHENTICATION]: { - title: 'Token Expired', - message: 'Your GitHub token has expired or is invalid. Tokens expire after 90 days by default. Please create a new token and update it in settings.', - action: 'Go to Settings' + title: 'GitHub Sign-In Needed', + message: 'Your GitHub sign-in expired or was revoked. Reconnect GitHub in settings to keep monitoring repositories.', + action: 'Open Settings' }, [ERROR_TYPES.RATE_LIMIT]: { title: 'Rate Limit Exceeded', @@ -96,7 +96,14 @@ export function classifyError(error, response = null) { if (errorMessage.includes('fetch') || errorMessage.includes('network')) { return ERROR_TYPES.NETWORK; } - if (errorMessage.includes('token') || errorMessage.includes('auth')) { + if ( + errorMessage.includes('token') + || errorMessage.includes('auth') + || errorMessage.includes('sign-in') + || errorMessage.includes('revoked') + || errorMessage.includes('unauthorized') + || errorMessage.includes('expired') + ) { return ERROR_TYPES.AUTHENTICATION; } if (errorMessage.includes('rate limit') || errorMessage.includes('429')) { @@ -353,4 +360,4 @@ export async function handleApiResponse(response, context = 'API request') { } return response; -} \ No newline at end of file +} diff --git a/shared/github-api.js b/shared/github-api.js index a4032bf..ef7829f 100644 --- a/shared/github-api.js +++ b/shared/github-api.js @@ -4,12 +4,12 @@ /** * Create standard GitHub API headers - * @param {string} token - GitHub personal access token + * @param {string} token - GitHub access token * @returns {Object} Headers object for fetch */ export function createHeaders(token) { return { - 'Authorization': `token ${token}`, + 'Authorization': `Bearer ${token}`, 'Accept': 'application/vnd.github.v3+json' }; } @@ -24,7 +24,7 @@ export function handleApiResponse(response, repo = '') { if (!response.ok) { let error; if (response.status === 401) { - error = new Error('Invalid GitHub token'); + error = new Error('GitHub sign-in expired or was revoked'); } else if (response.status === 403) { error = new Error('Rate limit exceeded'); } else if (response.status === 404) { diff --git a/shared/onboarding.js b/shared/onboarding.js index 1da5d50..1c387d5 100644 --- a/shared/onboarding.js +++ b/shared/onboarding.js @@ -3,7 +3,8 @@ * Handles first-run experience and setup flow */ -import { getToken } from './storage-helpers.js'; +import { getAccessToken } from './storage-helpers.js'; +import { createHeaders } from './github-api.js'; export class OnboardingManager { static STORAGE_KEY = 'onboarding_state'; @@ -124,26 +125,12 @@ export class OnboardingManager { async getPopularRepos() { try { - // Try to fetch trending repositories from GitHub API - // Get stored token (if the user entered one) and include it to - // improve rate limits and ensure access to any private data the - // token allows. We intentionally avoid setting the User-Agent - // header in browser fetch to avoid disallowed header errors. - // Prefer token stored in onboarding step data (user-entered token) - // so that prefetching works even if storage hasn't been updated yet - // by UI code that persists the token. Fallback to getToken() to - // support tokens set outside onboarding. - const tokenStep = await this.getStepData('token'); - const storedToken = await getToken(); - const githubToken = tokenStep?.token || storedToken; - - const headers = { - 'Accept': 'application/vnd.github.v3+json' - }; - - if (githubToken) { - headers['Authorization'] = `token ${githubToken}`; - } + // Try to fetch trending repositories from GitHub API using the + // current authenticated session when it is available. + const storedToken = await getAccessToken(); + const headers = storedToken + ? createHeaders(storedToken) + : { 'Accept': 'application/vnd.github.v3+json' }; const apiUrl = 'https://api.github.com/search/repositories?q=stars:1000..50000&sort=stars&order=desc&per_page=20'; @@ -329,10 +316,6 @@ export class OnboardingManager { return shuffled; } - getGitHubTokenUrl() { - return 'https://github.com/settings/tokens/new?scopes=repo,notifications&description=DevWatch%20Chrome%20Extension'; - } - async getProgress() { const state = await this.getState(); @@ -369,4 +352,4 @@ export class OnboardingManager { } } -export default OnboardingManager; \ No newline at end of file +export default OnboardingManager; diff --git a/shared/repository-validator.js b/shared/repository-validator.js index 689cacb..9eed201 100644 --- a/shared/repository-validator.js +++ b/shared/repository-validator.js @@ -24,7 +24,7 @@ export function isValidRepoFormat(repo) { * 3. Return basic metadata needed for display * * @param {string} repo - Repository identifier in format "owner/repo" - * @param {string} [token] - Optional GitHub token for authentication + * @param {string} [token] - Optional GitHub access token for authentication * @returns {Promise} Validation result * @property {boolean} valid - Whether repository is valid * @property {Object|null} metadata - Basic repository metadata if valid @@ -89,7 +89,7 @@ export async function validateRepository(repo, token = null) { case 401: return { valid: false, - error: 'Authentication failed. Check your GitHub token.' + error: 'GitHub sign-in expired or was revoked. Reconnect GitHub and try again.' }; default: return { @@ -117,7 +117,7 @@ export async function validateRepository(repo, token = null) { * This can be used when more detailed information is needed * * @param {string} repo - Repository identifier in format "owner/repo" - * @param {string} [token] - Optional GitHub token for authentication + * @param {string} [token] - Optional GitHub access token for authentication * @returns {Promise} Enhanced validation result with additional metadata */ export async function validateRepositoryEnhanced(repo, token = null) { @@ -174,7 +174,7 @@ export async function validateRepositoryEnhanced(repo, token = null) { /** * Batch validate multiple repositories * @param {Array} repos - Array of repository strings - * @param {string} [token] - Optional GitHub token for authentication + * @param {string} [token] - Optional GitHub access token for authentication * @param {Function} [progressCallback] - Optional progress callback * @returns {Promise} Array of validation results */ @@ -230,4 +230,4 @@ export function quickValidateRepo(repo) { valid: true, normalized: trimmedRepo }; -} \ No newline at end of file +} diff --git a/shared/state-manager.js b/shared/state-manager.js index a88da37..24196e2 100644 --- a/shared/state-manager.js +++ b/shared/state-manager.js @@ -3,7 +3,7 @@ * Provides consistent state handling across popup, options, and background scripts */ -import { getSyncItems, getLocalItems } from './storage-helpers.js'; +import { getSyncItems, getLocalItems, getWatchedRepos, setWatchedRepos } from './storage-helpers.js'; import { STORAGE_KEYS, STORAGE_DEFAULTS } from './storage-helpers.js'; import { STORAGE_CONFIG } from './config.js'; @@ -67,12 +67,13 @@ class StateManager { // Load settings from storage const settings = await getSyncItems(STORAGE_KEYS.SETTINGS); + const watchedRepos = await getWatchedRepos(); const activityData = await getLocalItems(STORAGE_KEYS.ACTIVITY); // Update state with loaded data this.state = { ...this.state, - watchedRepos: settings.watchedRepos || STORAGE_DEFAULTS.watchedRepos, + watchedRepos, mutedRepos: settings.mutedRepos || STORAGE_DEFAULTS.mutedRepos, snoozedRepos: settings.snoozedRepos || STORAGE_DEFAULTS.snoozedRepos, filters: { ...STORAGE_DEFAULTS.filters, ...settings.filters }, @@ -190,7 +191,11 @@ class StateManager { const persistPromises = []; // Persist settings that go to sync storage - const syncKeys = ['watchedRepos', 'mutedRepos', 'snoozedRepos', 'filters', 'notifications', 'checkInterval', 'theme', 'itemExpiryHours']; + if ('watchedRepos' in updatesObj) { + persistPromises.push(setWatchedRepos(updatesObj.watchedRepos)); + } + + const syncKeys = ['mutedRepos', 'snoozedRepos', 'filters', 'notifications', 'checkInterval', 'theme', 'itemExpiryHours']; const syncUpdates = {}; syncKeys.forEach(key => { @@ -402,4 +407,4 @@ export const markAsRead = (activityIds) => stateManager.markAsRead(activityIds); export const addWatchedRepo = (repo) => stateManager.addWatchedRepo(repo); export const removeWatchedRepo = (repo) => stateManager.removeWatchedRepo(repo); export const getFilteredActivities = () => stateManager.getFilteredActivities(); -export const getStats = () => stateManager.getStats(); \ No newline at end of file +export const getStats = () => stateManager.getStats(); diff --git a/shared/storage-helpers.js b/shared/storage-helpers.js index 35013a2..ada59b5 100644 --- a/shared/storage-helpers.js +++ b/shared/storage-helpers.js @@ -3,6 +3,10 @@ */ import { encryptData, decryptData } from './crypto-utils.js'; +const AUTH_SESSION_CACHE_KEY = 'githubAuthSession'; +const AUTH_SESSION_STORAGE_KEY = 'encryptedGithubAuthSession'; +const WATCHED_REPOS_STORAGE_KEY = 'watchedRepos'; + /** * Check if running in Chrome extension context * @returns {boolean} True if Chrome APIs are available @@ -145,6 +149,50 @@ export function setLocalItem(key, value) { }); } +function clearLegacySyncWatchedRepos() { + if (!isChromeExtension()) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + chrome.storage.sync.remove([WATCHED_REPOS_STORAGE_KEY], () => { + resolve(); + }); + }); +} + +/** + * Get watched repositories from local storage, with a sync-storage fallback for legacy installs. + * @returns {Promise} Watched repository records + */ +export async function getWatchedRepos() { + const localRepos = await getLocalItem(WATCHED_REPOS_STORAGE_KEY, null); + if (Array.isArray(localRepos)) { + return localRepos; + } + + const legacyRepos = await getSyncItem(WATCHED_REPOS_STORAGE_KEY, STORAGE_DEFAULTS.watchedRepos); + + if (Array.isArray(legacyRepos) && legacyRepos.length > 0) { + await setLocalItem(WATCHED_REPOS_STORAGE_KEY, legacyRepos); + await clearLegacySyncWatchedRepos(); + return legacyRepos; + } + + return STORAGE_DEFAULTS.watchedRepos; +} + +/** + * Persist watched repositories in local storage so larger repo lists do not hit sync item quotas. + * @param {Array} watchedRepos - Repository records to store + * @returns {Promise} + */ +export async function setWatchedRepos(watchedRepos = []) { + const normalizedRepos = Array.isArray(watchedRepos) ? watchedRepos : []; + await setLocalItem(WATCHED_REPOS_STORAGE_KEY, normalizedRepos); + await clearLegacySyncWatchedRepos(); +} + /** * Calculate the set of excluded repositories (muted + snoozed) * @param {Array} mutedRepos - Array of muted repository names @@ -164,71 +212,93 @@ export function getExcludedRepos(mutedRepos = [], snoozedRepos = []) { } /** - * Get GitHub token + * Get the stored GitHub auth session * Tries session storage first (decrypted cache), then local storage (encrypted) - * @returns {Promise} Token or null + * @returns {Promise} Auth session or null */ -export async function getToken() { - // 1. Try session storage first (fast path, decrypted) +export async function getAuthSession() { if (isChromeExtension() && chrome.storage.session) { - const session = await new Promise(resolve => { - chrome.storage.session.get(['githubToken'], result => resolve(result.githubToken)); + const cachedSession = await new Promise(resolve => { + chrome.storage.session.get([AUTH_SESSION_CACHE_KEY], result => resolve(result[AUTH_SESSION_CACHE_KEY])); }); - if (session) return session; + + if (cachedSession && typeof cachedSession === 'object') { + return cachedSession; + } } - // 2. Try local storage (encrypted) - const encrypted = await getLocalItem('encryptedGithubToken'); + const encrypted = await getLocalItem(AUTH_SESSION_STORAGE_KEY); if (!encrypted) { return null; } - // 3. Decrypt and cache in session storage - const token = await decryptData(encrypted); - if (token && isChromeExtension() && chrome.storage.session) { - await new Promise(resolve => { - chrome.storage.session.set({ githubToken: token }, resolve); - }); - } + try { + const decrypted = await decryptData(encrypted); + const session = typeof decrypted === 'string' ? JSON.parse(decrypted) : decrypted; + + if (!session || typeof session !== 'object') { + return null; + } + + if (isChromeExtension() && chrome.storage.session) { + await new Promise(resolve => { + chrome.storage.session.set({ [AUTH_SESSION_CACHE_KEY]: session }, resolve); + }); + } - return token; + return session; + } catch (_error) { + return null; + } } /** - * Set GitHub token - * Encrypts before storing in local storage, caches decrypted in session storage - * @param {string} token - GitHub token to store + * Persist a GitHub auth session + * @param {Object|null} session - Auth session to store * @returns {Promise} */ -export async function setToken(token) { - if (!token) { - await clearToken(); +export async function setAuthSession(session) { + if (!session || typeof session !== 'object' || !session.accessToken) { + await clearAuthSession(); return; } - // 1. Cache in session storage (decrypted) if (isChromeExtension() && chrome.storage.session) { await new Promise(resolve => { - chrome.storage.session.set({ githubToken: token }, resolve); + chrome.storage.session.set({ [AUTH_SESSION_CACHE_KEY]: session }, resolve); }); } - // 2. Encrypt and store in local storage - const encrypted = await encryptData(token); - await setLocalItem('encryptedGithubToken', encrypted); + const encrypted = await encryptData(JSON.stringify(session)); + await setLocalItem(AUTH_SESSION_STORAGE_KEY, encrypted); } /** - * Clear GitHub token from all storage + * Clear the stored GitHub auth session * @returns {Promise} */ -export async function clearToken() { +export async function clearAuthSession() { if (isChromeExtension() && chrome.storage.session) { await new Promise(resolve => { - chrome.storage.session.remove(['githubToken'], resolve); + chrome.storage.session.remove([AUTH_SESSION_CACHE_KEY], resolve); }); } - await setLocalItem('encryptedGithubToken', null); + + await setLocalItem(AUTH_SESSION_STORAGE_KEY, null); +} + +/** + * Get the access token used for GitHub API requests + * Prefers the OAuth auth session when present. + * @returns {Promise} Access token or null + */ +export async function getAccessToken() { + const authSession = await getAuthSession(); + if (authSession?.accessToken) { + return authSession.accessToken; + } + + return null; } // Storage configuration objects for batch operations @@ -292,10 +362,11 @@ export const STORAGE_DEFAULTS = { */ export async function getSettings() { const result = await getSyncItems(STORAGE_KEYS.SETTINGS); + const watchedRepos = await getWatchedRepos(); // Apply defaults for missing properties return { - watchedRepos: result.watchedRepos || STORAGE_DEFAULTS.watchedRepos, + watchedRepos, lastCheck: result.lastCheck || STORAGE_DEFAULTS.lastCheck, filters: { ...STORAGE_DEFAULTS.filters, ...result.filters }, notifications: { ...STORAGE_DEFAULTS.notifications, ...result.notifications }, @@ -356,7 +427,7 @@ export async function getActivityData() { * @returns {Promise} */ export async function updateSettings(updates) { - await setSyncItem('watchedRepos', updates.watchedRepos); + await setWatchedRepos(updates.watchedRepos); await setSyncItem('lastCheck', updates.lastCheck); await setSyncItem('filters', updates.filters); await setSyncItem('notifications', updates.notifications); diff --git a/tests/auth.test.js b/tests/auth.test.js new file mode 100644 index 0000000..8a7aa1e --- /dev/null +++ b/tests/auth.test.js @@ -0,0 +1,145 @@ +import { jest } from '@jest/globals'; + +const mockCreateTab = jest.fn(); +const mockLocalGet = jest.fn((keys, callback) => callback({ githubOAuthClientId: 'Iv1.test-client-id' })); +const mockSyncGet = jest.fn((keys, callback) => callback({})); + +global.chrome = { + storage: { + local: { + get: mockLocalGet + }, + sync: { + get: mockSyncGet + } + }, + tabs: { + create: mockCreateTab + } +}; + +describe('GitHub OAuth helpers', () => { + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = jest.fn(); + mockLocalGet.mockImplementation((keys, callback) => callback({ githubOAuthClientId: 'Iv1.test-client-id' })); + mockSyncGet.mockImplementation((keys, callback) => callback({})); + }); + + it('requests a GitHub device code', async () => { + const { requestGitHubDeviceCode } = await import('../shared/auth.js'); + + global.fetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ + device_code: 'device-code', + user_code: 'ABCD-EFGH', + verification_uri: 'https://github.com/login/device', + verification_uri_complete: 'https://github.com/login/device?user_code=ABCD-EFGH', + expires_in: 900, + interval: 5 + })) + }); + + const result = await requestGitHubDeviceCode(); + + expect(result).toEqual({ + deviceCode: 'device-code', + userCode: 'ABCD-EFGH', + verificationUri: 'https://github.com/login/device', + verificationUriComplete: 'https://github.com/login/device?user_code=ABCD-EFGH', + expiresIn: 900, + interval: 5 + }); + expect(global.fetch).toHaveBeenCalledWith( + 'https://github.com/login/device/code', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Accept': 'application/json' + }), + body: expect.stringContaining('client_id=Iv1.test-client-id') + }) + ); + }); + + it('fails early when no OAuth client ID is configured', async () => { + const { requestGitHubDeviceCode } = await import('../shared/auth.js'); + mockLocalGet.mockImplementation((keys, callback) => callback({})); + + await expect(requestGitHubDeviceCode()).rejects.toMatchObject({ + code: 'client_id_missing' + }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('opens the GitHub verification page in a new tab', async () => { + const { openGitHubDevicePage } = await import('../shared/auth.js'); + + openGitHubDevicePage({ + verificationUri: 'https://github.com/login/device', + verificationUriComplete: 'https://github.com/login/device?user_code=ABCD-EFGH' + }); + + expect(mockCreateTab).toHaveBeenCalledWith({ + url: 'https://github.com/login/device?user_code=ABCD-EFGH' + }); + }); + + it('polls until the user approves sign-in', async () => { + const { pollForGitHubAccessToken } = await import('../shared/auth.js'); + + global.fetch + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ + error: 'authorization_pending' + })) + }) + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ + access_token: 'oauth-token', + token_type: 'bearer', + scope: 'repo read:user' + })) + }); + + const result = await pollForGitHubAccessToken({ + deviceCode: 'device-code', + expiresIn: 900, + interval: 0 + }); + + expect(result).toEqual({ + accessToken: 'oauth-token', + tokenType: 'bearer', + scopes: ['repo', 'read:user'], + refreshToken: null, + refreshTokenExpiresIn: null, + expiresIn: null + }); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it('fetches the authenticated GitHub user with bearer auth', async () => { + const { fetchGitHubUser } = await import('../shared/auth.js'); + + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ login: 'octocat', id: 1 }) + }); + + const result = await fetchGitHubUser('oauth-token'); + + expect(result).toEqual({ login: 'octocat', id: 1 }); + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.github.com/user', + expect.objectContaining({ + headers: expect.objectContaining({ + 'Authorization': 'Bearer oauth-token' + }) + }) + ); + }); +}); diff --git a/tests/background.test.js b/tests/background.test.js index d8410a0..c6b5532 100644 --- a/tests/background.test.js +++ b/tests/background.test.js @@ -956,10 +956,10 @@ describe('Background Service Worker', () => { chrome.storage.session.get.mockImplementation((keys, callback) => { const result = {}; - if (Array.isArray(keys) && keys.includes('githubToken')) { - result.githubToken = mockToken; - } else if (keys === 'githubToken') { - result.githubToken = mockToken; + if (Array.isArray(keys) && keys.includes('githubAuthSession')) { + result.githubAuthSession = { accessToken: mockToken }; + } else if (keys === 'githubAuthSession') { + result.githubAuthSession = { accessToken: mockToken }; } callback(result); }); @@ -968,12 +968,12 @@ describe('Background Service Worker', () => { const result = {}; if (Array.isArray(keys)) { keys.forEach(key => { - if (key === 'githubToken') result[key] = mockToken; + if (key === 'encryptedGithubAuthSession') result[key] = null; else if (key === 'activities') result[key] = []; else if (key === 'rateLimit') result[key] = null; }); - } else if (keys === 'githubToken') { - result.githubToken = mockToken; + } else if (keys === 'encryptedGithubAuthSession') { + result.encryptedGithubAuthSession = null; } callback(result); }); @@ -993,10 +993,10 @@ describe('Background Service Worker', () => { allowUnexpectedConsole('warn'); chrome.storage.session.get.mockImplementation((keys, callback) => { const result = {}; - if (typeof keys === 'string' && keys === 'githubToken') { - result.githubToken = null; - } else if (Array.isArray(keys) && keys.includes('githubToken')) { - result.githubToken = null; + if (typeof keys === 'string' && keys === 'githubAuthSession') { + result.githubAuthSession = null; + } else if (Array.isArray(keys) && keys.includes('githubAuthSession')) { + result.githubAuthSession = null; } callback(result); }); diff --git a/tests/error-handler.test.js b/tests/error-handler.test.js index e735578..72cdab2 100644 --- a/tests/error-handler.test.js +++ b/tests/error-handler.test.js @@ -72,9 +72,9 @@ describe('Error Handler', () => { const error = getUserFriendlyError(new Error('Invalid token')); expect(error.type).toBe('auth'); - expect(error.title).toBe('Token Expired'); - expect(error.message).toContain('expired or is invalid'); - expect(error.action).toBe('Go to Settings'); + expect(error.title).toBe('GitHub Sign-In Needed'); + expect(error.message).toContain('expired or was revoked'); + expect(error.action).toBe('Open Settings'); }); it('should return user-friendly network error', () => { diff --git a/tests/export-import-controller.test.js b/tests/export-import-controller.test.js index 4c3985f..d85cdfd 100644 --- a/tests/export-import-controller.test.js +++ b/tests/export-import-controller.test.js @@ -13,6 +13,12 @@ jest.unstable_mockModule('../shared/ui/notification-manager.js', () => ({ } })); +jest.unstable_mockModule('../shared/storage-helpers.js', () => ({ + getWatchedRepos: jest.fn(() => Promise.resolve([])), + setWatchedRepos: jest.fn(() => Promise.resolve()) +})); + +const { getWatchedRepos, setWatchedRepos } = await import('../shared/storage-helpers.js'); const { exportSettings, handleImportFile } = await import('../options/controllers/export-import-controller.js'); describe('export-import-controller', () => { @@ -21,6 +27,8 @@ describe('export-import-controller', () => { mockNotifications.success.mockClear(); mockNotifications.error.mockClear(); mockNotifications.info.mockClear(); + getWatchedRepos.mockClear(); + setWatchedRepos.mockClear(); // Mock chrome.storage global.chrome = { @@ -62,7 +70,6 @@ describe('export-import-controller', () => { test('creates blob with correct content structure', async () => { const mockData = { - watchedRepos: ['owner/repo1', 'owner/repo2'], mutedRepos: ['owner/muted'], pinnedRepos: ['owner/pinned'], filters: { prs: true, issues: false, releases: true }, @@ -73,6 +80,7 @@ describe('export-import-controller', () => { snoozedRepos: ['owner/snoozed'] }; + getWatchedRepos.mockResolvedValueOnce(['owner/repo1', 'owner/repo2']); chrome.storage.sync.get.mockResolvedValueOnce(mockData); await exportSettings(); @@ -89,10 +97,8 @@ describe('export-import-controller', () => { }); test('uses default values for missing settings', async () => { - chrome.storage.sync.get.mockResolvedValueOnce({ - watchedRepos: ['owner/repo1'] - // Other settings missing - }); + chrome.storage.sync.get.mockResolvedValueOnce({}); + getWatchedRepos.mockResolvedValueOnce(['owner/repo1']); await exportSettings(); @@ -179,9 +185,8 @@ describe('export-import-controller', () => { const circular = { a: 1 }; circular.self = circular; - chrome.storage.sync.get.mockResolvedValueOnce({ - watchedRepos: [circular] - }); + chrome.storage.sync.get.mockResolvedValueOnce({}); + getWatchedRepos.mockResolvedValueOnce([circular]); await exportSettings(); @@ -215,6 +220,7 @@ describe('export-import-controller', () => { expect(mockFile.text).not.toHaveBeenCalled(); expect(chrome.storage.sync.set).not.toHaveBeenCalled(); + expect(setWatchedRepos).not.toHaveBeenCalled(); }); test('imports valid settings file', async () => { @@ -234,9 +240,9 @@ describe('export-import-controller', () => { await handleImportFile(mockEvent); + expect(setWatchedRepos).toHaveBeenCalledWith(['owner/repo1']); expect(chrome.storage.sync.set).toHaveBeenCalledWith( expect.objectContaining({ - watchedRepos: ['owner/repo1'], mutedRepos: ['owner/muted'], theme: 'dark', checkInterval: 30 @@ -274,6 +280,7 @@ describe('export-import-controller', () => { await handleImportFile(mockEvent); expect(chrome.storage.sync.set).not.toHaveBeenCalled(); + expect(setWatchedRepos).not.toHaveBeenCalled(); expect(mockNotifications.info).toHaveBeenCalledWith('Import cancelled'); expect(mockEvent.target.value).toBe(''); }); @@ -290,6 +297,7 @@ describe('export-import-controller', () => { await handleImportFile(mockEvent); + expect(setWatchedRepos).toHaveBeenCalledWith(['owner/repo1']); const setCall = chrome.storage.sync.set.mock.calls[0][0]; expect(setCall.mutedRepos).toEqual([]); expect(setCall.pinnedRepos).toEqual([]); @@ -329,6 +337,7 @@ describe('export-import-controller', () => { await handleImportFile(mockEvent, loadSettingsCallback); + expect(setWatchedRepos).toHaveBeenCalledWith([]); expect(loadSettingsCallback).toHaveBeenCalled(); }); @@ -423,6 +432,7 @@ describe('export-import-controller', () => { await handleImportFile(mockEvent); + expect(setWatchedRepos).toHaveBeenCalledWith([]); expect(mockEvent.target.value).toBe(''); }); @@ -471,8 +481,8 @@ describe('export-import-controller', () => { await handleImportFile(mockEvent); + expect(setWatchedRepos).toHaveBeenCalledWith(['owner/repo1', 'owner/repo2']); expect(chrome.storage.sync.set).toHaveBeenCalledWith({ - watchedRepos: ['owner/repo1', 'owner/repo2'], mutedRepos: ['owner/muted1'], pinnedRepos: ['owner/pinned1'], filters: { prs: false, issues: true, releases: false }, @@ -496,6 +506,7 @@ describe('export-import-controller', () => { checkInterval: 30 }; + getWatchedRepos.mockResolvedValueOnce(originalSettings.watchedRepos); chrome.storage.sync.get.mockResolvedValueOnce(originalSettings); await exportSettings(); @@ -522,7 +533,7 @@ describe('export-import-controller', () => { // Verify the imported settings match the original const importedSettings = chrome.storage.sync.set.mock.calls[0][0]; - expect(importedSettings.watchedRepos).toEqual(originalSettings.watchedRepos); + expect(setWatchedRepos).toHaveBeenCalledWith(originalSettings.watchedRepos); expect(importedSettings.mutedRepos).toEqual(originalSettings.mutedRepos); expect(importedSettings.theme).toBe(originalSettings.theme); expect(importedSettings.checkInterval).toBe(originalSettings.checkInterval); diff --git a/tests/github-api.test.js b/tests/github-api.test.js index d0c3527..1a2ed81 100644 --- a/tests/github-api.test.js +++ b/tests/github-api.test.js @@ -16,7 +16,7 @@ describe('GitHub API Helpers', () => { const headers = createHeaders(token); expect(headers).toEqual({ - 'Authorization': 'token ghp_test1234567890', + 'Authorization': 'Bearer ghp_test1234567890', 'Accept': 'application/vnd.github.v3+json' }); }); @@ -25,7 +25,7 @@ describe('GitHub API Helpers', () => { const headers = createHeaders(''); expect(headers).toEqual({ - 'Authorization': 'token ', + 'Authorization': 'Bearer ', 'Accept': 'application/vnd.github.v3+json' }); }); @@ -34,7 +34,7 @@ describe('GitHub API Helpers', () => { const token = 'github_pat_123ABC'; const headers = createHeaders(token); - expect(headers.Authorization).toBe('token github_pat_123ABC'); + expect(headers.Authorization).toBe('Bearer github_pat_123ABC'); }); }); @@ -56,7 +56,7 @@ describe('GitHub API Helpers', () => { statusText: 'Unauthorized' }; - expect(() => handleApiResponse(mockResponse, 'owner/repo')).toThrow('Invalid GitHub token'); + expect(() => handleApiResponse(mockResponse, 'owner/repo')).toThrow('GitHub sign-in expired or was revoked'); }); it('should throw error for 403 rate limit', () => { diff --git a/tests/import-controller.test.js b/tests/import-controller.test.js index ea18312..788f931 100644 --- a/tests/import-controller.test.js +++ b/tests/import-controller.test.js @@ -2,11 +2,13 @@ import { jest, describe, test, beforeEach, expect } from '@jest/globals'; // Mock dependencies jest.unstable_mockModule('../shared/storage-helpers.js', () => ({ - getToken: jest.fn(() => Promise.resolve('fake-token')) + getAccessToken: jest.fn(() => Promise.resolve('fake-token')), + getSyncItem: jest.fn(() => Promise.resolve(false)), + setWatchedRepos: jest.fn(() => Promise.resolve()) })); jest.unstable_mockModule('../shared/github-api.js', () => ({ - createHeaders: jest.fn((token) => ({ 'Authorization': `token ${token}` })) + createHeaders: jest.fn((token) => ({ 'Authorization': `Bearer ${token}` })) })); jest.unstable_mockModule('../shared/sanitize.js', () => ({ @@ -23,7 +25,7 @@ jest.unstable_mockModule('../shared/icons.js', () => ({ createSvg: jest.fn(() => '') })); -const { getToken } = await import('../shared/storage-helpers.js'); +const { getAccessToken, getSyncItem, setWatchedRepos } = await import('../shared/storage-helpers.js'); const { openImportModal, closeImportModal, @@ -88,6 +90,11 @@ describe('import-controller', () => { selectedCount.id = 'selectedCount'; modal.appendChild(selectedCount); + const selectAllCheckbox = document.createElement('input'); + selectAllCheckbox.id = 'selectAllImport'; + selectAllCheckbox.type = 'checkbox'; + modal.appendChild(selectAllCheckbox); + confirmBtn = document.createElement('button'); confirmBtn.id = 'confirmImportBtn'; confirmBtn.disabled = true; @@ -99,7 +106,7 @@ describe('import-controller', () => { document.body.appendChild(modal); - // Mock chrome.storage + // Mock chrome.storage for browser-only access paths global.chrome = { storage: { sync: { @@ -113,8 +120,8 @@ describe('import-controller', () => { }); describe('openImportModal', () => { - test('does not open modal if no token', async () => { - getToken.mockResolvedValueOnce(null); + test('does not open modal if no GitHub connection is available', async () => { + getAccessToken.mockResolvedValueOnce(null); await openImportModal('starred', []); @@ -241,7 +248,7 @@ describe('import-controller', () => { await openImportModal('starred', []); expect(errorState.style.display).toBe('block'); - expect(errorMessage.textContent).toContain('Invalid GitHub token'); + expect(errorMessage.textContent).toContain('GitHub sign-in expired or was revoked'); }); test('handles API error with 403 status', async () => { @@ -494,7 +501,7 @@ describe('import-controller', () => { expect(onReposAdded).toHaveBeenCalled(); }); - test('saves to chrome storage', async () => { + test('saves to sync storage helper', async () => { reposContainer.innerHTML = `
  • Repo 1
  • `; @@ -503,11 +510,11 @@ describe('import-controller', () => { await importSelectedRepos(watchedRepos); - expect(chrome.storage.sync.set).toHaveBeenCalledWith({ - watchedRepos: expect.arrayContaining([ + expect(setWatchedRepos).toHaveBeenCalledWith( + expect.arrayContaining([ expect.objectContaining({ fullName: 'owner/repo1' }) ]) - }); + ); }); test('does nothing when no repos selected', async () => { @@ -520,7 +527,7 @@ describe('import-controller', () => { await importSelectedRepos(watchedRepos); expect(watchedRepos.length).toBe(0); - expect(chrome.storage.sync.set).not.toHaveBeenCalled(); + expect(setWatchedRepos).not.toHaveBeenCalled(); }); test('excludes already-added repos from import', async () => { @@ -547,6 +554,32 @@ describe('import-controller', () => { expect(modal.classList.contains('show')).toBe(false); }); + + test('does not mutate watched repos when the storage write fails', async () => { + reposContainer.innerHTML = ` +
  • Repo 1
  • + `; + setWatchedRepos.mockRejectedValueOnce(new Error('Storage quota exceeded. Please clear old data.')); + + const watchedRepos = []; + + await expect(importSelectedRepos(watchedRepos)).rejects.toThrow('Storage quota exceeded'); + expect(watchedRepos).toEqual([]); + }); + + test('rejects imports that exceed the repository limit when unlimited is off', async () => { + reposContainer.innerHTML = ` +
  • Repo 51
  • + `; + getSyncItem.mockResolvedValueOnce(false); + + const watchedRepos = Array.from({ length: 50 }, (_, index) => ({ + fullName: `owner/repo${index + 1}` + })); + + await expect(importSelectedRepos(watchedRepos)).rejects.toThrow('Import would exceed the 50 repository limit'); + expect(setWatchedRepos).not.toHaveBeenCalled(); + }); }); describe('keyboard navigation and accessibility', () => { diff --git a/tests/onboarding.test.js b/tests/onboarding.test.js index b44dbf8..0e425aa 100644 --- a/tests/onboarding.test.js +++ b/tests/onboarding.test.js @@ -52,6 +52,14 @@ global.chrome = { _localStorage = { ..._localStorage, ...items }; if (callback) callback(); return Promise.resolve(); + }), + remove: jest.fn((keys, callback) => { + const keyArray = Array.isArray(keys) ? keys : [keys]; + keyArray.forEach((key) => { + delete _localStorage[key]; + }); + if (callback) callback(); + return Promise.resolve(); }) } }, @@ -68,6 +76,7 @@ let _renderOnboardingStep; async function renderTokenStep(stateOverrides = {}) { _localStorage = { + githubOAuthClientId: 'Iv1.test-client-id', onboarding_state: { currentStep: 1, completed: false, @@ -83,18 +92,20 @@ async function renderTokenStep(stateOverrides = {}) { `; await _renderOnboardingStep(); + await Promise.resolve(); } describe('Onboarding - token persistence', () => { beforeEach(async () => { // Reset storage and mocks _localStorage = { + githubOAuthClientId: 'Iv1.test-client-id', onboarding_state: { currentStep: 1, // token step completed: false, skippedSteps: [], data: { - token: { token: 'ghp_OLD', validated: true, username: 'alice' } + token: { validated: true, username: 'alice', authType: 'oauth_device' } } } }; @@ -121,6 +132,12 @@ describe('Onboarding - token persistence', () => { configurable: true, value: TextEncoder }); + Object.defineProperty(globalThis.navigator, 'clipboard', { + configurable: true, + value: { + writeText: jest.fn(async () => undefined) + } + }); // Reload modules to reset module-level onboardingManager cache jest.resetModules(); const module = await import('../popup/views/onboarding-view.js'); @@ -129,11 +146,12 @@ describe('Onboarding - token persistence', () => { _renderOnboardingStep = module.renderOnboardingStep; }); - test('preserves validated flag when saving token and navigating next', async () => { + test('preserves validated auth state when navigating past the connect step', async () => { // Setup minimal DOM expected by handleNextStep document.body.innerHTML = ` - +
    +
    `; @@ -144,24 +162,20 @@ describe('Onboarding - token persistence', () => { expect(dataBefore.validated).toBe(true); expect(dataBefore.username).toBe('alice'); - // Call handleNextStep which should preserve existing validated info await _handleNextStep(); - // Read what's saved const result = await new Promise((resolve) => { chrome.storage.local.get(['onboarding_state'], (res) => resolve(res.onboarding_state)); }); - // The token object should have been merged with the existing validated info expect(result).toBeTruthy(); expect(result.data).toBeTruthy(); - expect(result.data.token.token).toBe('ghp_NEW'); - // Preserved expect(result.data.token.validated).toBe(true); expect(result.data.token.username).toBe('alice'); + expect(document.getElementById('tokenStatus').textContent).toBe(''); }); - test('getPopularRepos uses stored token in request', async () => { + test('getPopularRepos uses stored auth session in request', async () => { const mockFetch = jest.fn().mockResolvedValue({ ok: true, status: 200, @@ -173,30 +187,30 @@ describe('Onboarding - token persistence', () => { global.fetch = mockFetch; - // Set a token in local storage - _localStorage.githubToken = 'ghp_TEST_TOKEN'; + // Set an auth session in storage + _localStorage.githubAuthSession = { + accessToken: 'gho_TEST_TOKEN', + authType: 'oauth_device' + }; const manager = new OnboardingManager(); - // Clear any step token so that the function picks up chrome.storage.local token + // Clear any onboarding step state so the function uses the active auth session await manager.saveStepData('token', {}); const result = await manager.getPopularRepos(); // Ensure fetch was called with Authorization header expect(mockFetch).toHaveBeenCalled(); const [, options] = mockFetch.mock.calls[0]; - expect(options.headers['Authorization']).toBe('token ghp_TEST_TOKEN'); + expect(options.headers['Authorization']).toBe('Bearer gho_TEST_TOKEN'); // Should return at least 1 repo from our mocked response expect(result.length).toBeGreaterThan(0); expect(result[0].name).toBe('repo'); }); - test('getPopularRepos uses onboarding step token when storage missing', async () => { + test('getPopularRepos falls back to unauthenticated headers when no auth session exists', async () => { const manager = new OnboardingManager(); - // Save token inside onboarding step data, not in chrome.local - await manager.saveStepData('token', { token: 'ghp_STEP_TOKEN' }); - const mockFetch = jest.fn().mockResolvedValue({ ok: true, status: 200, @@ -212,7 +226,8 @@ describe('Onboarding - token persistence', () => { expect(mockFetch).toHaveBeenCalled(); const [, options] = mockFetch.mock.calls[0]; - expect(options.headers['Authorization']).toBe('token ghp_STEP_TOKEN'); + expect(options.headers['Authorization']).toBeUndefined(); + expect(options.headers['Accept']).toBe('application/vnd.github.v3+json'); expect(result.length).toBeGreaterThan(0); }); @@ -259,11 +274,11 @@ describe('Onboarding - token persistence', () => { expect(html).not.toContain(''); }); - test('renderOnboardingStep escapes saved token values and usernames on the token step', async () => { + test('renderOnboardingStep escapes saved device codes and usernames on the connect step', async () => { await renderTokenStep({ data: { token: { - token: 'ghp_test" autofocus="true', + userCode: 'ABCD"" autofocus="true', validated: true, username: '' } @@ -274,18 +289,17 @@ describe('Onboarding - token persistence', () => { const tokenInput = document.getElementById('tokenInput'); const tokenStatus = document.getElementById('tokenStatus'); - expect(tokenInput.value).toBe('ghp_test" autofocus="true'); + expect(tokenInput.value).toBe('ABCD"" autofocus="true'); expect(tokenInput.outerHTML).toContain('"'); - expect(tokenStatus.textContent).toContain('Logged in as '); + expect(tokenStatus.textContent).toContain('Connected as '); expect(onboardingHtml).toContain('<img src=x onerror=alert(1)>'); expect(onboardingHtml).not.toContain(''); }); - test('renderOnboardingStep shows validated status without username safely', async () => { + test('renderOnboardingStep shows connected status without username safely', async () => { await renderTokenStep({ data: { token: { - token: 'ghp_token', validated: true } } @@ -294,50 +308,123 @@ describe('Onboarding - token persistence', () => { const onboardingHtml = document.getElementById('onboardingView').innerHTML; const tokenStatus = document.getElementById('tokenStatus'); - expect(tokenStatus.textContent).toContain('✓ Token is valid!'); - expect(onboardingHtml).toContain('Validated'); + expect(tokenStatus.textContent).toContain('GitHub is connected'); + expect(onboardingHtml).toContain('Connected'); }); - test('token step shows an error when validation is attempted with no token', async () => { - await renderTokenStep(); + test('connect step lets you copy the saved device code', async () => { + await renderTokenStep({ + data: { + token: { + userCode: 'ABCD-EFGH', + validated: false + } + } + }); - document.getElementById('validateTokenBtn').click(); + document.getElementById('copyTokenCodeBtn').click(); + await Promise.resolve(); + await Promise.resolve(); - expect(document.getElementById('tokenStatus').textContent).toBe('Please enter a token'); - expect(global.fetch).not.toHaveBeenCalled(); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('ABCD-EFGH'); + expect(document.getElementById('tokenStatus').textContent).toContain('Copied ABCD-EFGH'); }); - test('token step escapes invalid-token responses', async () => { - global.fetch = jest.fn().mockResolvedValue({ - ok: false, - status: 401 + test('connect step shows device instructions when sign-in starts', async () => { + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ + device_code: 'device-code', + user_code: 'ABCD-EFGH', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 0 + }) + }).mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ + error: 'authorization_pending' + }) }); await renderTokenStep(); - const tokenInput = document.getElementById('tokenInput'); - tokenInput.value = 'ghp_invalid'; document.getElementById('validateTokenBtn').click(); await Promise.resolve(); + await Promise.resolve(); + await new Promise(resolve => setTimeout(resolve, 0)); - expect(document.getElementById('tokenStatus').textContent).toBe('✗ Invalid token'); + expect(document.getElementById('tokenInput').value).toBe('ABCD-EFGH'); + expect(document.getElementById('tokenStatus').textContent).toContain('ABCD-EFGH'); + expect(chrome.tabs.create).toHaveBeenCalledWith({ url: 'https://github.com/login/device' }); }); - test('token step escapes network validation errors', async () => { + test('connect step handles cancelled sign-in', async () => { + global.fetch = jest.fn() + .mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ + device_code: 'device-code', + user_code: 'ABCD-EFGH', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 0 + }) + }) + .mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ + error: 'access_denied' + }) + }); + + await renderTokenStep(); + document.getElementById('validateTokenBtn').click(); + await Promise.resolve(); + await Promise.resolve(); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(document.getElementById('tokenStatus').textContent).toBe('GitHub sign-in was cancelled'); + }); + + test('connect step handles device flow 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(); + await new Promise(resolve => setTimeout(resolve, 0)); - expect(document.getElementById('tokenStatus').textContent).toBe('Error validating token'); + expect(document.getElementById('tokenStatus').textContent).toBe('GitHub sign-in failed'); }); - test('token step escapes successful validation messages', async () => { + test('connect step escapes successful sign-in messages', async () => { global.fetch = jest.fn(async (url) => { + if (url === 'https://github.com/login/device/code') { + return { + ok: true, + text: async () => JSON.stringify({ + device_code: 'device-code', + user_code: 'ABCD-EFGH', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 0 + }) + }; + } + + if (url === 'https://github.com/login/oauth/access_token') { + return { + ok: true, + text: async () => JSON.stringify({ + access_token: 'oauth-token', + token_type: 'bearer', + scope: 'repo read:user' + }) + }; + } + if (url === 'https://api.github.com/user') { return { ok: true, @@ -345,22 +432,19 @@ describe('Onboarding - token persistence', () => { }; } - return { - ok: true, - json: async () => ({ items: [] }) - }; + 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(); + await Promise.resolve(); + await new Promise(resolve => setTimeout(resolve, 0)); const tokenStatus = document.getElementById('tokenStatus'); - expect(tokenStatus.textContent).toContain('Logged in as '); + expect(tokenStatus.textContent).toContain('Connected as '); expect(tokenStatus.innerHTML).toContain('<img src=x onerror=alert(1)>'); }); diff --git a/tests/options-main.test.js b/tests/options-main.test.js index 086fe33..a07af8c 100644 --- a/tests/options-main.test.js +++ b/tests/options-main.test.js @@ -16,9 +16,12 @@ describe('Options Main Functions', () => { beforeEach(() => { // Setup complete DOM structure for options page document.body.innerHTML = ` - + +
    - +
    @@ -85,7 +88,25 @@ describe('Options Main Functions', () => { storage: { local: { get: jest.fn((keys, callback) => { - callback({ activities: [], readItems: [] }); + const requestedKeys = Array.isArray(keys) ? keys : [keys]; + const result = {}; + + requestedKeys.forEach((key) => { + if (key === 'activities') { + result.activities = []; + } + if (key === 'readItems') { + result.readItems = []; + } + if (key === 'githubOAuthClientId') { + result.githubOAuthClientId = 'Iv1.test-client-id'; + } + if (key === 'encryptedGithubAuthSession') { + result.encryptedGithubAuthSession = null; + } + }); + + callback(result); }), set: jest.fn((data, callback) => { if (callback) callback(); @@ -126,6 +147,9 @@ describe('Options Main Functions', () => { }, runtime: { sendMessage: jest.fn(() => Promise.resolve()) + }, + tabs: { + create: jest.fn() } }; @@ -295,8 +319,9 @@ describe('Options Main Functions', () => { expect(shouldClearStoredToken({ isValid: true, user: 'testuser' })).toBe(false); }); - test('restores authenticated UI when a stored token still exists', () => { + test('restores authenticated UI when a stored session exists', () => { const clearBtn = document.getElementById('clearTokenBtn'); + const connectBtn = document.getElementById('connectGitHubBtn'); const repoInput = document.getElementById('repoInput'); const addBtn = document.getElementById('addRepoBtn'); const helpText = document.getElementById('repoHelpText'); @@ -305,12 +330,13 @@ describe('Options Main Functions', () => { clearBtn.style.display = 'none'; repoInput.disabled = true; addBtn.disabled = true; - helpText.textContent = 'Invalid token. Please check your GitHub token and try again.'; + helpText.textContent = 'GitHub sign-in expired or was revoked. Reconnect GitHub and try again.'; importSection.classList.add('hidden'); importSection.style.display = 'none'; syncTokenUiWithStoredCredential(true); + expect(connectBtn.textContent).toBe('Reconnect GitHub'); expect(clearBtn.style.display).toBe('block'); expect(repoInput.disabled).toBe(false); expect(addBtn.disabled).toBe(false); @@ -321,6 +347,7 @@ describe('Options Main Functions', () => { test('restores unauthenticated UI when no stored token is available', () => { const clearBtn = document.getElementById('clearTokenBtn'); + const connectBtn = document.getElementById('connectGitHubBtn'); const repoInput = document.getElementById('repoInput'); const addBtn = document.getElementById('addRepoBtn'); const helpText = document.getElementById('repoHelpText'); @@ -328,47 +355,23 @@ describe('Options Main Functions', () => { syncTokenUiWithStoredCredential(false); + expect(connectBtn.textContent).toBe('Connect GitHub'); expect(clearBtn.style.display).toBe('none'); expect(repoInput.disabled).toBe(true); expect(addBtn.disabled).toBe(true); - expect(helpText.textContent).toContain('Add a valid GitHub token above'); + expect(helpText.textContent).toContain('Connect GitHub above'); expect(importSection.classList.contains('hidden')).toBe(true); expect(importSection.style.display).toBe('none'); }); - test('loadSettings preserves stored token on transient validation failures', async () => { - chrome.storage.session.get.mockImplementation((keys, callback) => { - callback({ githubToken: 'persisted-token' }); - }); - chrome.storage.sync.get.mockImplementation((keys, callback) => { - const result = Array.isArray(keys) && keys.includes('snoozedRepos') - ? { snoozedRepos: [] } - : {}; - if (callback) { - callback(result); - return; - } - return Promise.resolve(result); - }); - global.fetch.mockResolvedValue({ - ok: false, - status: 500 - }); - - await loadSettings(); - - expect(document.getElementById('githubToken').value).toBe('persisted-token'); - expect(document.getElementById('clearTokenBtn').style.display).toBe('none'); - expect(document.getElementById('repoInput').disabled).toBe(true); - expect(chrome.storage.local.set).not.toHaveBeenCalledWith( - expect.objectContaining({ encryptedGithubToken: null }), - expect.any(Function) - ); - }); - - test('loadSettings clears stored token when validation reports invalid credentials', async () => { + test('loadSettings restores a stored auth session', async () => { chrome.storage.session.get.mockImplementation((keys, callback) => { - callback({ githubToken: 'expired-token' }); + callback({ + githubAuthSession: { + accessToken: 'persisted-token', + username: 'persisted-user' + } + }); }); chrome.storage.sync.get.mockImplementation((keys, callback) => { const result = Array.isArray(keys) && keys.includes('snoozedRepos') @@ -380,138 +383,76 @@ describe('Options Main Functions', () => { } return Promise.resolve(result); }); - global.fetch.mockResolvedValue({ - ok: false, - status: 401 - }); await loadSettings(); - expect(chrome.storage.local.set).toHaveBeenCalledWith( - expect.objectContaining({ encryptedGithubToken: null }), - expect.any(Function) - ); + expect(document.getElementById('tokenStatus').textContent).toContain('persisted-user'); + expect(document.getElementById('connectGitHubBtn').textContent).toBe('Reconnect GitHub'); + expect(document.getElementById('clearTokenBtn').style.display).toBe('block'); + expect(document.getElementById('repoInput').disabled).toBe(false); }); - test('setupEventListeners clears persisted token after the clear action succeeds', async () => { + test('setupEventListeners clears persisted auth after the disconnect action succeeds', async () => { setupEventListeners(); document.getElementById('clearTokenBtn').click(); await Promise.resolve(); - expect(chrome.storage.session.remove).toHaveBeenCalledWith(['githubToken'], expect.any(Function)); + expect(chrome.storage.session.remove).toHaveBeenCalledWith(['githubAuthSession'], expect.any(Function)); expect(chrome.storage.local.set).toHaveBeenCalledWith( - expect.objectContaining({ encryptedGithubToken: null }), + expect.objectContaining({ encryptedGithubAuthSession: null }), expect.any(Function) ); }); - test('empty token input restores stored-token UI state', async () => { - chrome.storage.session.get.mockImplementation((keys, callback) => { - callback({ githubToken: 'persisted-token' }); - }); - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ login: 'persisted-user' }) - }); - - await loadSettings(); - setupEventListeners(); - - const tokenInput = document.getElementById('githubToken'); - tokenInput.value = ''; - tokenInput.dispatchEvent(new Event('input', { bubbles: true })); - - expect(document.getElementById('clearTokenBtn').style.display).toBe('block'); - expect(document.getElementById('repoInput').disabled).toBe(false); - }); - - test('token input persists newly validated replacement tokens', async () => { - jest.useFakeTimers(); + test('connect button starts the device flow and stores the session', async () => { setupEventListeners(); - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ login: 'new-user' }) - }); - - const tokenInput = document.getElementById('githubToken'); - tokenInput.value = 'new-token'; - tokenInput.dispatchEvent(new Event('input', { bubbles: true })); - await jest.advanceTimersByTimeAsync(500); - await Promise.resolve(); - - expect(chrome.storage.session.set).toHaveBeenCalledWith( - { githubToken: 'new-token' }, - expect.any(Function) - ); - expect(chrome.storage.local.set).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ encryptedGithubToken: expect.any(Object) }), - expect.any(Function) - ); - }); - - test('token input clears persisted credentials after confirmed invalid replacement', async () => { - jest.useFakeTimers(); - chrome.storage.session.get.mockImplementation((keys, callback) => { - callback({ githubToken: 'persisted-token' }); - }); global.fetch .mockResolvedValueOnce({ ok: true, - json: async () => ({ login: 'persisted-user' }) + text: async () => JSON.stringify({ + device_code: 'device-code', + user_code: 'ABCD-EFGH', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 0 + }) }) - .mockResolvedValueOnce({ - ok: false, - status: 401 - }); - - await loadSettings(); - setupEventListeners(); - - const tokenInput = document.getElementById('githubToken'); - tokenInput.value = 'expired-token'; - tokenInput.dispatchEvent(new Event('input', { bubbles: true })); - - await jest.advanceTimersByTimeAsync(500); - await Promise.resolve(); - - expect(chrome.storage.session.remove).toHaveBeenCalledWith(['githubToken'], expect.any(Function)); - expect(chrome.storage.local.set).toHaveBeenCalledWith( - expect.objectContaining({ encryptedGithubToken: null }), - expect.any(Function) - ); - }); - - test('token input keeps repository controls enabled after transient replacement failure', async () => { - jest.useFakeTimers(); - chrome.storage.session.get.mockImplementation((keys, callback) => { - callback({ githubToken: 'persisted-token' }); - }); - global.fetch .mockResolvedValueOnce({ ok: true, - json: async () => ({ login: 'persisted-user' }) + text: async () => JSON.stringify({ + access_token: 'oauth-token', + token_type: 'bearer', + scope: 'repo read:user' + }) }) .mockResolvedValueOnce({ - ok: false, - status: 500 + ok: true, + json: async () => ({ login: 'new-user' }) }); - await loadSettings(); - setupEventListeners(); - - const tokenInput = document.getElementById('githubToken'); - tokenInput.value = 'replacement-token'; - tokenInput.dispatchEvent(new Event('input', { bubbles: true })); - - await jest.advanceTimersByTimeAsync(500); + document.getElementById('connectGitHubBtn').click(); + await Promise.resolve(); await Promise.resolve(); + await Promise.resolve(); + await new Promise(resolve => setTimeout(resolve, 0)); expect(document.getElementById('clearTokenBtn').style.display).toBe('block'); expect(document.getElementById('repoInput').disabled).toBe(false); expect(document.getElementById('addRepoBtn').disabled).toBe(false); + expect(chrome.storage.session.set).toHaveBeenCalledWith( + expect.objectContaining({ + githubAuthSession: expect.objectContaining({ + accessToken: 'oauth-token', + username: 'new-user' + }) + }), + expect.any(Function) + ); + expect(chrome.tabs.create).toHaveBeenCalledWith({ + url: 'https://github.com/login/device' + }); }); }); diff --git a/tests/options-token-controller.test.js b/tests/options-token-controller.test.js index ada5f3e..a64db0a 100644 --- a/tests/options-token-controller.test.js +++ b/tests/options-token-controller.test.js @@ -1,257 +1,136 @@ -import { jest, describe, test, beforeEach, expect } from '@jest/globals'; +import { jest } from '@jest/globals'; -const { clearToken, validateToken } = await import('../options/controllers/token-controller.js'); +const mockCompleteGitHubDeviceAuth = jest.fn(); +const mockClearAuthSession = jest.fn(() => Promise.resolve()); +const mockGetAuthSession = jest.fn(() => Promise.resolve(null)); +const mockSetAuthSession = jest.fn(() => Promise.resolve()); + +jest.unstable_mockModule('../shared/auth.js', () => ({ + completeGitHubDeviceAuth: mockCompleteGitHubDeviceAuth +})); + +jest.unstable_mockModule('../shared/storage-helpers.js', () => ({ + clearAuthSession: mockClearAuthSession, + getAuthSession: mockGetAuthSession, + setAuthSession: mockSetAuthSession +})); + +const { + applyStoredConnection, + clearToken, + connectGitHub +} = await import('../options/controllers/token-controller.js'); describe('Token Controller', () => { beforeEach(() => { document.body.innerHTML = ` - + + +
    -
    -
    + `; - // Chrome mocks are provided by setup.js + jest.clearAllMocks(); global.confirm = jest.fn(() => true); - global.fetch = jest.fn(); - }); - - test('clearToken does nothing when cancelled', async () => { - global.confirm.mockReturnValue(false); - - await clearToken(); - - expect(document.getElementById('githubToken').value).toBe('test-token'); }); - test('validateToken handles valid token', async () => { - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ login: 'testuser' }) - }); - - const toastManager = {}; - const result = await validateToken('test-token', toastManager); - - const statusEl = document.getElementById('tokenStatus'); - expect(statusEl.textContent).toContain('testuser'); - expect(result).toEqual({ isValid: true, user: 'testuser' }); - }); - - test('validateToken skips UI updates for stale responses', async () => { - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ login: 'stale-user' }) - }); - - document.getElementById('tokenStatus').textContent = 'Checking...'; - document.getElementById('tokenStatus').className = 'token-status checking'; - document.getElementById('clearTokenBtn').style.display = 'none'; - - const toastManager = { isManualTokenEntry: true }; - const result = await validateToken('stale-token', toastManager, { - shouldApplyResult: () => false - }); + test('applyStoredConnection restores signed-out state', () => { + applyStoredConnection(null); - expect(result).toEqual({ isValid: true, user: 'stale-user' }); - expect(document.getElementById('tokenStatus').textContent).toBe('Checking...'); - expect(document.getElementById('tokenStatus').className).toBe('token-status checking'); + expect(document.getElementById('connectGitHubBtn').textContent).toBe('Connect GitHub'); expect(document.getElementById('clearTokenBtn').style.display).toBe('none'); - expect(toastManager.lastValidToken).toBeUndefined(); + expect(document.getElementById('repoInput').disabled).toBe(true); + expect(document.getElementById('addRepoBtn').disabled).toBe(true); + expect(document.getElementById('repoHelpText').textContent).toContain('Connect GitHub above'); }); - test('validateToken handles invalid token', async () => { - global.fetch.mockResolvedValue({ - ok: false, - status: 401 - }); - - const toastManager = {}; - const result = await validateToken('bad-token', toastManager); - - const statusEl = document.getElementById('tokenStatus'); - expect(statusEl.textContent).toContain('Invalid'); - expect(result).toEqual({ isValid: false, reason: 'invalid' }); - }); - - test('validateToken skips stale invalid-token UI updates', async () => { - global.fetch.mockResolvedValue({ - ok: false, - status: 401 - }); - - document.getElementById('tokenStatus').textContent = 'Checking...'; - document.getElementById('tokenStatus').className = 'token-status checking'; - document.getElementById('clearTokenBtn').style.display = 'block'; - - const toastManager = {}; - const result = await validateToken('stale-bad-token', toastManager, { - shouldApplyResult: () => false + test('applyStoredConnection restores connected state', () => { + applyStoredConnection({ + accessToken: 'oauth-token', + username: 'octocat' }); - expect(result).toEqual({ isValid: false, reason: 'invalid' }); - expect(document.getElementById('tokenStatus').textContent).toBe('Checking...'); - expect(document.getElementById('tokenStatus').className).toBe('token-status checking'); + expect(document.getElementById('connectGitHubBtn').textContent).toBe('Reconnect GitHub'); expect(document.getElementById('clearTokenBtn').style.display).toBe('block'); - expect(toastManager.lastInvalidToken).toBeUndefined(); + expect(document.getElementById('tokenStatus').textContent).toContain('octocat'); + expect(document.getElementById('repoInput').disabled).toBe(false); + expect(document.getElementById('importReposSection').style.display).toBe('block'); }); - test('clearToken clears all fields when confirmed', async () => { - global.confirm.mockReturnValue(true); - const tokenInput = document.getElementById('githubToken'); - const statusEl = document.getElementById('tokenStatus'); - const clearBtn = document.getElementById('clearTokenBtn'); - const repoInput = document.getElementById('repoInput'); - const addBtn = document.getElementById('addRepoBtn'); - const importSection = document.getElementById('importReposSection'); - - await clearToken(); - - expect(tokenInput.value).toBe(''); - expect(statusEl.textContent).toBe(''); - expect(clearBtn.style.display).toBe('none'); - expect(repoInput.disabled).toBe(true); - expect(addBtn.disabled).toBe(true); - expect(importSection.style.display).toBe('none'); - }); - - test('validateToken handles other HTTP errors', async () => { - global.fetch.mockResolvedValue({ - ok: false, - status: 500 - }); - - const toastManager = {}; - const result = await validateToken('token', toastManager); - - const statusEl = document.getElementById('tokenStatus'); - expect(statusEl.textContent).toContain('Error (500)'); - expect(statusEl.className).toContain('invalid'); - expect(result).toEqual({ isValid: false, reason: 'http', status: 500 }); - }); - - test('validateToken skips stale API error UI updates', async () => { - global.fetch.mockResolvedValue({ - ok: false, - status: 500 - }); - - document.getElementById('tokenStatus').textContent = 'Checking...'; - document.getElementById('tokenStatus').className = 'token-status checking'; - document.getElementById('clearTokenBtn').style.display = 'block'; - - const toastManager = {}; - const result = await validateToken('stale-token', toastManager, { - shouldApplyResult: () => false - }); - - expect(result).toEqual({ isValid: false, reason: 'http', status: 500 }); - expect(document.getElementById('tokenStatus').textContent).toBe('Checking...'); - expect(document.getElementById('tokenStatus').className).toBe('token-status checking'); - expect(document.getElementById('clearTokenBtn').style.display).toBe('block'); - expect(toastManager.lastApiError).toBeUndefined(); - }); - - test('validateToken handles network errors', async () => { - global.fetch.mockRejectedValue(new Error('Network error')); + test('clearToken does nothing when cancelled', async () => { + global.confirm.mockReturnValue(false); - const toastManager = {}; - const result = await validateToken('token', toastManager); + const result = await clearToken(); - const statusEl = document.getElementById('tokenStatus'); - expect(statusEl.textContent).toContain('Network error'); - expect(statusEl.className).toContain('invalid'); - expect(result).toEqual({ isValid: false, reason: 'network' }); + expect(result).toBe(false); + expect(mockClearAuthSession).not.toHaveBeenCalled(); }); - test('validateToken skips stale network-error UI updates', async () => { - global.fetch.mockRejectedValue(new Error('Network error')); - - document.getElementById('tokenStatus').textContent = 'Checking...'; - document.getElementById('tokenStatus').className = 'token-status checking'; - document.getElementById('clearTokenBtn').style.display = 'block'; - - const toastManager = {}; - const result = await validateToken('stale-token', toastManager, { - shouldApplyResult: () => false + test('clearToken clears auth state when confirmed', async () => { + applyStoredConnection({ + accessToken: 'oauth-token', + username: 'octocat' }); - expect(result).toEqual({ isValid: false, reason: 'network' }); - expect(document.getElementById('tokenStatus').textContent).toBe('Checking...'); - expect(document.getElementById('tokenStatus').className).toBe('token-status checking'); - expect(document.getElementById('clearTokenBtn').style.display).toBe('block'); - }); - - test('validateToken shows success toast only on first validation', async () => { - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ login: 'testuser' }) - }); + const result = await clearToken(); - const toastManager = { isManualTokenEntry: true }; - - await validateToken('new-token', toastManager); - expect(toastManager.lastValidToken).toBe('new-token'); - - // Second validation with same token shouldn't show toast - await validateToken('new-token', toastManager); - }); - - test('validateToken shows error toast only once per token', async () => { - global.fetch.mockResolvedValue({ - ok: false, - status: 401 + expect(result).toBe(true); + expect(mockClearAuthSession).toHaveBeenCalled(); + expect(document.getElementById('clearTokenBtn').style.display).toBe('none'); + expect(document.getElementById('repoInput').disabled).toBe(true); + }); + + test('connectGitHub stores auth session after a successful device flow', async () => { + mockCompleteGitHubDeviceAuth.mockImplementation(async ({ onCode }) => { + await onCode({ userCode: 'ABCD-EFGH' }); + return { + user: { login: 'octocat' }, + authSession: { + accessToken: 'oauth-token', + username: 'octocat' + } + }; }); - const toastManager = {}; - - await validateToken('bad-token', toastManager); - expect(toastManager.lastInvalidToken).toBe('bad-token'); - }); + const result = await connectGitHub({}); - test('validateToken shows API error toast only once per status', async () => { - global.fetch.mockResolvedValue({ - ok: false, - status: 500 + expect(result).toEqual({ + isValid: true, + user: 'octocat', + authSession: { + accessToken: 'oauth-token', + username: 'octocat' + } }); - - const toastManager = {}; - - await validateToken('token', toastManager); - expect(toastManager.lastApiError).toBe(500); - }); - - test('validateToken enables import section on valid token', async () => { - global.fetch.mockResolvedValue({ - ok: true, - json: async () => ({ login: 'testuser' }) + expect(mockSetAuthSession).toHaveBeenCalledWith({ + accessToken: 'oauth-token', + username: 'octocat' }); - - const toastManager = {}; - const importSection = document.getElementById('importReposSection'); - importSection.classList.add('hidden'); - - await validateToken('token', toastManager); - - expect(importSection.classList.contains('hidden')).toBe(false); - expect(importSection.style.display).toBe('block'); + expect(document.getElementById('connectGitHubBtn').textContent).toBe('Reconnect GitHub'); + expect(document.getElementById('tokenStatus').textContent).toContain('octocat'); + expect(document.getElementById('githubToken').value).toBe(''); }); - test('validateToken disables import section on invalid token', async () => { - global.fetch.mockResolvedValue({ - ok: false, - status: 401 + test('connectGitHub keeps the existing session enabled when reconnect fails', async () => { + mockGetAuthSession.mockResolvedValueOnce({ + accessToken: 'existing-token', + username: 'existing-user' }); + mockCompleteGitHubDeviceAuth.mockRejectedValueOnce(Object.assign(new Error('cancelled'), { + code: 'access_denied' + })); - const toastManager = {}; - const importSection = document.getElementById('importReposSection'); + const result = await connectGitHub({}); - await validateToken('token', toastManager); - - expect(importSection.classList.contains('hidden')).toBe(true); - expect(importSection.style.display).toBe('none'); + expect(result).toEqual({ isValid: false, reason: 'access_denied' }); + expect(document.getElementById('clearTokenBtn').style.display).toBe('block'); + expect(document.getElementById('repoInput').disabled).toBe(false); + expect(document.getElementById('tokenStatus').textContent).toContain('cancelled'); }); }); diff --git a/tests/options.test.js b/tests/options.test.js index 9c327d2..d286728 100644 --- a/tests/options.test.js +++ b/tests/options.test.js @@ -6,7 +6,7 @@ global.chrome = { sync: { get: jest.fn((keys, callback) => { // Always call callback if provided - if (callback) callback({ githubToken: null }); + if (callback) callback({}); }), set: jest.fn((items, callback) => { // Always call callback if provided @@ -180,12 +180,11 @@ describe('Options Page - Repository Management', () => { describe('validateRepo', () => { beforeEach(() => { - // Mock token storage for validateRepo tests - // Mock session storage to return token (getToken checks session first) + // Mock auth session storage for validateRepo tests chrome.storage.session.get.mockImplementation((keys, callback) => { const result = {}; - if (keys.includes('githubToken')) { - result.githubToken = 'test-token'; + if (Array.isArray(keys) && keys.includes('githubAuthSession')) { + result.githubAuthSession = { accessToken: 'test-token' }; } if (callback) callback(result); return Promise.resolve(result); @@ -299,7 +298,7 @@ describe('Options Page - Repository Management', () => { const result = await validateRepo('some/repo'); expect(result.valid).toBe(false); - expect(result.error).toContain('Authentication failed'); + expect(result.error).toContain('GitHub sign-in expired or was revoked'); }); test('provides default values for missing metadata', async () => { diff --git a/tests/phase1.test.js b/tests/phase1.test.js index 2d7fbb9..a3f477c 100644 --- a/tests/phase1.test.js +++ b/tests/phase1.test.js @@ -168,7 +168,7 @@ describe('Error Display', () => { test('shows recent errors', () => { allowUnexpectedConsole('error'); const error = { - message: 'Invalid GitHub token', + message: 'GitHub sign-in expired or was revoked', repo: 'facebook/react', timestamp: Date.now() }; @@ -177,8 +177,8 @@ describe('Error Display', () => { const errorMsg = document.getElementById('errorMessage'); expect(errorMsg.style.display).toBe('block'); - expect(errorMsg.textContent).toContain('Token Expired'); - expect(errorMsg.textContent).toContain('invalid'); + expect(errorMsg.textContent).toContain('GitHub Sign-In Needed'); + expect(errorMsg.textContent).toContain('revoked'); }); test('displays all errors when shown', () => { diff --git a/tests/repository-validator.test.js b/tests/repository-validator.test.js index 4141e44..2eda289 100644 --- a/tests/repository-validator.test.js +++ b/tests/repository-validator.test.js @@ -139,7 +139,7 @@ describe('repository-validator', () => { const result = await validateRepository('test/repo'); expect(result.valid).toBe(false); - expect(result.error).toContain('Authentication failed'); + expect(result.error).toContain('GitHub sign-in expired or was revoked'); }); test('handles unknown API errors', async () => { diff --git a/tests/state-manager.test.js b/tests/state-manager.test.js index d87f8d5..735fa34 100644 --- a/tests/state-manager.test.js +++ b/tests/state-manager.test.js @@ -21,6 +21,8 @@ global.chrome = { jest.unstable_mockModule('../shared/storage-helpers.js', () => ({ getSyncItems: jest.fn(async () => ({})), getLocalItems: jest.fn(async () => ({})), + getWatchedRepos: jest.fn(async () => []), + setWatchedRepos: jest.fn(async () => {}), STORAGE_KEYS: { SETTINGS: ['watchedRepos', 'mutedRepos', 'snoozedRepos', 'filters', 'notifications', 'checkInterval', 'theme', 'itemExpiryHours'], ACTIVITY: ['activities', 'readItems'] diff --git a/tests/storage-helpers.test.js b/tests/storage-helpers.test.js index 1dd9074..3daba37 100644 --- a/tests/storage-helpers.test.js +++ b/tests/storage-helpers.test.js @@ -22,14 +22,16 @@ const { setSyncItem, setLocalItem, getExcludedRepos, - getToken, - setToken, - clearToken + getAuthSession, + setAuthSession, + clearAuthSession, + getAccessToken } = await import('../shared/storage-helpers.js'); describe('Storage Helpers', () => { let mockSyncStorage; let mockLocalStorage; + let mockSessionStorage; beforeEach(() => { // Reset mocks @@ -38,6 +40,7 @@ describe('Storage Helpers', () => { // Mock storage with in-memory objects mockSyncStorage = {}; mockLocalStorage = {}; + mockSessionStorage = {}; // Mock chrome.storage.sync global.chrome = { @@ -91,6 +94,29 @@ describe('Storage Helpers', () => { resolve(); }); }) + }, + session: { + get: jest.fn((keys, callback) => { + const result = {}; + const keyArray = Array.isArray(keys) ? keys : [keys]; + keyArray.forEach(key => { + if (Object.prototype.hasOwnProperty.call(mockSessionStorage, key)) { + result[key] = mockSessionStorage[key]; + } + }); + callback(result); + }), + set: jest.fn((items, callback) => { + Object.assign(mockSessionStorage, items); + if (callback) callback(); + }), + remove: jest.fn((keys, callback) => { + const keyArray = Array.isArray(keys) ? keys : [keys]; + keyArray.forEach(key => { + delete mockSessionStorage[key]; + }); + if (callback) callback(); + }) } } }; @@ -361,58 +387,83 @@ describe('Storage Helpers', () => { }); }); - describe('getToken', () => { - it('should return token from local storage if it exists', async () => { - // Mock encrypted token in local storage - mockLocalStorage.encryptedGithubToken = { iv: [], data: [] }; - // Mock decryptData to return a specific token - mockDecryptData.mockResolvedValueOnce('decrypted-token'); + describe('auth session helpers', () => { + it('returns auth session from encrypted local storage', async () => { + mockLocalStorage.encryptedGithubAuthSession = { iv: [], data: [] }; + mockDecryptData.mockResolvedValueOnce(JSON.stringify({ + accessToken: 'oauth-token', + username: 'octocat' + })); - const result = await getToken(); + const result = await getAuthSession(); - expect(result).toBe('decrypted-token'); - expect(mockDecryptData).toHaveBeenCalled(); + expect(result).toEqual({ + accessToken: 'oauth-token', + username: 'octocat' + }); + expect(mockSessionStorage.githubAuthSession).toEqual({ + accessToken: 'oauth-token', + username: 'octocat' + }); }); - it('should return null if no token exists', async () => { - const result = await getToken(); + it('returns auth session from session cache when available', async () => { + mockSessionStorage.githubAuthSession = { + accessToken: 'cached-token', + username: 'cached-user' + }; - expect(result).toBeNull(); + const result = await getAuthSession(); + + expect(result).toEqual({ + accessToken: 'cached-token', + username: 'cached-user' + }); + expect(mockDecryptData).not.toHaveBeenCalled(); }); - }); - describe('setToken', () => { - it('should set token in local storage', async () => { - await setToken('new-token-789'); + it('stores auth session in session and encrypted local storage', async () => { + await setAuthSession({ + accessToken: 'oauth-token', + username: 'octocat' + }); - // Should store encrypted data - expect(mockLocalStorage.encryptedGithubToken).toBeDefined(); - expect(mockEncryptData).toHaveBeenCalledWith('new-token-789'); + expect(mockSessionStorage.githubAuthSession).toEqual({ + accessToken: 'oauth-token', + username: 'octocat' + }); + expect(mockEncryptData).toHaveBeenCalledWith(JSON.stringify({ + accessToken: 'oauth-token', + username: 'octocat' + })); + expect(mockLocalStorage.encryptedGithubAuthSession).toBeDefined(); }); - it('should overwrite existing local token', async () => { - mockLocalStorage.encryptedGithubToken = { iv: [], data: [] }; + it('clears auth session from all storage', async () => { + mockSessionStorage.githubAuthSession = { accessToken: 'oauth-token' }; + mockLocalStorage.encryptedGithubAuthSession = { iv: [], data: [] }; - await setToken('new-token-789'); + await clearAuthSession(); - expect(mockEncryptData).toHaveBeenCalledWith('new-token-789'); - expect(mockLocalStorage.encryptedGithubToken).toBeDefined(); + expect(mockSessionStorage.githubAuthSession).toBeUndefined(); + expect(mockLocalStorage.encryptedGithubAuthSession).toBeNull(); }); - }); - describe('clearToken', () => { - it('should clear token from local storage', async () => { - mockLocalStorage.encryptedGithubToken = { iv: [], data: [] }; + it('returns the access token from the current auth session', async () => { + mockSessionStorage.githubAuthSession = { + accessToken: 'oauth-token', + username: 'octocat' + }; - await clearToken(); + const result = await getAccessToken(); - expect(mockLocalStorage.encryptedGithubToken).toBeNull(); + expect(result).toBe('oauth-token'); }); - it('should work even if no token exists', async () => { - await clearToken(); + it('returns null when no auth session is stored', async () => { + const result = await getAccessToken(); - expect(mockLocalStorage.encryptedGithubToken).toBeNull(); + expect(result).toBeNull(); }); }); });