diff --git a/entrypoints/content/index.ts b/entrypoints/content/index.ts index 7e82a788..2c98ea2b 100644 --- a/entrypoints/content/index.ts +++ b/entrypoints/content/index.ts @@ -6,6 +6,7 @@ import { } from "src/utils/inlineSiteSettings"; import { t } from "../../lib/i18n"; import { USER_GUIDE_URL } from "src/utils/externalLinks"; +import { loadInlineAccountHistory } from "src/services/inlineHistoryService"; import { getPreviousAliasForWebsite, generateSuggestionsForWebsite, @@ -710,40 +711,9 @@ function createPopup( if (!historyList) return; try { - // Use the same account that was used to generate this popup's aliases. - const historyKey = getAccountStorageKey( - data.activeEmail, - "gmail_alias_recent", - ); - const favoritesKey = getAccountStorageKey(data.activeEmail, "favorites"); - - const storage = (await browser.storage.local.get([ - historyKey, - favoritesKey, - "gmail_alias_recent", - "favorites", - ])) as Record; - // Older installations may still have history under the global key. - const history = (storage[historyKey] ?? - storage.gmail_alias_recent ?? - []) as Array<{ - email: string; - timestamp: number; - }>; - currentHistory = history - .filter((item) => item && typeof item.email === "string") - .slice() - .sort((a, b) => b.timestamp - a.timestamp); - const favorites = (storage[favoritesKey] ?? storage.favorites ?? []) as - | Array<{ email?: string } | string> - | undefined; - currentFavorites = Array.isArray(favorites) - ? favorites - .map((favorite) => - typeof favorite === "string" ? favorite : favorite.email, - ) - .filter((email): email is string => Boolean(email)) - : []; + const accountHistory = await loadInlineAccountHistory(data.activeEmail); + currentHistory = accountHistory.history; + currentFavorites = accountHistory.favorites; if (historyTag) { const selectedTag = historyTag.value; diff --git a/entrypoints/popup/components/Settings.tsx b/entrypoints/popup/components/Settings.tsx index 57354bf6..3315d49d 100644 --- a/entrypoints/popup/components/Settings.tsx +++ b/entrypoints/popup/components/Settings.tsx @@ -24,6 +24,11 @@ import { parseDisabledInlineSites, } from "src/utils/inlineSiteSettings"; import { openUserGuide } from "src/utils/externalLinks"; +import { + CHANGELOG, + type ChangelogChange, + type ChangelogEntry, +} from "../data/changelog"; interface SettingsProps { isOpen: boolean; @@ -61,17 +66,6 @@ interface ConfirmationRequest { resolve: (confirmed: boolean) => void; } -interface ChangelogChange { - type: "Added" | "Changed" | "Fixed"; - items: string[]; -} - -interface ChangelogEntry { - version: string; - date: string; - changes: ChangelogChange[]; -} - const DEFAULT_SETTINGS: AppSettings = { customPresets: [], maxHistory: 20, @@ -83,155 +77,6 @@ const DEFAULT_SETTINGS: AppSettings = { const TOAST_DURATION = 2000; -const CHANGELOG: ChangelogEntry[] = [ - { - version: "1.3.2", - date: "2026-07-24", - changes: [ - { - type: "Added", - items: [ - "Added generated-manifest scope verification with regression coverage for Chrome and Firefox-style permissions", - ], - }, - { - type: "Changed", - items: [ - "Aligned WXT development and production URL-scope handling", - "Restricted development builds to the configured site allowlist while preserving all-site production support", - ], - }, - { - type: "Fixed", - items: [ - "Restored inline popup availability in production builds", - "Prevented unrelated content scripts and broad development URL patterns from passing manifest validation", - ], - }, - ], - }, - { - version: "1.3.1", - date: "2026-07-20", - changes: [ - { - type: "Added", - items: [ - "Added Edge and Opera release packages with multi-browser installation guidance", - "Added an interactive product tour and richer landing-page previews", - ], - }, - { - type: "Changed", - items: [ - "Improved active email handling across the popup, inline helper, and context menus", - "Optimized inline popup behavior and displayed the active base email", - ], - }, - { - type: "Fixed", - items: [ - "Improved user feedback when disabling the inline helper or saving aliases", - "Fixed context-menu caching, history loading, injected icon cleanup, and development-site configuration", - ], - }, - ], - }, - { - version: "1.3.0", - date: "2026-07-13", - changes: [ - { - type: "Added", - items: [ - "Added website-aware alias suggestions based on the current hostname", - "Added an email input helper with inline icons, suggestion popups, live previews, and explicit Use actions", - "Added previous-alias navigation and an information panel explaining supported rules and local-only storage", - "Added expanded statistics metrics and Russian and Turkish translations", - "Added a product landing page with automated GitHub Pages deployment", - ], - }, - { - type: "Changed", - items: [ - "Enhanced the context menu with dynamic, website-specific alias suggestions", - "Updated the History tab to show aliases across websites and store history per email account", - "Improved popup navigation, layout, styling, and alias selection behavior", - "Reorganized the content script and colocated its email helper styles", - ], - }, - { - type: "Fixed", - items: [ - "Preserved email input width and flex layout when injecting the helper icon", - "Improved helper popup positioning and hover behavior to prevent accidental closing", - "Hid the Tags statistics tab when there is not enough data for a useful chart", - "Hardened content rendering against client-side cross-site scripting", - "Resolved code quality, localization, and build workflow issues", - ], - }, - ], - }, - { - version: "1.2.0", - date: "2026-07-03", - changes: [ - { - type: "Added", - items: [ - "Added Tailwind CSS v4, shadcn, and beUI motion components", - "Added Action Swap, Animated Badge, Bouncy Accordion, Theme Toggle, Tooltip, and Table integrations", - "Added dark mode toggle in the popup header", - "Added locale key coverage tests for all supported languages", - ], - }, - { - type: "Changed", - items: [ - "Redesigned popup, settings, generator tabs, Gmail tricks, history table, and changelog UI with a unified beUI style", - "Reworked Recent Aliases into a compact non-scrolling table with fixed action buttons", - "Improved dark mode contrast, spacing, hover states, tooltips, and responsive popup layout", - "Moved theme switching out of Settings and into the main popup header", - "Updated all supported locales with the new UI strings", - ], - }, - { - type: "Fixed", - items: [ - '"Copy All" no longer undercounts statistics for generated aliases', - "Settings/QR modals no longer render outside the popup bounds", - "Tab key now moves focus normally instead of being hijacked for @gmail.com autocomplete", - "Fixed missing imports and old component references after replacing legacy UI components", - "Fixed table overflow and hidden row action buttons in alias history", - "Fixed untranslated/fallback strings in the new UI", - ], - }, - ], - }, - { - version: "1.1.0", - date: "2025-12-30", - changes: [ - { - type: "Added", - items: [ - "Gmail alias generation with plus addressing", - "Preset management", - "Keyboard shortcuts", - "Statistics tracking", - ], - }, - { type: "Changed", items: ["Updated dependencies"] }, - { type: "Fixed", items: ["Bug fixes and improvements"] }, - ], - }, - { - version: "1.0.0", - date: "2025-12-30", - changes: [{ type: "Added", items: ["Initial release"] }], - }, -]; - interface SettingsPanelProps { settings: AppSettings; saveSettings: (settings: AppSettings) => Promise; diff --git a/entrypoints/popup/data/changelog.ts b/entrypoints/popup/data/changelog.ts new file mode 100644 index 00000000..2a8e6880 --- /dev/null +++ b/entrypoints/popup/data/changelog.ts @@ -0,0 +1,175 @@ +export type ChangelogChangeType = "Added" | "Changed" | "Fixed"; + +export interface ChangelogChange { + readonly type: ChangelogChangeType; + readonly items: readonly string[]; +} + +export interface ChangelogEntry { + readonly version: string; + readonly date: string; + readonly changes: readonly ChangelogChange[]; +} + +export const CHANGELOG: readonly ChangelogEntry[] = [ + { + version: "1.3.3", + date: "2026-07-29", + changes: [ + { + type: "Fixed", + items: [ + "Preserved the selected Google Workspace domain when generating website-aware aliases instead of forcing @gmail.com", + "Filtered previous aliases, recent history, and favorites by the active account to prevent Gmail data from appearing under a Workspace account", + "Rejected malformed base email addresses before generating inline alias suggestions", + ], + }, + ], + }, + { + version: "1.3.2", + date: "2026-07-24", + changes: [ + { + type: "Added", + items: [ + "Added generated-manifest scope verification with regression coverage for Chrome and Firefox-style permissions", + ], + }, + { + type: "Changed", + items: [ + "Aligned WXT development and production URL-scope handling", + "Restricted development builds to the configured site allowlist while preserving all-site production support", + ], + }, + { + type: "Fixed", + items: [ + "Restored inline popup availability in production builds", + "Prevented unrelated content scripts and broad development URL patterns from passing manifest validation", + ], + }, + ], + }, + { + version: "1.3.1", + date: "2026-07-20", + changes: [ + { + type: "Added", + items: [ + "Added Edge and Opera release packages with multi-browser installation guidance", + "Added an interactive product tour and richer landing-page previews", + ], + }, + { + type: "Changed", + items: [ + "Improved active email handling across the popup, inline helper, and context menus", + "Optimized inline popup behavior and displayed the active base email", + ], + }, + { + type: "Fixed", + items: [ + "Improved user feedback when disabling the inline helper or saving aliases", + "Fixed context-menu caching, history loading, injected icon cleanup, and development-site configuration", + ], + }, + ], + }, + { + version: "1.3.0", + date: "2026-07-13", + changes: [ + { + type: "Added", + items: [ + "Added website-aware alias suggestions based on the current hostname", + "Added an email input helper with inline icons, suggestion popups, live previews, and explicit Use actions", + "Added previous-alias navigation and an information panel explaining supported rules and local-only storage", + "Added expanded statistics metrics and Russian and Turkish translations", + "Added a product landing page with automated GitHub Pages deployment", + ], + }, + { + type: "Changed", + items: [ + "Enhanced the context menu with dynamic, website-specific alias suggestions", + "Updated the History tab to show aliases across websites and store history per email account", + "Improved popup navigation, layout, styling, and alias selection behavior", + "Reorganized the content script and colocated its email helper styles", + ], + }, + { + type: "Fixed", + items: [ + "Preserved email input width and flex layout when injecting the helper icon", + "Improved helper popup positioning and hover behavior to prevent accidental closing", + "Hid the Tags statistics tab when there is not enough data for a useful chart", + "Hardened content rendering against client-side cross-site scripting", + "Resolved code quality, localization, and build workflow issues", + ], + }, + ], + }, + { + version: "1.2.0", + date: "2026-07-03", + changes: [ + { + type: "Added", + items: [ + "Added Tailwind CSS v4, shadcn, and beUI motion components", + "Added Action Swap, Animated Badge, Bouncy Accordion, Theme Toggle, Tooltip, and Table integrations", + "Added dark mode toggle in the popup header", + "Added locale key coverage tests for all supported languages", + ], + }, + { + type: "Changed", + items: [ + "Redesigned popup, settings, generator tabs, Gmail tricks, history table, and changelog UI with a unified beUI style", + "Reworked Recent Aliases into a compact non-scrolling table with fixed action buttons", + "Improved dark mode contrast, spacing, hover states, tooltips, and responsive popup layout", + "Moved theme switching out of Settings and into the main popup header", + "Updated all supported locales with the new UI strings", + ], + }, + { + type: "Fixed", + items: [ + "\"Copy All\" no longer undercounts statistics for generated aliases", + "Settings/QR modals no longer render outside the popup bounds", + "Tab key now moves focus normally instead of being hijacked for @gmail.com autocomplete", + "Fixed missing imports and old component references after replacing legacy UI components", + "Fixed table overflow and hidden row action buttons in alias history", + "Fixed untranslated/fallback strings in the new UI", + ], + }, + ], + }, + { + version: "1.1.0", + date: "2025-12-30", + changes: [ + { + type: "Added", + items: [ + "Gmail alias generation with plus addressing", + "Preset management", + "Keyboard shortcuts", + "Statistics tracking", + ], + }, + { type: "Changed", items: ["Updated dependencies"] }, + { type: "Fixed", items: ["Bug fixes and improvements"] }, + ], + }, + { + version: "1.0.0", + date: "2025-12-30", + changes: [{ type: "Added", items: ["Initial release"] }], + }, +]; diff --git a/package.json b/package.json index 74a6f5f8..6ed73408 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gmail-alias-toolkit", "description": "Generate and manage Gmail aliases with plus addressing and presets", "private": true, - "version": "1.3.2", + "version": "1.3.3", "author": "dev@eplus.dev", "license": "MIT", "homepage_url": "https://eplus.dev", diff --git a/src/services/inlineHistoryService.ts b/src/services/inlineHistoryService.ts new file mode 100644 index 00000000..67d212d9 --- /dev/null +++ b/src/services/inlineHistoryService.ts @@ -0,0 +1,107 @@ +import { + filterAliasesForAccount, + getAccountStorageKey, +} from "../../entrypoints/popup/utils"; + +export interface InlineHistoryAlias { + email: string; + timestamp: number; +} + +export interface InlineAccountHistory { + history: InlineHistoryAlias[]; + favorites: string[]; +} + +interface FavoriteAlias { + email: string; +} + +/** Returns whether a stored value is a usable inline history entry. */ +function isInlineHistoryAlias(value: unknown): value is InlineHistoryAlias { + if (!value || typeof value !== "object") return false; + const candidate = value as { email?: unknown; timestamp?: unknown }; + return ( + typeof candidate.email === "string" && + typeof candidate.timestamp === "number" && + Number.isFinite(candidate.timestamp) + ); +} + +/** Normalizes legacy string and object favorite values into one shape. */ +function normalizeFavoriteAlias(value: unknown): FavoriteAlias | null { + if (typeof value === "string" && value) return { email: value }; + if (!value || typeof value !== "object") return null; + + const email = (value as { email?: unknown }).email; + return typeof email === "string" && email ? { email } : null; +} + +/** Sorts history entries from newest to oldest. */ +function sortHistoryNewestFirst( + first: InlineHistoryAlias, + second: InlineHistoryAlias, +): number { + return second.timestamp - first.timestamp; +} + +/** Extracts valid history entries from account-scoped or legacy storage. */ +function parseHistory(value: unknown): InlineHistoryAlias[] { + if (!Array.isArray(value)) return []; + + const history: InlineHistoryAlias[] = []; + for (const entry of value) { + if (isInlineHistoryAlias(entry)) history.push(entry); + } + return history; +} + +/** Extracts valid favorite aliases from legacy string and object formats. */ +function parseFavorites(value: unknown): FavoriteAlias[] { + if (!Array.isArray(value)) return []; + + const favorites: FavoriteAlias[] = []; + for (const entry of value) { + const favorite = normalizeFavoriteAlias(entry); + if (favorite) favorites.push(favorite); + } + return favorites; +} + +/** Uses scoped storage whenever its key exists, otherwise falls back to legacy data. */ +function selectStoredValue( + storage: Record, + accountKey: string, + legacyKey: string, +): unknown { + return Object.prototype.hasOwnProperty.call(storage, accountKey) + ? storage[accountKey] + : (storage[legacyKey] ?? []); +} + +/** Loads account-isolated history and favorites for the inline helper popup. */ +export async function loadInlineAccountHistory( + activeEmail: string, +): Promise { + const historyKey = getAccountStorageKey(activeEmail, "gmail_alias_recent"); + const favoritesKey = getAccountStorageKey(activeEmail, "favorites"); + const storage = (await browser.storage.local.get([ + historyKey, + favoritesKey, + "gmail_alias_recent", + "favorites", + ])) as Record; + + const history = filterAliasesForAccount( + parseHistory(selectStoredValue(storage, historyKey, "gmail_alias_recent")), + activeEmail, + ).sort(sortHistoryNewestFirst); + const favoriteAliases = filterAliasesForAccount( + parseFavorites(selectStoredValue(storage, favoritesKey, "favorites")), + activeEmail, + ); + const favorites: string[] = []; + for (const favorite of favoriteAliases) favorites.push(favorite.email); + + return { history, favorites }; +} diff --git a/src/services/websiteAliasService.ts b/src/services/websiteAliasService.ts index cdde8d35..4275b853 100644 --- a/src/services/websiteAliasService.ts +++ b/src/services/websiteAliasService.ts @@ -1,5 +1,10 @@ import { normalizeHostname } from "../utils/hostnameNormalizer"; -import { getAccountStorageKey } from "../../entrypoints/popup/utils"; +import { + generateAlias, + getAccountStorageKey, + isAliasForAccount, + validateEmail, +} from "../../entrypoints/popup/utils"; interface WebsiteAliasEntry { alias: string; @@ -49,9 +54,7 @@ export async function saveWebsiteAlias( await browser.storage.local.set({ [key]: map }); } -/** - * Get the previously used alias for a website (if any). - */ +/** Returns a previous website alias only when it belongs to the active account. */ export async function getPreviousAliasForWebsite( email: string, urlOrHostname: string, @@ -62,48 +65,46 @@ export async function getPreviousAliasForWebsite( const map = await getWebsiteAliasMap(email); const entry = map[normalized]; - return entry ? { alias: entry.alias, timestamp: entry.timestamp } : null; + if (!entry || !isAliasForAccount(entry.alias, email)) return null; + return { alias: entry.alias, timestamp: entry.timestamp }; } -/** - * Generate alias suggestions for a website. - * Returns 3–5 suggestions based on the normalized hostname and random formats. - */ +/** Generates website-aware suggestions while preserving the active account domain. */ export async function generateSuggestionsForWebsite( baseEmail: string, urlOrHostname: string, ): Promise { const normalized = normalizeHostname(urlOrHostname); - if (!normalized) return []; - - // Extract base email part (before @gmail.com) - const emailBase = baseEmail.split("@")[0]; + if (!normalized || !validateEmail(baseEmail).isValid) return []; const suggestions: string[] = []; - // 1. Simple: base+normalized - suggestions.push(`${emailBase}+${normalized}@gmail.com`); + /** Adds a valid plus-addressed alias while preserving the active account domain. */ + function appendSuggestion(tag: string): void { + const alias = generateAlias(baseEmail, tag); + if (alias) suggestions.push(alias); + } + + // 1. Simple: base+normalized@configured-domain + appendSuggestion(normalized); - // 2. Counter: base+normalized001 + // 2. Counter: base+normalized001@configured-domain const map = await getWebsiteAliasMap(baseEmail); const count = map[normalized]?.generatedCount || 0; if (count > 0) { - suggestions.push( - `${emailBase}+${normalized}${String(count + 1).padStart(3, "0")}@gmail.com`, - ); + appendSuggestion(`${normalized}${String(count + 1).padStart(3, "0")}`); } - // 3. Short: base+first-3-letters - const shortCode = normalized.slice(0, 3); - suggestions.push(`${emailBase}+${shortCode}@gmail.com`); + // 3. Short: base+first-3-letters@configured-domain + appendSuggestion(normalized.slice(0, 3)); - // 4. With date: base+normalized-YYYYMMDD + // 4. With date: base+normalized-YYYYMMDD@configured-domain const today = new Date().toISOString().slice(0, 10).replace(/-/g, ""); - suggestions.push(`${emailBase}+${normalized}-${today}@gmail.com`); + appendSuggestion(`${normalized}-${today}`); - // 5. Random suffix: base+normalized-XXXX + // 5. Random suffix: base+normalized-XXXX@configured-domain const randomSuffix = Math.random().toString(36).substring(2, 6); - suggestions.push(`${emailBase}+${normalized}-${randomSuffix}@gmail.com`); + appendSuggestion(`${normalized}-${randomSuffix}`); return suggestions.slice(0, 5); } diff --git a/tests/popup/changelogData.test.ts b/tests/popup/changelogData.test.ts new file mode 100644 index 00000000..81970f21 --- /dev/null +++ b/tests/popup/changelogData.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { CHANGELOG } from "../../entrypoints/popup/data/changelog"; +import { APP_VERSION } from "../../src/version"; + +let versions: string[] = []; + +/** Converts a semantic version into numeric parts for descending-order checks. */ +function parseVersion(version: string): number[] { + const parts: number[] = []; + for (const part of version.split(".")) parts.push(Number(part)); + return parts; +} + +/** Compares two semantic versions from newest to oldest. */ +function compareVersionsDescending(first: string, second: string): number { + const firstParts = parseVersion(first); + const secondParts = parseVersion(second); + + for (let index = 0; index < 3; index += 1) { + const difference = (secondParts[index] ?? 0) - (firstParts[index] ?? 0); + if (difference !== 0) return difference; + } + + return 0; +} + +/** Restores changelog version fixtures before each assertion. */ +function resetVersions(): void { + versions = []; + for (const entry of CHANGELOG) versions.push(entry.version); +} + +/** Verifies the newest changelog entry matches the package version. */ +function assertCurrentVersionIsFirst(): void { + expect(CHANGELOG[0]?.version).toBe(APP_VERSION); +} + +/** Verifies changelog versions are unique and ordered newest to oldest. */ +function assertVersionsAreUniqueAndDescending(): void { + expect(new Set(versions).size).toBe(versions.length); + expect([...versions].sort(compareVersionsDescending)).toEqual(versions); +} + +/** Exercises newer, older, and equal comparator outcomes directly. */ +function assertVersionComparatorBranches(): void { + expect(compareVersionsDescending("1.3.3", "1.3.2")).toBeLessThan(0); + expect(compareVersionsDescending("1.3.2", "1.3.3")).toBeGreaterThan(0); + expect(compareVersionsDescending("1.3.3", "1.3.3")).toBe(0); +} + +/** Verifies every release includes a valid date and non-empty change item. */ +function assertReleaseEntriesAreComplete(): void { + for (const entry of CHANGELOG) { + expect(entry.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(entry.changes.length).toBeGreaterThan(0); + + for (const change of entry.changes) { + expect(change.items.length).toBeGreaterThan(0); + for (const item of change.items) expect(item).not.toBe(""); + } + } +} + +/** Registers changelog data integrity tests. */ +function defineChangelogDataTests(): void { + beforeEach(resetVersions); + it("starts with the current package version", assertCurrentVersionIsFirst); + it( + "keeps versions unique and ordered from newest to oldest", + assertVersionsAreUniqueAndDescending, + ); + it( + "compares newer, older, and equal versions", + assertVersionComparatorBranches, + ); + it( + "provides at least one non-empty change item for every release", + assertReleaseEntriesAreComplete, + ); +} + +describe("extension changelog data", defineChangelogDataTests); diff --git a/tests/services/inlineHistoryService.test.ts b/tests/services/inlineHistoryService.test.ts new file mode 100644 index 00000000..6b553d1e --- /dev/null +++ b/tests/services/inlineHistoryService.test.ts @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getAccountStorageKey } from "../../entrypoints/popup/utils"; +import { loadInlineAccountHistory } from "../../src/services/inlineHistoryService"; + +const mockStorageData: Record = {}; + +/** Returns the requested keys from the in-memory browser storage fixture. */ +function mockStorageGet(keys: string[]): Promise> { + const result: Record = {}; + for (const key of keys) { + if (key in mockStorageData) result[key] = mockStorageData[key]; + } + return Promise.resolve(result); +} + +const mockStorageGetFunction = vi.fn(mockStorageGet); + +vi.stubGlobal("browser", { + storage: { + local: { + get: mockStorageGetFunction, + }, + }, +}); + +/** Clears browser storage fixtures before each service test. */ +function resetMockStorage(): void { + for (const key of Object.keys(mockStorageData)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete mockStorageData[key]; + } + vi.clearAllMocks(); +} + +/** Verifies mixed account-scoped entries render only for the active Workspace account. */ +async function assertScopedWorkspaceHistoryIsIsolated(): Promise { + const activeEmail = "nguyen.minh.hoang@rivercrane.vn"; + const historyKey = getAccountStorageKey(activeEmail, "gmail_alias_recent"); + const favoritesKey = getAccountStorageKey(activeEmail, "favorites"); + mockStorageData[historyKey] = [ + { + email: "nguyen.minh.hoang+older@rivercrane.vn", + timestamp: 10, + }, + { email: "nguyen.minh.hoang+gmail@gmail.com", timestamp: 30 }, + { + email: "nguyen.minh.hoang+newer@rivercrane.vn", + timestamp: 20, + }, + ]; + mockStorageData[favoritesKey] = [ + "nguyen.minh.hoang+newer@rivercrane.vn", + { email: "nguyen.minh.hoang+gmail@gmail.com" }, + ]; + + const result = await loadInlineAccountHistory(activeEmail); + + expect(result.history).toEqual([ + { + email: "nguyen.minh.hoang+newer@rivercrane.vn", + timestamp: 20, + }, + { + email: "nguyen.minh.hoang+older@rivercrane.vn", + timestamp: 10, + }, + ]); + expect(result.favorites).toEqual([ + "nguyen.minh.hoang+newer@rivercrane.vn", + ]); +} + +/** Verifies mixed legacy storage is filtered before the inline popup consumes it. */ +async function assertLegacyWorkspaceHistoryIsIsolated(): Promise { + const activeEmail = "nguyen.minh.hoang@rivercrane.vn"; + mockStorageData.gmail_alias_recent = [ + { email: "nguyen.minh.hoang+webike@rivercrane.vn", timestamp: 2 }, + { email: "nguyen.minh.hoang+webike@gmail.com", timestamp: 3 }, + { email: "not-an-email", timestamp: 4 }, + ]; + mockStorageData.favorites = [ + { email: "nguyen.minh.hoang+webike@rivercrane.vn" }, + "nguyen.minh.hoang+webike@gmail.com", + null, + ]; + + const result = await loadInlineAccountHistory(activeEmail); + + expect(result.history).toEqual([ + { email: "nguyen.minh.hoang+webike@rivercrane.vn", timestamp: 2 }, + ]); + expect(result.favorites).toEqual([ + "nguyen.minh.hoang+webike@rivercrane.vn", + ]); +} + +/** Verifies account-scoped values take precedence over obsolete global values. */ +async function assertScopedStorageTakesPrecedence(): Promise { + const activeEmail = "nguyen.minh.hoang@rivercrane.vn"; + const historyKey = getAccountStorageKey(activeEmail, "gmail_alias_recent"); + const favoritesKey = getAccountStorageKey(activeEmail, "favorites"); + mockStorageData[historyKey] = []; + mockStorageData[favoritesKey] = []; + mockStorageData.gmail_alias_recent = [ + { email: "nguyen.minh.hoang+legacy@rivercrane.vn", timestamp: 1 }, + ]; + mockStorageData.favorites = [ + "nguyen.minh.hoang+legacy@rivercrane.vn", + ]; + + const result = await loadInlineAccountHistory(activeEmail); + + expect(result).toEqual({ history: [], favorites: [] }); +} + +/** Verifies present null scoped values block fallback to obsolete global data. */ +async function assertNullScopedStorageBlocksLegacyFallback(): Promise { + const activeEmail = "nguyen.minh.hoang@rivercrane.vn"; + const historyKey = getAccountStorageKey(activeEmail, "gmail_alias_recent"); + const favoritesKey = getAccountStorageKey(activeEmail, "favorites"); + mockStorageData[historyKey] = null; + mockStorageData[favoritesKey] = null; + mockStorageData.gmail_alias_recent = [ + { email: "nguyen.minh.hoang+legacy@rivercrane.vn", timestamp: 1 }, + ]; + mockStorageData.favorites = [ + "nguyen.minh.hoang+legacy@rivercrane.vn", + ]; + + const result = await loadInlineAccountHistory(activeEmail); + + expect(result).toEqual({ history: [], favorites: [] }); +} + +/** Verifies non-array account storage is treated as empty data. */ +async function assertNonArrayStorageIsIgnored(): Promise { + const activeEmail = "nguyen.minh.hoang@rivercrane.vn"; + const historyKey = getAccountStorageKey(activeEmail, "gmail_alias_recent"); + const favoritesKey = getAccountStorageKey(activeEmail, "favorites"); + mockStorageData[historyKey] = { email: "not-an-array" }; + mockStorageData[favoritesKey] = "not-an-array"; + + const result = await loadInlineAccountHistory(activeEmail); + + expect(result).toEqual({ history: [], favorites: [] }); +} + +/** Verifies malformed entries are skipped while valid Workspace entries remain. */ +async function assertMalformedEntriesAreIgnored(): Promise { + const activeEmail = "nguyen.minh.hoang@rivercrane.vn"; + const historyKey = getAccountStorageKey(activeEmail, "gmail_alias_recent"); + const favoritesKey = getAccountStorageKey(activeEmail, "favorites"); + mockStorageData[historyKey] = [ + null, + { email: "nguyen.minh.hoang+missing-time@rivercrane.vn" }, + { + email: "nguyen.minh.hoang+bad-time@rivercrane.vn", + timestamp: "10", + }, + { + email: "nguyen.minh.hoang+nan@rivercrane.vn", + timestamp: Number.NaN, + }, + { + email: "nguyen.minh.hoang+infinity@rivercrane.vn", + timestamp: Number.POSITIVE_INFINITY, + }, + { + email: "nguyen.minh.hoang+negative-infinity@rivercrane.vn", + timestamp: Number.NEGATIVE_INFINITY, + }, + { email: 123, timestamp: 9 }, + { email: "nguyen.minh.hoang+valid@rivercrane.vn", timestamp: 8 }, + ]; + mockStorageData[favoritesKey] = [ + null, + "", + {}, + { email: "" }, + { email: 123 }, + { email: "nguyen.minh.hoang+valid@rivercrane.vn" }, + ]; + + const result = await loadInlineAccountHistory(activeEmail); + + expect(result.history).toEqual([ + { email: "nguyen.minh.hoang+valid@rivercrane.vn", timestamp: 8 }, + ]); + expect(result.favorites).toEqual([ + "nguyen.minh.hoang+valid@rivercrane.vn", + ]); +} + +/** Verifies browser storage failures propagate to the inline popup caller. */ +async function assertStorageFailurePropagates(): Promise { + mockStorageGetFunction.mockRejectedValueOnce( + new Error("Inline history storage unavailable"), + ); + + await expect( + loadInlineAccountHistory("nguyen.minh.hoang@rivercrane.vn"), + ).rejects.toThrow("Inline history storage unavailable"); +} + +/** Registers browser-storage integration coverage for inline account history. */ +function defineInlineHistoryServiceTests(): void { + beforeEach(resetMockStorage); + it( + "filters account-scoped history and favorites by active Workspace account", + assertScopedWorkspaceHistoryIsIsolated, + ); + it( + "filters legacy history and favorites by active Workspace account", + assertLegacyWorkspaceHistoryIsIsolated, + ); + it( + "prefers account-scoped storage over legacy global storage", + assertScopedStorageTakesPrecedence, + ); + it( + "does not fall back when scoped storage exists as null", + assertNullScopedStorageBlocksLegacyFallback, + ); + it("treats non-array storage as empty", assertNonArrayStorageIsIgnored); + it( + "ignores malformed history and favorite entries", + assertMalformedEntriesAreIgnored, + ); + it("propagates browser storage failures", assertStorageFailurePropagates); +} + +describe("loadInlineAccountHistory", defineInlineHistoryServiceTests); diff --git a/tests/services/websiteAliasService.test.ts b/tests/services/websiteAliasService.test.ts index 6a7af22f..17ffb239 100644 --- a/tests/services/websiteAliasService.test.ts +++ b/tests/services/websiteAliasService.test.ts @@ -20,6 +20,8 @@ type MockBrowser = { }; }; +type ImportOriginal = () => Promise; + vi.stubGlobal("browser", { storage: { local: { @@ -47,10 +49,26 @@ vi.stubGlobal("browser", { }, } as unknown as MockBrowser); -// Mock the getAccountStorageKey utility -vi.mock("../../entrypoints/popup/utils", () => ({ - getAccountStorageKey: (email: string, suffix: string) => `${email}:${suffix}`, -})); +/** Builds deterministic account-scoped storage keys for service tests. */ +function getMockAccountStorageKey(email: string, suffix: string): string { + return `${email}:${suffix}`; +} + +/** Keeps real alias utilities while replacing account storage key generation. */ +async function createPopupUtilsMock( + importOriginal: ImportOriginal, +): Promise { + const original = await importOriginal< + typeof import("../../entrypoints/popup/utils") + >(); + + return { + ...original, + getAccountStorageKey: getMockAccountStorageKey, + }; +} + +vi.mock("../../entrypoints/popup/utils", createPopupUtilsMock); describe("websiteAliasService", () => { const testEmail = "user@gmail.com"; @@ -131,6 +149,21 @@ describe("websiteAliasService", () => { }); describe("getPreviousAliasForWebsite", () => { + /** Verifies that a stored Gmail alias cannot leak into a Workspace account. */ + async function assertCrossAccountAliasIsIgnored(): Promise { + const workspaceEmail = "nguyen.minh.hoang@rivercrane.vn"; + mockStorageData[`${workspaceEmail}:website_alias_map`] = { + github: { + alias: "nguyen.minh.hoang+github@gmail.com", + timestamp: 12345, + generatedCount: 1, + }, + }; + + const result = await getPreviousAliasForWebsite(workspaceEmail, testUrl); + expect(result).toBeNull(); + } + it("returns null for unmapped website", async () => { const result = await getPreviousAliasForWebsite(testEmail, "unknown.com"); expect(result).toBeNull(); @@ -146,6 +179,11 @@ describe("websiteAliasService", () => { expect(result).toEqual({ alias: testAlias, timestamp }); }); + it( + "ignores a stale website alias owned by another account", + assertCrossAccountAliasIsIgnored, + ); + it("accepts both URL and hostname", async () => { // Save alias with normalized key "github" await saveWebsiteAlias(testEmail, "github", testAlias); @@ -175,6 +213,39 @@ describe("websiteAliasService", () => { }); describe("generateSuggestionsForWebsite", () => { + /** Verifies that malformed base emails never produce inline suggestions. */ + async function assertMalformedBaseEmailsAreRejected(): Promise { + const invalidEmails = [ + "invalid-email", + "user@localhost", + "user @gmail.com", + ]; + + for (const invalidEmail of invalidEmails) { + const suggestions = await generateSuggestionsForWebsite( + invalidEmail, + "https://github.com", + ); + expect(suggestions, invalidEmail).toEqual([]); + } + } + + /** Verifies that all generated suggestions retain the Workspace domain. */ + async function assertWorkspaceDomainIsPreserved(): Promise { + const workspaceEmail = "nguyen.minh.hoang@rivercrane.vn"; + const suggestions = await generateSuggestionsForWebsite( + workspaceEmail, + "https://github.com", + ); + + expect(suggestions).toContain( + "nguyen.minh.hoang+github@rivercrane.vn", + ); + for (const suggestion of suggestions) { + expect(suggestion).toMatch(/@rivercrane\.vn$/); + } + } + it("returns empty array for invalid hostname", async () => { const suggestions = await generateSuggestionsForWebsite( testEmail, @@ -183,6 +254,11 @@ describe("websiteAliasService", () => { expect(suggestions).toEqual([]); }); + it( + "returns empty array for malformed base email", + assertMalformedBaseEmailsAreRejected, + ); + it("generates up to 5 suggestions for new website", async () => { const suggestions = await generateSuggestionsForWebsite( testEmail, @@ -258,15 +334,10 @@ describe("websiteAliasService", () => { expect(suggestions.length).toBeLessThanOrEqual(5); }); - it("always uses @gmail.com for suggestions regardless of email domain", async () => { - const suggestions = await generateSuggestionsForWebsite( - "user@example.com", - "https://github.com", - ); - - // Suggestions always use @gmail.com, not the original email domain - suggestions.forEach((s) => expect(s).toMatch(/@gmail\.com$/)); - }); + it( + "preserves the selected Google Workspace domain", + assertWorkspaceDomainIsPreserved, + ); }); describe("clearWebsiteAliasMap", () => {