From 12f4d2fbb85bd4138f9e5e28247f2599c4fb36f9 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 10:46:51 +0700 Subject: [PATCH 01/19] fix: preserve active account domain in website aliases --- src/services/websiteAliasService.ts | 39 ++++++++++++++++------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/src/services/websiteAliasService.ts b/src/services/websiteAliasService.ts index cdde8d35..ee039639 100644 --- a/src/services/websiteAliasService.ts +++ b/src/services/websiteAliasService.ts @@ -1,5 +1,8 @@ import { normalizeHostname } from "../utils/hostnameNormalizer"; -import { getAccountStorageKey } from "../../entrypoints/popup/utils"; +import { + generateAlias, + getAccountStorageKey, +} from "../../entrypoints/popup/utils"; interface WebsiteAliasEntry { alias: string; @@ -74,36 +77,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 || !generateAlias(baseEmail, normalized)) 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); } From 596ee9fee7f595200f297b9f635213ec771d3275 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 10:48:05 +0700 Subject: [PATCH 02/19] test: cover Google Workspace alias domains --- tests/services/websiteAliasService.test.ts | 34 +++++++++++++++++----- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/tests/services/websiteAliasService.test.ts b/tests/services/websiteAliasService.test.ts index 6a7af22f..4d25bc00 100644 --- a/tests/services/websiteAliasService.test.ts +++ b/tests/services/websiteAliasService.test.ts @@ -48,9 +48,16 @@ vi.stubGlobal("browser", { } as unknown as MockBrowser); // Mock the getAccountStorageKey utility -vi.mock("../../entrypoints/popup/utils", () => ({ - getAccountStorageKey: (email: string, suffix: string) => `${email}:${suffix}`, -})); +vi.mock("../../entrypoints/popup/utils", async (importOriginal) => { + const original = await importOriginal< + typeof import("../../entrypoints/popup/utils") + >(); + + return { + ...original, + getAccountStorageKey: (email: string, suffix: string) => `${email}:${suffix}`, + }; +}); describe("websiteAliasService", () => { const testEmail = "user@gmail.com"; @@ -183,6 +190,14 @@ describe("websiteAliasService", () => { expect(suggestions).toEqual([]); }); + it("returns empty array for malformed base email", async () => { + const suggestions = await generateSuggestionsForWebsite( + "invalid-email", + "https://github.com", + ); + expect(suggestions).toEqual([]); + }); + it("generates up to 5 suggestions for new website", async () => { const suggestions = await generateSuggestionsForWebsite( testEmail, @@ -258,14 +273,19 @@ describe("websiteAliasService", () => { expect(suggestions.length).toBeLessThanOrEqual(5); }); - it("always uses @gmail.com for suggestions regardless of email domain", async () => { + it("preserves the selected Google Workspace domain", async () => { + const workspaceEmail = "nguyen.minh.hoang@rivercrane.vn"; const suggestions = await generateSuggestionsForWebsite( - "user@example.com", + workspaceEmail, "https://github.com", ); - // Suggestions always use @gmail.com, not the original email domain - suggestions.forEach((s) => expect(s).toMatch(/@gmail\.com$/)); + expect(suggestions).toContain( + "nguyen.minh.hoang+github@rivercrane.vn", + ); + suggestions.forEach((suggestion) => + expect(suggestion).toMatch(/@rivercrane\.vn$/), + ); }); }); From 511f1835a2fa2268847d06514ce312381f50d52b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 10:51:39 +0700 Subject: [PATCH 03/19] fix: ignore cross-account website aliases --- src/services/websiteAliasService.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/services/websiteAliasService.ts b/src/services/websiteAliasService.ts index ee039639..38bb3820 100644 --- a/src/services/websiteAliasService.ts +++ b/src/services/websiteAliasService.ts @@ -2,6 +2,7 @@ import { normalizeHostname } from "../utils/hostnameNormalizer"; import { generateAlias, getAccountStorageKey, + isAliasForAccount, } from "../../entrypoints/popup/utils"; interface WebsiteAliasEntry { @@ -65,7 +66,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 }; } /** @@ -115,6 +117,6 @@ export async function generateSuggestionsForWebsite( * Clear all website alias mappings for an email (used for reset/import). */ export async function clearWebsiteAliasMap(email: string): Promise { - const key = getWebsiteAliasMapKey(email); + const key = getWebsiteAliasMapKey(email,); await browser.storage.local.remove([key]); } From 8e4c8a469f078fbae76917821632a781cbbe61a1 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 10:53:44 +0700 Subject: [PATCH 04/19] chore: add one-shot Workspace history patch --- scripts/patch-workspace-inline-history.py | 100 ++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 scripts/patch-workspace-inline-history.py diff --git a/scripts/patch-workspace-inline-history.py b/scripts/patch-workspace-inline-history.py new file mode 100644 index 00000000..227a773e --- /dev/null +++ b/scripts/patch-workspace-inline-history.py @@ -0,0 +1,100 @@ +from pathlib import Path + + +def replace_once(path: str, old: str, new: str) -> None: + """Replace one expected source fragment and fail if the branch drifted.""" + file_path = Path(path) + content = file_path.read_text(encoding="utf-8") + count = content.count(old) + if count != 1: + raise RuntimeError(f"Expected exactly one match in {path}, found {count}") + file_path.write_text(content.replace(old, new, 1), encoding="utf-8") + + +replace_once( + "entrypoints/content/index.ts", + ''' filterAliases, + getAccountStorageKey, +''', + ''' filterAliases, + filterAliasesForAccount, + getAccountStorageKey, +''', +) + +replace_once( + "entrypoints/content/index.ts", + ''' 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 validHistory = history.filter( + (item) => item && typeof item.email === "string", + ); + currentHistory = filterAliasesForAccount( + validHistory, + data.activeEmail, + ) + .slice() + .sort((a, b) => b.timestamp - a.timestamp); + const favorites = (storage[favoritesKey] ?? storage.favorites ?? []) as + | Array<{ email?: string } | string> + | undefined; + const favoriteEntries = Array.isArray(favorites) + ? favorites + .map((favorite) => ({ + email: + typeof favorite === "string" ? favorite : favorite.email || "", + })) + .filter((favorite) => Boolean(favorite.email)) + : []; + currentFavorites = filterAliasesForAccount( + favoriteEntries, + data.activeEmail, + ).map((favorite) => favorite.email); +''', +) + +replace_once( + "src/services/websiteAliasService.ts", + "const key = getWebsiteAliasMapKey(email,);", + "const key = getWebsiteAliasMapKey(email);", +) + +replace_once( + "tests/services/websiteAliasService.test.ts", + ''' it("accepts both URL and hostname", async () => { +''', + ''' it("ignores a stale website alias owned by another account", async () => { + 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("accepts both URL and hostname", async () => { +''', +) + +if "filterAliasesForAccount" not in Path("entrypoints/content/index.ts").read_text( + encoding="utf-8" +): + raise RuntimeError("Inline history account filter was not added") From 8f83b4c31d4b7ac390380ee222bc76e37222a241 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 10:54:10 +0700 Subject: [PATCH 05/19] chore: add one-shot Workspace fix workflow --- .../apply-workspace-inline-history-fix.yml | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/apply-workspace-inline-history-fix.yml diff --git a/.github/workflows/apply-workspace-inline-history-fix.yml b/.github/workflows/apply-workspace-inline-history-fix.yml new file mode 100644 index 00000000..eb00d054 --- /dev/null +++ b/.github/workflows/apply-workspace-inline-history-fix.yml @@ -0,0 +1,55 @@ +name: Apply Workspace inline history fix + +on: + pull_request: + types: [reopened] + +permissions: + contents: write + +jobs: + patch: + if: github.event.pull_request.number == 87 && github.head_ref == 'fix/preserve-workspace-alias-domain' + runs-on: ubuntu-latest + steps: + - name: Checkout feature branch + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24.x + cache: yarn + cache-dependency-path: yarn.lock + + - name: Apply account-isolation patch + run: python3 scripts/patch-workspace-inline-history.py + + - name: Validate patched source + shell: bash + run: | + set -euo pipefail + corepack enable + yarn install --immutable --mode=skip-build + yarn exec wxt prepare + yarn compile --pretty false + yarn test + + - name: Commit fix and remove temporary files + shell: bash + run: | + set -euo pipefail + git rm \ + .github/workflows/apply-workspace-inline-history-fix.yml \ + scripts/patch-workspace-inline-history.py + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add \ + entrypoints/content/index.ts \ + src/services/websiteAliasService.ts \ + tests/services/websiteAliasService.test.ts + git commit -m "fix: isolate Workspace inline aliases" + git push origin HEAD:${{ github.head_ref }} From 8abcb6dd4f48fee59e8a3e8bed9c6feb883c74bc Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 10:55:44 +0700 Subject: [PATCH 06/19] fix: enable one-shot Workspace patch workflow --- .github/workflows/apply-workspace-inline-history-fix.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/apply-workspace-inline-history-fix.yml b/.github/workflows/apply-workspace-inline-history-fix.yml index eb00d054..d6175ee7 100644 --- a/.github/workflows/apply-workspace-inline-history-fix.yml +++ b/.github/workflows/apply-workspace-inline-history-fix.yml @@ -22,8 +22,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: 24.x - cache: yarn - cache-dependency-path: yarn.lock - name: Apply account-isolation patch run: python3 scripts/patch-workspace-inline-history.py From 4c134063029437abd10dc1a81e1bb6a1852faa54 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:56:49 +0000 Subject: [PATCH 07/19] fix: isolate Workspace inline aliases --- .../apply-workspace-inline-history-fix.yml | 53 ---------- entrypoints/content/index.ts | 25 +++-- scripts/patch-workspace-inline-history.py | 100 ------------------ src/services/websiteAliasService.ts | 2 +- tests/services/websiteAliasService.test.ts | 14 +++ 5 files changed, 33 insertions(+), 161 deletions(-) delete mode 100644 .github/workflows/apply-workspace-inline-history-fix.yml delete mode 100644 scripts/patch-workspace-inline-history.py diff --git a/.github/workflows/apply-workspace-inline-history-fix.yml b/.github/workflows/apply-workspace-inline-history-fix.yml deleted file mode 100644 index d6175ee7..00000000 --- a/.github/workflows/apply-workspace-inline-history-fix.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Apply Workspace inline history fix - -on: - pull_request: - types: [reopened] - -permissions: - contents: write - -jobs: - patch: - if: github.event.pull_request.number == 87 && github.head_ref == 'fix/preserve-workspace-alias-domain' - runs-on: ubuntu-latest - steps: - - name: Checkout feature branch - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 24.x - - - name: Apply account-isolation patch - run: python3 scripts/patch-workspace-inline-history.py - - - name: Validate patched source - shell: bash - run: | - set -euo pipefail - corepack enable - yarn install --immutable --mode=skip-build - yarn exec wxt prepare - yarn compile --pretty false - yarn test - - - name: Commit fix and remove temporary files - shell: bash - run: | - set -euo pipefail - git rm \ - .github/workflows/apply-workspace-inline-history-fix.yml \ - scripts/patch-workspace-inline-history.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add \ - entrypoints/content/index.ts \ - src/services/websiteAliasService.ts \ - tests/services/websiteAliasService.test.ts - git commit -m "fix: isolate Workspace inline aliases" - git push origin HEAD:${{ github.head_ref }} diff --git a/entrypoints/content/index.ts b/entrypoints/content/index.ts index 7e82a788..65667260 100644 --- a/entrypoints/content/index.ts +++ b/entrypoints/content/index.ts @@ -15,6 +15,7 @@ import { generateAlias, generateRandomString, filterAliases, + filterAliasesForAccount, getAccountStorageKey, getAliasTags, getDotVariationCandidates, @@ -730,20 +731,30 @@ function createPopup( email: string; timestamp: number; }>; - currentHistory = history - .filter((item) => item && typeof item.email === "string") + const validHistory = history.filter( + (item) => item && typeof item.email === "string", + ); + currentHistory = filterAliasesForAccount( + validHistory, + data.activeEmail, + ) .slice() .sort((a, b) => b.timestamp - a.timestamp); const favorites = (storage[favoritesKey] ?? storage.favorites ?? []) as | Array<{ email?: string } | string> | undefined; - currentFavorites = Array.isArray(favorites) + const favoriteEntries = Array.isArray(favorites) ? favorites - .map((favorite) => - typeof favorite === "string" ? favorite : favorite.email, - ) - .filter((email): email is string => Boolean(email)) + .map((favorite) => ({ + email: + typeof favorite === "string" ? favorite : favorite.email || "", + })) + .filter((favorite) => Boolean(favorite.email)) : []; + currentFavorites = filterAliasesForAccount( + favoriteEntries, + data.activeEmail, + ).map((favorite) => favorite.email); if (historyTag) { const selectedTag = historyTag.value; diff --git a/scripts/patch-workspace-inline-history.py b/scripts/patch-workspace-inline-history.py deleted file mode 100644 index 227a773e..00000000 --- a/scripts/patch-workspace-inline-history.py +++ /dev/null @@ -1,100 +0,0 @@ -from pathlib import Path - - -def replace_once(path: str, old: str, new: str) -> None: - """Replace one expected source fragment and fail if the branch drifted.""" - file_path = Path(path) - content = file_path.read_text(encoding="utf-8") - count = content.count(old) - if count != 1: - raise RuntimeError(f"Expected exactly one match in {path}, found {count}") - file_path.write_text(content.replace(old, new, 1), encoding="utf-8") - - -replace_once( - "entrypoints/content/index.ts", - ''' filterAliases, - getAccountStorageKey, -''', - ''' filterAliases, - filterAliasesForAccount, - getAccountStorageKey, -''', -) - -replace_once( - "entrypoints/content/index.ts", - ''' 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 validHistory = history.filter( - (item) => item && typeof item.email === "string", - ); - currentHistory = filterAliasesForAccount( - validHistory, - data.activeEmail, - ) - .slice() - .sort((a, b) => b.timestamp - a.timestamp); - const favorites = (storage[favoritesKey] ?? storage.favorites ?? []) as - | Array<{ email?: string } | string> - | undefined; - const favoriteEntries = Array.isArray(favorites) - ? favorites - .map((favorite) => ({ - email: - typeof favorite === "string" ? favorite : favorite.email || "", - })) - .filter((favorite) => Boolean(favorite.email)) - : []; - currentFavorites = filterAliasesForAccount( - favoriteEntries, - data.activeEmail, - ).map((favorite) => favorite.email); -''', -) - -replace_once( - "src/services/websiteAliasService.ts", - "const key = getWebsiteAliasMapKey(email,);", - "const key = getWebsiteAliasMapKey(email);", -) - -replace_once( - "tests/services/websiteAliasService.test.ts", - ''' it("accepts both URL and hostname", async () => { -''', - ''' it("ignores a stale website alias owned by another account", async () => { - 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("accepts both URL and hostname", async () => { -''', -) - -if "filterAliasesForAccount" not in Path("entrypoints/content/index.ts").read_text( - encoding="utf-8" -): - raise RuntimeError("Inline history account filter was not added") diff --git a/src/services/websiteAliasService.ts b/src/services/websiteAliasService.ts index 38bb3820..c6ffd0df 100644 --- a/src/services/websiteAliasService.ts +++ b/src/services/websiteAliasService.ts @@ -117,6 +117,6 @@ export async function generateSuggestionsForWebsite( * Clear all website alias mappings for an email (used for reset/import). */ export async function clearWebsiteAliasMap(email: string): Promise { - const key = getWebsiteAliasMapKey(email,); + const key = getWebsiteAliasMapKey(email); await browser.storage.local.remove([key]); } diff --git a/tests/services/websiteAliasService.test.ts b/tests/services/websiteAliasService.test.ts index 4d25bc00..5d37cfef 100644 --- a/tests/services/websiteAliasService.test.ts +++ b/tests/services/websiteAliasService.test.ts @@ -153,6 +153,20 @@ describe("websiteAliasService", () => { expect(result).toEqual({ alias: testAlias, timestamp }); }); + it("ignores a stale website alias owned by another account", async () => { + 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("accepts both URL and hostname", async () => { // Save alias with normalized key "github" await saveWebsiteAlias(testEmail, "github", testAlias); From 2efd2755a68a7e2eeca0228013dafebf54a457d3 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 11:00:25 +0700 Subject: [PATCH 08/19] fix: validate base email before generating suggestions --- src/services/websiteAliasService.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/services/websiteAliasService.ts b/src/services/websiteAliasService.ts index c6ffd0df..b66c70ef 100644 --- a/src/services/websiteAliasService.ts +++ b/src/services/websiteAliasService.ts @@ -3,6 +3,7 @@ import { generateAlias, getAccountStorageKey, isAliasForAccount, + validateEmail, } from "../../entrypoints/popup/utils"; interface WebsiteAliasEntry { @@ -79,7 +80,7 @@ export async function generateSuggestionsForWebsite( urlOrHostname: string, ): Promise { const normalized = normalizeHostname(urlOrHostname); - if (!normalized || !generateAlias(baseEmail, normalized)) return []; + if (!normalized || !validateEmail(baseEmail).isValid) return []; const suggestions: string[] = []; From 33fa0a436543dd7483bc3c4a037eb41e8628a729 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 11:02:11 +0700 Subject: [PATCH 09/19] test: harden Workspace alias regression coverage --- tests/services/websiteAliasService.test.ts | 111 ++++++++++++++------- 1 file changed, 73 insertions(+), 38 deletions(-) diff --git a/tests/services/websiteAliasService.test.ts b/tests/services/websiteAliasService.test.ts index 5d37cfef..6e8c0791 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,17 +49,24 @@ vi.stubGlobal("browser", { }, } as unknown as MockBrowser); -// Mock the getAccountStorageKey utility -vi.mock("../../entrypoints/popup/utils", async (importOriginal) => { +/** 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) { const original = await importOriginal< typeof import("../../entrypoints/popup/utils") >(); return { ...original, - getAccountStorageKey: (email: string, suffix: string) => `${email}:${suffix}`, + getAccountStorageKey: getMockAccountStorageKey, }; -}); +} + +vi.mock("../../entrypoints/popup/utils", createPopupUtilsMock); describe("websiteAliasService", () => { const testEmail = "user@gmail.com"; @@ -138,6 +147,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(); @@ -153,19 +177,10 @@ describe("websiteAliasService", () => { expect(result).toEqual({ alias: testAlias, timestamp }); }); - it("ignores a stale website alias owned by another account", async () => { - 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( + "ignores a stale website alias owned by another account", + assertCrossAccountAliasIsIgnored, + ); it("accepts both URL and hostname", async () => { // Save alias with normalized key "github" @@ -196,6 +211,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, @@ -204,13 +252,10 @@ describe("websiteAliasService", () => { expect(suggestions).toEqual([]); }); - it("returns empty array for malformed base email", async () => { - const suggestions = await generateSuggestionsForWebsite( - "invalid-email", - "https://github.com", - ); - 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( @@ -287,20 +332,10 @@ describe("websiteAliasService", () => { expect(suggestions.length).toBeLessThanOrEqual(5); }); - it("preserves the selected Google Workspace domain", async () => { - const workspaceEmail = "nguyen.minh.hoang@rivercrane.vn"; - const suggestions = await generateSuggestionsForWebsite( - workspaceEmail, - "https://github.com", - ); - - expect(suggestions).toContain( - "nguyen.minh.hoang+github@rivercrane.vn", - ); - suggestions.forEach((suggestion) => - expect(suggestion).toMatch(/@rivercrane\.vn$/), - ); - }); + it( + "preserves the selected Google Workspace domain", + assertWorkspaceDomainIsPreserved, + ); }); describe("clearWebsiteAliasMap", () => { From d99587daa7ef1ab340c0e5c7a0e7be56a7b54eae Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 11:05:28 +0700 Subject: [PATCH 10/19] refactor: isolate inline account history loading --- src/services/inlineHistoryService.ts | 104 +++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 src/services/inlineHistoryService.ts 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 }; +} From 7458d6512869dada5c8d3326f449fc62f1f41a07 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 11:06:07 +0700 Subject: [PATCH 11/19] test: cover inline Workspace history isolation --- tests/services/inlineHistoryService.test.ts | 131 ++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 tests/services/inlineHistoryService.test.ts diff --git a/tests/services/inlineHistoryService.test.ts b/tests/services/inlineHistoryService.test.ts new file mode 100644 index 00000000..1fa9d576 --- /dev/null +++ b/tests/services/inlineHistoryService.test.ts @@ -0,0 +1,131 @@ +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); +} + +vi.stubGlobal("browser", { + storage: { + local: { + get: vi.fn(mockStorageGet), + }, + }, +}); + +/** 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: [] }); +} + +/** 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, + ); +} + +describe("loadInlineAccountHistory", defineInlineHistoryServiceTests); From 3a47e42140ae10d4a614222450ec49e5a0623a60 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 11:06:35 +0700 Subject: [PATCH 12/19] chore: add one-shot inline history service patch --- scripts/patch-inline-history-service.py | 99 +++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 scripts/patch-inline-history-service.py diff --git a/scripts/patch-inline-history-service.py b/scripts/patch-inline-history-service.py new file mode 100644 index 00000000..d02ca1ec --- /dev/null +++ b/scripts/patch-inline-history-service.py @@ -0,0 +1,99 @@ +from pathlib import Path + + +def replace_once(path: str, old: str, new: str) -> None: + """Replace one expected source fragment and fail when the branch has drifted.""" + file_path = Path(path) + content = file_path.read_text(encoding="utf-8") + count = content.count(old) + if count != 1: + raise RuntimeError(f"Expected exactly one match in {path}, found {count}") + file_path.write_text(content.replace(old, new, 1), encoding="utf-8") + + +replace_once( + "entrypoints/content/index.ts", + '''import { + getPreviousAliasForWebsite, + generateSuggestionsForWebsite, + saveWebsiteAlias, +} from "src/services/websiteAliasService"; +''', + '''import { loadInlineAccountHistory } from "src/services/inlineHistoryService"; +import { + getPreviousAliasForWebsite, + generateSuggestionsForWebsite, + saveWebsiteAlias, +} from "src/services/websiteAliasService"; +''', +) + +replace_once( + "entrypoints/content/index.ts", + ''' filterAliases, + filterAliasesForAccount, + getAccountStorageKey, +''', + ''' filterAliases, + getAccountStorageKey, +''', +) + +replace_once( + "entrypoints/content/index.ts", + ''' // 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; + }>; + const validHistory = history.filter( + (item) => item && typeof item.email === "string", + ); + currentHistory = filterAliasesForAccount( + validHistory, + data.activeEmail, + ) + .slice() + .sort((a, b) => b.timestamp - a.timestamp); + const favorites = (storage[favoritesKey] ?? storage.favorites ?? []) as + | Array<{ email?: string } | string> + | undefined; + const favoriteEntries = Array.isArray(favorites) + ? favorites + .map((favorite) => ({ + email: + typeof favorite === "string" ? favorite : favorite.email || "", + })) + .filter((favorite) => Boolean(favorite.email)) + : []; + currentFavorites = filterAliasesForAccount( + favoriteEntries, + data.activeEmail, + ).map((favorite) => favorite.email); +''', + ''' const accountHistory = await loadInlineAccountHistory(data.activeEmail); + currentHistory = accountHistory.history; + currentFavorites = accountHistory.favorites; +''', +) + +content = Path("entrypoints/content/index.ts").read_text(encoding="utf-8") +if "filterAliasesForAccount" in content: + raise RuntimeError("Stale inline account-filter import remains") +if "loadInlineAccountHistory(data.activeEmail)" not in content: + raise RuntimeError("Inline history service was not connected") From a031d5b20cd693a0791155ec8f2ac2d7af0c85ed Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 11:06:54 +0700 Subject: [PATCH 13/19] chore: add one-shot inline history service workflow --- .../apply-inline-history-service.yml | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/apply-inline-history-service.yml diff --git a/.github/workflows/apply-inline-history-service.yml b/.github/workflows/apply-inline-history-service.yml new file mode 100644 index 00000000..438f2b58 --- /dev/null +++ b/.github/workflows/apply-inline-history-service.yml @@ -0,0 +1,50 @@ +name: Apply inline history service + +on: + pull_request: + types: [reopened] + +permissions: + contents: write + +jobs: + patch: + if: github.event.pull_request.number == 87 && github.head_ref == 'fix/preserve-workspace-alias-domain' + runs-on: ubuntu-latest + steps: + - name: Checkout feature branch + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24.x + + - name: Apply inline history service patch + run: python3 scripts/patch-inline-history-service.py + + - name: Validate patched source + shell: bash + run: | + set -euo pipefail + corepack enable + yarn install --immutable --mode=skip-build + yarn exec wxt prepare + yarn compile --pretty false + yarn test + + - name: Commit patch and remove temporary files + shell: bash + run: | + set -euo pipefail + git rm \ + .github/workflows/apply-inline-history-service.yml \ + scripts/patch-inline-history-service.py + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add entrypoints/content/index.ts + git commit -m "refactor: use inline history service" + git push origin HEAD:${{ github.head_ref }} From 301e063d1c63e9b039026cc3837073ee7df69370 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:07:59 +0000 Subject: [PATCH 14/19] refactor: use inline history service --- .../apply-inline-history-service.yml | 50 ---------- entrypoints/content/index.ts | 49 +-------- scripts/patch-inline-history-service.py | 99 ------------------- 3 files changed, 4 insertions(+), 194 deletions(-) delete mode 100644 .github/workflows/apply-inline-history-service.yml delete mode 100644 scripts/patch-inline-history-service.py diff --git a/.github/workflows/apply-inline-history-service.yml b/.github/workflows/apply-inline-history-service.yml deleted file mode 100644 index 438f2b58..00000000 --- a/.github/workflows/apply-inline-history-service.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Apply inline history service - -on: - pull_request: - types: [reopened] - -permissions: - contents: write - -jobs: - patch: - if: github.event.pull_request.number == 87 && github.head_ref == 'fix/preserve-workspace-alias-domain' - runs-on: ubuntu-latest - steps: - - name: Checkout feature branch - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 24.x - - - name: Apply inline history service patch - run: python3 scripts/patch-inline-history-service.py - - - name: Validate patched source - shell: bash - run: | - set -euo pipefail - corepack enable - yarn install --immutable --mode=skip-build - yarn exec wxt prepare - yarn compile --pretty false - yarn test - - - name: Commit patch and remove temporary files - shell: bash - run: | - set -euo pipefail - git rm \ - .github/workflows/apply-inline-history-service.yml \ - scripts/patch-inline-history-service.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add entrypoints/content/index.ts - git commit -m "refactor: use inline history service" - git push origin HEAD:${{ github.head_ref }} diff --git a/entrypoints/content/index.ts b/entrypoints/content/index.ts index 65667260..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, @@ -15,7 +16,6 @@ import { generateAlias, generateRandomString, filterAliases, - filterAliasesForAccount, getAccountStorageKey, getAliasTags, getDotVariationCandidates, @@ -711,50 +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; - }>; - const validHistory = history.filter( - (item) => item && typeof item.email === "string", - ); - currentHistory = filterAliasesForAccount( - validHistory, - data.activeEmail, - ) - .slice() - .sort((a, b) => b.timestamp - a.timestamp); - const favorites = (storage[favoritesKey] ?? storage.favorites ?? []) as - | Array<{ email?: string } | string> - | undefined; - const favoriteEntries = Array.isArray(favorites) - ? favorites - .map((favorite) => ({ - email: - typeof favorite === "string" ? favorite : favorite.email || "", - })) - .filter((favorite) => Boolean(favorite.email)) - : []; - currentFavorites = filterAliasesForAccount( - favoriteEntries, - data.activeEmail, - ).map((favorite) => favorite.email); + const accountHistory = await loadInlineAccountHistory(data.activeEmail); + currentHistory = accountHistory.history; + currentFavorites = accountHistory.favorites; if (historyTag) { const selectedTag = historyTag.value; diff --git a/scripts/patch-inline-history-service.py b/scripts/patch-inline-history-service.py deleted file mode 100644 index d02ca1ec..00000000 --- a/scripts/patch-inline-history-service.py +++ /dev/null @@ -1,99 +0,0 @@ -from pathlib import Path - - -def replace_once(path: str, old: str, new: str) -> None: - """Replace one expected source fragment and fail when the branch has drifted.""" - file_path = Path(path) - content = file_path.read_text(encoding="utf-8") - count = content.count(old) - if count != 1: - raise RuntimeError(f"Expected exactly one match in {path}, found {count}") - file_path.write_text(content.replace(old, new, 1), encoding="utf-8") - - -replace_once( - "entrypoints/content/index.ts", - '''import { - getPreviousAliasForWebsite, - generateSuggestionsForWebsite, - saveWebsiteAlias, -} from "src/services/websiteAliasService"; -''', - '''import { loadInlineAccountHistory } from "src/services/inlineHistoryService"; -import { - getPreviousAliasForWebsite, - generateSuggestionsForWebsite, - saveWebsiteAlias, -} from "src/services/websiteAliasService"; -''', -) - -replace_once( - "entrypoints/content/index.ts", - ''' filterAliases, - filterAliasesForAccount, - getAccountStorageKey, -''', - ''' filterAliases, - getAccountStorageKey, -''', -) - -replace_once( - "entrypoints/content/index.ts", - ''' // 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; - }>; - const validHistory = history.filter( - (item) => item && typeof item.email === "string", - ); - currentHistory = filterAliasesForAccount( - validHistory, - data.activeEmail, - ) - .slice() - .sort((a, b) => b.timestamp - a.timestamp); - const favorites = (storage[favoritesKey] ?? storage.favorites ?? []) as - | Array<{ email?: string } | string> - | undefined; - const favoriteEntries = Array.isArray(favorites) - ? favorites - .map((favorite) => ({ - email: - typeof favorite === "string" ? favorite : favorite.email || "", - })) - .filter((favorite) => Boolean(favorite.email)) - : []; - currentFavorites = filterAliasesForAccount( - favoriteEntries, - data.activeEmail, - ).map((favorite) => favorite.email); -''', - ''' const accountHistory = await loadInlineAccountHistory(data.activeEmail); - currentHistory = accountHistory.history; - currentFavorites = accountHistory.favorites; -''', -) - -content = Path("entrypoints/content/index.ts").read_text(encoding="utf-8") -if "filterAliasesForAccount" in content: - raise RuntimeError("Stale inline account-filter import remains") -if "loadInlineAccountHistory(data.activeEmail)" not in content: - raise RuntimeError("Inline history service was not connected") From c1248e586876da45c3941c8051273c2af0042bdb Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 11:12:20 +0700 Subject: [PATCH 15/19] test: cover malformed inline history storage --- tests/services/inlineHistoryService.test.ts | 52 +++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/services/inlineHistoryService.test.ts b/tests/services/inlineHistoryService.test.ts index 1fa9d576..73c91453 100644 --- a/tests/services/inlineHistoryService.test.ts +++ b/tests/services/inlineHistoryService.test.ts @@ -111,6 +111,53 @@ async function assertScopedStorageTakesPrecedence(): Promise { 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", + ]); +} + /** Registers browser-storage integration coverage for inline account history. */ function defineInlineHistoryServiceTests(): void { beforeEach(resetMockStorage); @@ -126,6 +173,11 @@ function defineInlineHistoryServiceTests(): void { "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, + ); } describe("loadInlineAccountHistory", defineInlineHistoryServiceTests); From 5a817883f675d31c47c6090709cb0b275ceaf24e Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 11:12:45 +0700 Subject: [PATCH 16/19] chore: add one-shot mock return type patch --- scripts/patch-popup-utils-mock-return.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 scripts/patch-popup-utils-mock-return.py diff --git a/scripts/patch-popup-utils-mock-return.py b/scripts/patch-popup-utils-mock-return.py new file mode 100644 index 00000000..c1b7199b --- /dev/null +++ b/scripts/patch-popup-utils-mock-return.py @@ -0,0 +1,13 @@ +from pathlib import Path + +path = Path("tests/services/websiteAliasService.test.ts") +content = path.read_text(encoding="utf-8") +old = """async function createPopupUtilsMock(importOriginal: ImportOriginal) { +""" +new = """async function createPopupUtilsMock( + importOriginal: ImportOriginal, +): Promise { +""" +if content.count(old) != 1: + raise RuntimeError("Expected popup utility mock signature was not found exactly once") +path.write_text(content.replace(old, new, 1), encoding="utf-8") From aebca333e3c6b123b2803d52dade4f7d861480f5 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 11:13:01 +0700 Subject: [PATCH 17/19] chore: add one-shot mock return type workflow --- .../apply-popup-utils-mock-return.yml | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/apply-popup-utils-mock-return.yml diff --git a/.github/workflows/apply-popup-utils-mock-return.yml b/.github/workflows/apply-popup-utils-mock-return.yml new file mode 100644 index 00000000..f3ec3ead --- /dev/null +++ b/.github/workflows/apply-popup-utils-mock-return.yml @@ -0,0 +1,50 @@ +name: Apply popup utils mock return type + +on: + pull_request: + types: [reopened] + +permissions: + contents: write + +jobs: + patch: + if: github.event.pull_request.number == 87 && github.head_ref == 'fix/preserve-workspace-alias-domain' + runs-on: ubuntu-latest + steps: + - name: Checkout feature branch + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24.x + + - name: Apply explicit return type patch + run: python3 scripts/patch-popup-utils-mock-return.py + + - name: Validate patched source + shell: bash + run: | + set -euo pipefail + corepack enable + yarn install --immutable --mode=skip-build + yarn exec wxt prepare + yarn compile --pretty false + yarn test + + - name: Commit patch and remove temporary files + shell: bash + run: | + set -euo pipefail + git rm \ + .github/workflows/apply-popup-utils-mock-return.yml \ + scripts/patch-popup-utils-mock-return.py + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add tests/services/websiteAliasService.test.ts + git commit -m "test: type popup utility mock factory" + git push origin HEAD:${{ github.head_ref }} From b70a116ffb6f4afdd6e6e32c3b9e501def3f6bc8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:14:06 +0000 Subject: [PATCH 18/19] test: type popup utility mock factory --- .../apply-popup-utils-mock-return.yml | 50 ------------------- scripts/patch-popup-utils-mock-return.py | 13 ----- tests/services/websiteAliasService.test.ts | 4 +- 3 files changed, 3 insertions(+), 64 deletions(-) delete mode 100644 .github/workflows/apply-popup-utils-mock-return.yml delete mode 100644 scripts/patch-popup-utils-mock-return.py diff --git a/.github/workflows/apply-popup-utils-mock-return.yml b/.github/workflows/apply-popup-utils-mock-return.yml deleted file mode 100644 index f3ec3ead..00000000 --- a/.github/workflows/apply-popup-utils-mock-return.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Apply popup utils mock return type - -on: - pull_request: - types: [reopened] - -permissions: - contents: write - -jobs: - patch: - if: github.event.pull_request.number == 87 && github.head_ref == 'fix/preserve-workspace-alias-domain' - runs-on: ubuntu-latest - steps: - - name: Checkout feature branch - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 24.x - - - name: Apply explicit return type patch - run: python3 scripts/patch-popup-utils-mock-return.py - - - name: Validate patched source - shell: bash - run: | - set -euo pipefail - corepack enable - yarn install --immutable --mode=skip-build - yarn exec wxt prepare - yarn compile --pretty false - yarn test - - - name: Commit patch and remove temporary files - shell: bash - run: | - set -euo pipefail - git rm \ - .github/workflows/apply-popup-utils-mock-return.yml \ - scripts/patch-popup-utils-mock-return.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add tests/services/websiteAliasService.test.ts - git commit -m "test: type popup utility mock factory" - git push origin HEAD:${{ github.head_ref }} diff --git a/scripts/patch-popup-utils-mock-return.py b/scripts/patch-popup-utils-mock-return.py deleted file mode 100644 index c1b7199b..00000000 --- a/scripts/patch-popup-utils-mock-return.py +++ /dev/null @@ -1,13 +0,0 @@ -from pathlib import Path - -path = Path("tests/services/websiteAliasService.test.ts") -content = path.read_text(encoding="utf-8") -old = """async function createPopupUtilsMock(importOriginal: ImportOriginal) { -""" -new = """async function createPopupUtilsMock( - importOriginal: ImportOriginal, -): Promise { -""" -if content.count(old) != 1: - raise RuntimeError("Expected popup utility mock signature was not found exactly once") -path.write_text(content.replace(old, new, 1), encoding="utf-8") diff --git a/tests/services/websiteAliasService.test.ts b/tests/services/websiteAliasService.test.ts index 6e8c0791..17ffb239 100644 --- a/tests/services/websiteAliasService.test.ts +++ b/tests/services/websiteAliasService.test.ts @@ -55,7 +55,9 @@ function getMockAccountStorageKey(email: string, suffix: string): string { } /** Keeps real alias utilities while replacing account storage key generation. */ -async function createPopupUtilsMock(importOriginal: ImportOriginal) { +async function createPopupUtilsMock( + importOriginal: ImportOriginal, +): Promise { const original = await importOriginal< typeof import("../../entrypoints/popup/utils") >(); From 7c71b38ab895f39f7e108509ac8bcce3673ebbae Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 11:17:14 +0700 Subject: [PATCH 19/19] test: cover inline history storage failures --- tests/services/inlineHistoryService.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/services/inlineHistoryService.test.ts b/tests/services/inlineHistoryService.test.ts index 73c91453..2b70739e 100644 --- a/tests/services/inlineHistoryService.test.ts +++ b/tests/services/inlineHistoryService.test.ts @@ -13,10 +13,12 @@ function mockStorageGet(keys: string[]): Promise> { return Promise.resolve(result); } +const mockStorageGetFunction = vi.fn(mockStorageGet); + vi.stubGlobal("browser", { storage: { local: { - get: vi.fn(mockStorageGet), + get: mockStorageGetFunction, }, }, }); @@ -158,6 +160,17 @@ async function assertMalformedEntriesAreIgnored(): Promise { ]); } +/** 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); @@ -178,6 +191,7 @@ function defineInlineHistoryServiceTests(): void { "ignores malformed history and favorite entries", assertMalformedEntriesAreIgnored, ); + it("propagates browser storage failures", assertStorageFailurePropagates); } describe("loadInlineAccountHistory", defineInlineHistoryServiceTests);