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/src/services/inlineHistoryService.ts b/src/services/inlineHistoryService.ts new file mode 100644 index 00000000..cd00f5cb --- /dev/null +++ b/src/services/inlineHistoryService.ts @@ -0,0 +1,104 @@ +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" + ); +} + +/** 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; +} + +/** Returns the first available account-scoped value, then its legacy fallback. */ +function selectStoredValue( + storage: Record, + accountKey: string, + legacyKey: string, +): unknown { + return 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..b66c70ef 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; @@ -62,7 +67,8 @@ 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 }; } /** @@ -74,36 +80,36 @@ export async function generateSuggestionsForWebsite( 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/services/inlineHistoryService.test.ts b/tests/services/inlineHistoryService.test.ts new file mode 100644 index 00000000..2b70739e --- /dev/null +++ b/tests/services/inlineHistoryService.test.ts @@ -0,0 +1,197 @@ +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 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: 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("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", () => {