From 80879827ffe4f44b7f537db7836176643a713b25 Mon Sep 17 00:00:00 2001 From: jonmartin721 Date: Sun, 8 Mar 2026 19:53:42 -0500 Subject: [PATCH 1/3] Tighten token validation handling --- options/controllers/token-controller.js | 23 +++++++++++++- options/options.html | 2 +- options/options.js | 40 ++++++++++++++++++++----- popup/views/onboarding-view.js | 2 +- tests/options-token-controller.test.js | 39 ++++++++++++++++++++---- 5 files changed, 91 insertions(+), 15 deletions(-) diff --git a/options/controllers/token-controller.js b/options/controllers/token-controller.js index f604a4a..658833a 100644 --- a/options/controllers/token-controller.js +++ b/options/controllers/token-controller.js @@ -27,8 +27,9 @@ export async function clearToken() { notifications.info('GitHub token cleared successfully'); } -export async function validateToken(token, toastManager) { +export async function validateToken(token, toastManager, options = {}) { const statusEl = document.getElementById('tokenStatus'); + const shouldApplyResult = options.shouldApplyResult ?? (() => true); try { const response = await fetch('https://api.github.com/user', { @@ -37,6 +38,10 @@ export async function validateToken(token, toastManager) { if (response.ok) { const user = await response.json(); + if (!shouldApplyResult()) { + return { isValid: true, user: user.login }; + } + statusEl.textContent = `✓ Valid (${user.login})`; statusEl.className = 'token-status valid'; document.getElementById('clearTokenBtn').style.display = 'block'; @@ -59,7 +64,12 @@ export async function validateToken(token, toastManager) { } toastManager.isManualTokenEntry = false; + return { isValid: true, user: user.login }; } else if (response.status === 401) { + if (!shouldApplyResult()) { + return { isValid: false, reason: 'invalid' }; + } + statusEl.textContent = '✗ Invalid token'; statusEl.className = 'token-status invalid'; document.getElementById('clearTokenBtn').style.display = 'none'; @@ -78,7 +88,12 @@ export async function validateToken(token, toastManager) { 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 }; + } + statusEl.textContent = `✗ Error (${response.status})`; statusEl.className = 'token-status invalid'; document.getElementById('clearTokenBtn').style.display = 'none'; @@ -97,8 +112,13 @@ export async function validateToken(token, toastManager) { notifications.error(`GitHub API error (${response.status}). Please try again later.`); toastManager.lastApiError = response.status; } + 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'; @@ -112,5 +132,6 @@ export async function validateToken(token, toastManager) { document.getElementById('importReposSection').style.display = 'none'; notifications.error('Network error while validating token. Please check your connection and try again.'); + return { isValid: false, reason: 'network' }; } } diff --git a/options/options.html b/options/options.html index 5f42b28..82de4fd 100644 --- a/options/options.html +++ b/options/options.html @@ -79,7 +79,7 @@

Create a GitHub Token

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

diff --git a/options/options.js b/options/options.js index 89e7112..77b6624 100644 --- a/options/options.js +++ b/options/options.js @@ -1,5 +1,5 @@ import { applyTheme, formatDateVerbose } from '../shared/utils.js'; -import { getToken, setToken, getLocalItems, setLocalItem } from '../shared/storage-helpers.js'; +import { getToken, setToken, clearToken as clearStoredToken, getLocalItems, setLocalItem } from '../shared/storage-helpers.js'; import { createHeaders } from '../shared/github-api.js'; import { STORAGE_CONFIG, VALIDATION_PATTERNS } from '../shared/config.js'; import { validateRepository } from '../shared/repository-validator.js'; @@ -200,19 +200,29 @@ function setupEventListeners() { } }); - // Validate and auto-save token on input + // Validate and persist token only after the current input has been confirmed valid. let tokenValidationTimeout; - document.getElementById('githubToken').addEventListener('input', (e) => { + let tokenValidationRequestId = 0; + document.getElementById('githubToken').addEventListener('input', async (e) => { clearTimeout(tokenValidationTimeout); const token = e.target.value.trim(); + tokenValidationRequestId++; + const validationId = tokenValidationRequestId; if (!token) { + toastManager.lastValidatedToken = null; + await clearStoredToken(); document.getElementById('tokenStatus').textContent = ''; document.getElementById('tokenStatus').className = 'token-status'; document.getElementById('clearTokenBtn').style.display = 'none'; return; } + if (toastManager.lastValidatedToken && toastManager.lastValidatedToken !== token) { + toastManager.lastValidatedToken = null; + await clearStoredToken(); + } + document.getElementById('tokenStatus').textContent = 'Checking...'; document.getElementById('tokenStatus').className = 'token-status checking'; @@ -220,10 +230,21 @@ function setupEventListeners() { toastManager.isManualTokenEntry = true; tokenValidationTimeout = setTimeout(async () => { - await validateToken(token, toastManager); - // Auto-save token after validation - if (token) { + const validationResult = await validateToken(token, toastManager, { + shouldApplyResult: () => + validationId === tokenValidationRequestId && + document.getElementById('githubToken')?.value.trim() === token + }); + + if (validationId !== tokenValidationRequestId) { + return; + } + + if (validationResult.isValid) { await setToken(token); + toastManager.lastValidatedToken = token; + } else { + await clearStoredToken(); } }, 500); }); @@ -519,7 +540,12 @@ async function loadSettings() { document.getElementById('githubToken').value = githubToken; document.getElementById('clearTokenBtn').style.display = 'block'; // Validate existing token - validateToken(githubToken, toastManager); + const validationResult = await validateToken(githubToken, toastManager); + if (validationResult.isValid) { + toastManager.lastValidatedToken = githubToken; + } else { + await clearStoredToken(); + } } else { // No token - set appropriate placeholder and help text const repoInput = document.getElementById('repoInput'); diff --git a/popup/views/onboarding-view.js b/popup/views/onboarding-view.js index a0954ca..de190c6 100644 --- a/popup/views/onboarding-view.js +++ b/popup/views/onboarding-view.js @@ -189,7 +189,7 @@ async function renderTokenStep() {

Quick setup:

    -
  1. Create a GitHub token
  2. +
  3. Create a GitHub token
  4. Copy the generated token
  5. Paste it below
diff --git a/tests/options-token-controller.test.js b/tests/options-token-controller.test.js index 677fa61..615fa9a 100644 --- a/tests/options-token-controller.test.js +++ b/tests/options-token-controller.test.js @@ -17,6 +17,9 @@ describe('Token Controller', () => { // Chrome mocks are provided by setup.js global.confirm = jest.fn(() => true); global.fetch = jest.fn(); + chrome.storage.local.set.mockImplementation((items, callback) => { + if (callback) callback(); + }); }); test('clearToken does nothing when cancelled', async () => { @@ -34,10 +37,33 @@ describe('Token Controller', () => { }); const toastManager = {}; - await validateToken('test-token', 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 + }); + + 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('clearTokenBtn').style.display).toBe('none'); + expect(toastManager.lastValidToken).toBeUndefined(); }); test('validateToken handles invalid token', async () => { @@ -47,13 +73,14 @@ describe('Token Controller', () => { }); const toastManager = {}; - await validateToken('bad-token', 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.skip('clearToken clears all fields when confirmed', async () => { + test('clearToken clears all fields when confirmed', async () => { global.confirm.mockReturnValue(true); const tokenInput = document.getElementById('githubToken'); @@ -80,22 +107,24 @@ describe('Token Controller', () => { }); const toastManager = {}; - await validateToken('token', 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 handles network errors', async () => { global.fetch.mockRejectedValue(new Error('Network error')); const toastManager = {}; - await validateToken('token', toastManager); + const result = await validateToken('token', toastManager); const statusEl = document.getElementById('tokenStatus'); expect(statusEl.textContent).toContain('Network error'); expect(statusEl.className).toContain('invalid'); + expect(result).toEqual({ isValid: false, reason: 'network' }); }); test('validateToken shows success toast only on first validation', async () => { From ad03f05b210f3c70279d432ad009307f51aae5ab Mon Sep 17 00:00:00 2001 From: jonmartin721 Date: Sun, 8 Mar 2026 20:18:35 -0500 Subject: [PATCH 2/3] Preserve stored tokens during token edits --- options/controllers/token-controller.js | 3 +- options/options.js | 69 ++++++++++++++++--------- tests/options-main.test.js | 35 +++++++++++++ 3 files changed, 83 insertions(+), 24 deletions(-) diff --git a/options/controllers/token-controller.js b/options/controllers/token-controller.js index 658833a..3d3d6bf 100644 --- a/options/controllers/token-controller.js +++ b/options/controllers/token-controller.js @@ -6,7 +6,7 @@ const notifications = NotificationManager.getInstance(); export async function clearToken() { if (!confirm('Are you sure you want to clear your GitHub token?')) { - return; + return false; } document.getElementById('githubToken').value = ''; @@ -25,6 +25,7 @@ export async function clearToken() { await clearStoredToken(); notifications.info('GitHub token cleared successfully'); + return true; } export async function validateToken(token, toastManager, options = {}) { diff --git a/options/options.js b/options/options.js index 77b6624..a406073 100644 --- a/options/options.js +++ b/options/options.js @@ -29,6 +29,7 @@ const state = { searchQuery: '', hidePinnedRepos: false }; +let persistedToken = null; if (typeof document !== 'undefined') { document.addEventListener('DOMContentLoaded', async () => { @@ -121,12 +122,41 @@ function handleUrlParameters() { } +function syncTokenUiWithStoredCredential(hasStoredToken) { + const clearTokenBtn = document.getElementById('clearTokenBtn'); + const repoInput = document.getElementById('repoInput'); + const addRepoBtn = document.getElementById('addRepoBtn'); + const repoHelpText = document.getElementById('repoHelpText'); + const importSection = document.getElementById('importReposSection'); + + clearTokenBtn.style.display = hasStoredToken ? 'block' : 'none'; + repoInput.disabled = !hasStoredToken; + repoInput.placeholder = hasStoredToken + ? 'e.g., react, facebook/react, or GitHub URL' + : 'Enter a valid GitHub token to add repositories'; + addRepoBtn.disabled = !hasStoredToken; + repoHelpText.textContent = hasStoredToken + ? 'Add repositories to monitor (npm package, owner/repo, or GitHub URL)' + : 'Add a valid GitHub token above to start adding repositories'; + importSection.classList.toggle('hidden', !hasStoredToken); + importSection.style.display = hasStoredToken ? 'block' : 'none'; +} + +function shouldClearStoredToken(validationResult) { + return !validationResult.isValid && validationResult.reason === 'invalid'; +} + function setupEventListeners() { // Tab navigation setupTabNavigation(); document.getElementById('addRepoBtn').addEventListener('click', addRepo); - document.getElementById('clearTokenBtn').addEventListener('click', clearToken); + document.getElementById('clearTokenBtn').addEventListener('click', async () => { + const tokenCleared = await clearToken(); + if (tokenCleared) { + persistedToken = null; + } + }); // Action button toggles const hidePinnedToggleBtn = document.getElementById('hidePinnedToggleBtn2'); @@ -210,19 +240,12 @@ function setupEventListeners() { const validationId = tokenValidationRequestId; if (!token) { - toastManager.lastValidatedToken = null; - await clearStoredToken(); document.getElementById('tokenStatus').textContent = ''; document.getElementById('tokenStatus').className = 'token-status'; - document.getElementById('clearTokenBtn').style.display = 'none'; + syncTokenUiWithStoredCredential(Boolean(persistedToken)); return; } - if (toastManager.lastValidatedToken && toastManager.lastValidatedToken !== token) { - toastManager.lastValidatedToken = null; - await clearStoredToken(); - } - document.getElementById('tokenStatus').textContent = 'Checking...'; document.getElementById('tokenStatus').className = 'token-status checking'; @@ -242,9 +265,14 @@ function setupEventListeners() { if (validationResult.isValid) { await setToken(token); - toastManager.lastValidatedToken = token; - } else { + persistedToken = token; + } else if (shouldClearStoredToken(validationResult)) { await clearStoredToken(); + persistedToken = null; + } + + if (!validationResult.isValid && persistedToken) { + syncTokenUiWithStoredCredential(true); } }, 500); }); @@ -537,25 +565,17 @@ async function loadSettings() { applyTheme(theme); if (githubToken) { + persistedToken = githubToken; document.getElementById('githubToken').value = githubToken; document.getElementById('clearTokenBtn').style.display = 'block'; // Validate existing token const validationResult = await validateToken(githubToken, toastManager); - if (validationResult.isValid) { - toastManager.lastValidatedToken = githubToken; - } else { + if (shouldClearStoredToken(validationResult)) { await clearStoredToken(); + persistedToken = null; } } else { - // No token - set appropriate placeholder and help text - 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 importSection = document.getElementById('importReposSection'); - importSection.classList.add('hidden'); - importSection.style.display = 'none'; + syncTokenUiWithStoredCredential(false); } state.watchedRepos = settings.watchedRepos || []; @@ -1174,12 +1194,15 @@ setInterval(async () => { export { state, validateToken, + loadSettings, addRepo, validateRepo, removeRepo, cleanupRepoNotifications, getFilteredRepos, renderRepoList, + shouldClearStoredToken, + syncTokenUiWithStoredCredential, formatNumber, formatDateVerbose as formatDate, // Export verbose formatter for tests exportSettings, diff --git a/tests/options-main.test.js b/tests/options-main.test.js index 31efee7..bc36254 100644 --- a/tests/options-main.test.js +++ b/tests/options-main.test.js @@ -4,6 +4,8 @@ const { formatNumber, getFilteredRepos, cleanupRepoNotifications, + shouldClearStoredToken, + syncTokenUiWithStoredCredential, state } = await import('../options/options.js'); @@ -189,6 +191,39 @@ describe('Options Main Functions', () => { }); }); + describe('token persistence helpers', () => { + test('only clears stored tokens for invalid credentials', () => { + expect(shouldClearStoredToken({ isValid: false, reason: 'invalid' })).toBe(true); + expect(shouldClearStoredToken({ isValid: false, reason: 'network' })).toBe(false); + expect(shouldClearStoredToken({ isValid: false, reason: 'http', status: 500 })).toBe(false); + expect(shouldClearStoredToken({ isValid: true, user: 'testuser' })).toBe(false); + }); + + test('restores authenticated UI when a stored token still exists', () => { + const clearBtn = document.getElementById('clearTokenBtn'); + const repoInput = document.getElementById('repoInput'); + const addBtn = document.getElementById('addRepoBtn'); + const helpText = document.getElementById('repoHelpText'); + const importSection = document.getElementById('importReposSection'); + + clearBtn.style.display = 'none'; + repoInput.disabled = true; + addBtn.disabled = true; + helpText.textContent = 'Invalid token. Please check your GitHub token and try again.'; + importSection.classList.add('hidden'); + importSection.style.display = 'none'; + + syncTokenUiWithStoredCredential(true); + + expect(clearBtn.style.display).toBe('block'); + expect(repoInput.disabled).toBe(false); + expect(addBtn.disabled).toBe(false); + expect(helpText.textContent).toContain('Add repositories to monitor'); + expect(importSection.classList.contains('hidden')).toBe(false); + expect(importSection.style.display).toBe('block'); + }); + }); + describe('cleanupRepoNotifications', () => { test('removes activities for deleted repository', async () => { const activities = [ From 4dbc0cb58d20253ed19960d1adf07be7d29e7b90 Mon Sep 17 00:00:00 2001 From: jonmartin721 Date: Sun, 8 Mar 2026 20:29:13 -0500 Subject: [PATCH 3/3] Expand token coverage --- options/options.js | 1 + tests/options-main.test.js | 296 ++++++++++++++++++++++++- tests/options-token-controller.test.js | 62 ++++++ 3 files changed, 357 insertions(+), 2 deletions(-) diff --git a/options/options.js b/options/options.js index a406073..deece2b 100644 --- a/options/options.js +++ b/options/options.js @@ -1195,6 +1195,7 @@ export { state, validateToken, loadSettings, + setupEventListeners, addRepo, validateRepo, removeRepo, diff --git a/tests/options-main.test.js b/tests/options-main.test.js index bc36254..ca12e6b 100644 --- a/tests/options-main.test.js +++ b/tests/options-main.test.js @@ -1,9 +1,12 @@ -import { jest, describe, test, beforeEach, expect } from '@jest/globals'; +import { TextEncoder, TextDecoder } from 'node:util'; +import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals'; const { formatNumber, getFilteredRepos, cleanupRepoNotifications, + loadSettings, + setupEventListeners, shouldClearStoredToken, syncTokenUiWithStoredCredential, state @@ -23,9 +26,49 @@ describe('Options Main Functions', () => {
+
+
+ + +
+ + + + + + + + + + + + + + +
+ + +
+ + + + + + + + + + + + +
+
+ + + `; // Reset state for each test @@ -53,7 +96,25 @@ describe('Options Main Functions', () => { }, sync: { get: jest.fn((keys, callback) => { - callback({}); + const result = {}; + if (callback) { + callback(result); + return; + } + return Promise.resolve(result); + }), + set: jest.fn((data, callback) => { + if (callback) callback(); + return Promise.resolve(); + }), + remove: jest.fn((keys, callback) => { + if (callback) callback(); + return Promise.resolve(); + }) + }, + session: { + get: jest.fn((keys, callback) => { + if (callback) callback({}); }), set: jest.fn((data, callback) => { if (callback) callback(); @@ -69,6 +130,42 @@ describe('Options Main Functions', () => { }; global.fetch = jest.fn(); + global.confirm = jest.fn(() => true); + window.matchMedia = jest.fn().mockReturnValue({ + matches: false, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn() + }); + Object.defineProperty(global, 'crypto', { + configurable: true, + value: { + getRandomValues: jest.fn((array) => { + array.fill(1); + return array; + }), + subtle: { + generateKey: jest.fn(async () => ({ mockKey: true })), + importKey: jest.fn(async () => ({ mockKey: true })), + exportKey: jest.fn(async () => new Uint8Array([1, 2, 3, 4]).buffer), + encrypt: jest.fn(async () => new Uint8Array([9, 8, 7]).buffer), + decrypt: jest.fn(async () => new TextEncoder().encode('decrypted-token').buffer) + } + } + }); + Object.defineProperty(global, 'TextEncoder', { + configurable: true, + value: TextEncoder + }); + Object.defineProperty(global, 'TextDecoder', { + configurable: true, + value: TextDecoder + }); + }); + + afterEach(() => { + jest.useRealTimers(); }); describe('formatNumber', () => { @@ -222,6 +319,201 @@ describe('Options Main Functions', () => { expect(importSection.classList.contains('hidden')).toBe(false); expect(importSection.style.display).toBe('block'); }); + + test('restores unauthenticated UI when no stored token is available', () => { + const clearBtn = document.getElementById('clearTokenBtn'); + const repoInput = document.getElementById('repoInput'); + const addBtn = document.getElementById('addRepoBtn'); + const helpText = document.getElementById('repoHelpText'); + const importSection = document.getElementById('importReposSection'); + + syncTokenUiWithStoredCredential(false); + + 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(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 () => { + chrome.storage.session.get.mockImplementation((keys, callback) => { + callback({ githubToken: 'expired-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: 401 + }); + + await loadSettings(); + + expect(chrome.storage.local.set).toHaveBeenCalledWith( + expect.objectContaining({ encryptedGithubToken: null }), + expect.any(Function) + ); + }); + + test('setupEventListeners clears persisted token after the clear action succeeds', async () => { + setupEventListeners(); + + document.getElementById('clearTokenBtn').click(); + 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('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(); + 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' }) + }) + .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' }) + }) + .mockResolvedValueOnce({ + ok: false, + status: 500 + }); + + await loadSettings(); + setupEventListeners(); + + const tokenInput = document.getElementById('githubToken'); + tokenInput.value = 'replacement-token'; + tokenInput.dispatchEvent(new Event('input', { bubbles: true })); + + await jest.advanceTimersByTimeAsync(500); + await Promise.resolve(); + + expect(document.getElementById('clearTokenBtn').style.display).toBe('block'); + expect(document.getElementById('repoInput').disabled).toBe(false); + expect(document.getElementById('addRepoBtn').disabled).toBe(false); + }); }); describe('cleanupRepoNotifications', () => { diff --git a/tests/options-token-controller.test.js b/tests/options-token-controller.test.js index 615fa9a..78c6c61 100644 --- a/tests/options-token-controller.test.js +++ b/tests/options-token-controller.test.js @@ -80,6 +80,28 @@ describe('Token Controller', () => { 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 + }); + + 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('clearTokenBtn').style.display).toBe('block'); + expect(toastManager.lastInvalidToken).toBeUndefined(); + }); + test('clearToken clears all fields when confirmed', async () => { global.confirm.mockReturnValue(true); @@ -115,6 +137,28 @@ describe('Token Controller', () => { 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')); @@ -127,6 +171,24 @@ describe('Token Controller', () => { expect(result).toEqual({ isValid: false, reason: 'network' }); }); + 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 + }); + + 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,