feat(browser-ext): move browser extension to browser-ext/ and update API client to tRPC

- Create browser-ext/ with full extension code (MV3 manifest, background
  service worker, content script, popup, options page)
- Add tRPC API client that communicates with unified monolith endpoints
- Implement cache, settings, and phishing detection utilities
- Create extension tRPC router in web app (getAuthStatus, linkDevice,
  reportPhishing)
- Configure Vite build with manifest V3 support
- Write unit tests for cache, phishing detector, and API client
- All 20 tests passing, TypeScript lint clean
This commit is contained in:
2026-05-25 18:13:44 -04:00
parent 20dc5bf785
commit b03096f19d
30 changed files with 1474 additions and 5 deletions

View File

@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createApiClient } from "../src/lib/api-client";
vi.mock("@trpc/client", () => ({
createTRPCProxyClient: vi.fn(() => ({
spamshield: {
checkNumber: {
query: vi.fn().mockResolvedValue({
isSpam: true,
score: 0.85,
category: "scam",
}),
},
classifySMS: {
query: vi.fn().mockResolvedValue({
isSpam: false,
score: 0.1,
category: "legitimate",
}),
},
},
extension: {
getAuthStatus: {
query: vi.fn().mockResolvedValue({ linked: false }),
},
linkDevice: {
mutate: vi.fn().mockResolvedValue({
linked: true,
deviceId: "dev-123",
}),
},
reportPhishing: {
mutate: vi.fn().mockResolvedValue({
reported: true,
url: "https://phishing.com",
}),
},
},
})),
httpBatchLink: vi.fn(),
}));
describe("createApiClient", () => {
let client: ReturnType<typeof createApiClient>;
beforeEach(() => {
client = createApiClient("https://api.shieldai.com/api/trpc", "test-key");
});
it("should create a client", () => {
expect(client).toBeDefined();
});
it("should check phone number reputation", async () => {
const result = await client.spamshield.checkNumber.query({
phoneNumber: "+1234567890",
});
expect(result).toBeDefined();
expect(result.isSpam).toBe(true);
expect(result.score).toBe(0.85);
});
it("should classify SMS text", async () => {
const result = await client.spamshield.classifySMS.query({
text: "Hello, this is a test message",
});
expect(result).toBeDefined();
expect(result.isSpam).toBe(false);
});
it("should get auth status", async () => {
const result = await client.extension.getAuthStatus.query();
expect(result).toEqual({ linked: false });
});
it("should link device", async () => {
const result = await client.extension.linkDevice.mutate({
extensionId: "ext-123",
});
expect(result.linked).toBe(true);
expect(result.deviceId).toBe("dev-123");
});
it("should report phishing", async () => {
const result = await client.extension.reportPhishing.mutate({
url: "https://phishing.com",
});
expect(result.reported).toBe(true);
});
});

View File

@@ -0,0 +1,53 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Cache } from "../src/lib/cache";
describe("Cache", () => {
let cache: Cache<string>;
beforeEach(() => {
vi.useFakeTimers();
cache = new Cache(1000);
});
it("should store and retrieve values", () => {
cache.set("key1", "value1");
expect(cache.get("key1")).toBe("value1");
});
it("should return null for missing keys", () => {
expect(cache.get("nonexistent")).toBeNull();
});
it("should expire entries after TTL", () => {
cache.set("key1", "value1");
vi.advanceTimersByTime(1500);
expect(cache.get("key1")).toBeNull();
});
it("should not expire entries before TTL", () => {
cache.set("key1", "value1");
vi.advanceTimersByTime(500);
expect(cache.get("key1")).toBe("value1");
});
it("should delete entries", () => {
cache.set("key1", "value1");
cache.delete("key1");
expect(cache.get("key1")).toBeNull();
});
it("should clear all entries", () => {
cache.set("key1", "value1");
cache.set("key2", "value2");
cache.clear();
expect(cache.get("key1")).toBeNull();
expect(cache.get("key2")).toBeNull();
});
it("should not return expired entries when they are cleaned up on get", () => {
cache.set("key1", "value1");
vi.advanceTimersByTime(1001);
const result = cache.get("key1");
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from "vitest";
import { checkUrlForPhishing } from "../src/lib/phishing-detector";
describe("checkUrlForPhishing", () => {
it("should return isPhishing: false for a normal URL", () => {
const result = checkUrlForPhishing("https://www.google.com");
expect(result.isPhishing).toBe(false);
expect(result.confidence).toBe(0);
expect(result.reasons).toHaveLength(0);
});
it("should detect known phishing domains", () => {
const result = checkUrlForPhishing("https://shieldai-secure.com/login");
expect(result.isPhishing).toBe(true);
expect(result.reasons).toContain("Known phishing domain: shieldai-secure.com");
});
it("should detect suspicious URL patterns", () => {
const result = checkUrlForPhishing("https://login-secure.example.com");
expect(result.isPhishing).toBe(true);
expect(result.reasons.length).toBeGreaterThan(0);
});
it("should detect phishing with multiple signals for high confidence", () => {
const result = checkUrlForPhishing(
"https://shieldai-verify.com/account-update/verify",
);
expect(result.isPhishing).toBe(true);
expect(result.confidence).toBeGreaterThan(0.5);
expect(result.reasons.length).toBeGreaterThanOrEqual(1);
});
it("should handle invalid URLs gracefully", () => {
const result = checkUrlForPhishing("not-a-valid-url");
expect(result.isPhishing).toBe(true);
expect(result.reasons).toContain("Invalid URL format");
});
it("should detect phishing with subdomain of known domain", () => {
const result = checkUrlForPhishing("https://login.shieldai-secure.com");
expect(result.isPhishing).toBe(true);
});
it("should not flag legitimate shieldai.com URLs", () => {
const result = checkUrlForPhishing("https://api.shieldai.com/api/trpc");
expect(result.isPhishing).toBe(false);
});
});

View File

@@ -0,0 +1,40 @@
import { vi } from "vitest";
const mockStorage: Record<string, unknown> = {};
vi.stubGlobal("chrome", {
runtime: {
id: "test-extension-id",
onInstalled: {
addListener: vi.fn(),
},
onMessage: {
addListener: vi.fn(),
},
sendMessage: vi.fn(),
openOptionsPage: vi.fn(),
},
storage: {
sync: {
get: vi.fn((keys: string | string[] | Record<string, unknown>) => {
if (typeof keys === "string") {
return Promise.resolve({ [keys]: mockStorage[keys] ?? null });
}
if (Array.isArray(keys)) {
const result: Record<string, unknown> = {};
for (const key of keys) result[key] = mockStorage[key] ?? null;
return Promise.resolve(result);
}
return Promise.resolve({});
}),
set: vi.fn((items: Record<string, unknown>) => {
Object.assign(mockStorage, items);
return Promise.resolve();
}),
},
local: {
get: vi.fn(),
set: vi.fn(),
},
},
});