generated from coulomb/repo-seed
Release 0.1: Complete BinectChrome implementation
Implements all requirements from ProductRequirementsDocument.md: - PDF detection via Chrome Downloads API - Secure credential storage with AES-GCM encryption - Binect API integration for PDF uploads - Popup UI with Binect branding - Local transfer tracking (500 entry cap) - Help page with tracking view and CSV export - 60-day credential retention with auto-expiry - Accessibility compliance (WCAG 2.1 AA) Technical implementation: - Chrome Extension Manifest V3 - TypeScript with strict mode - Webpack build system - Jest test suite (22/22 passing) - ESLint configured (0 errors) Build output: 13 KB total (production minified) Test coverage: crypto, pdf-detector, tracker, binect-api Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
116
tests/binect-api.test.ts
Normal file
116
tests/binect-api.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Tests for Binect API client
|
||||
*/
|
||||
|
||||
import { authenticate, uploadPDF, BinectAPIError } from '../src/utils/binect-api';
|
||||
|
||||
describe('Binect API', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('authenticate', () => {
|
||||
test('should authenticate successfully', async () => {
|
||||
const mockResponse = {
|
||||
token: 'test-token',
|
||||
expiresAt: '2024-12-31T23:59:59Z'
|
||||
};
|
||||
|
||||
(fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse
|
||||
});
|
||||
|
||||
const result = await authenticate('user', 'pass');
|
||||
expect(result.token).toBe('test-token');
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://api.binect.de/auth/login',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'user', password: 'pass' })
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw on invalid credentials', async () => {
|
||||
(fetch as jest.Mock).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized'
|
||||
});
|
||||
|
||||
await expect(authenticate('user', 'wrong')).rejects.toThrow(
|
||||
BinectAPIError
|
||||
);
|
||||
await expect(authenticate('user', 'wrong')).rejects.toThrow(
|
||||
'Invalid credentials'
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle network errors', async () => {
|
||||
(fetch as jest.Mock).mockRejectedValue(new Error('Network failure'));
|
||||
|
||||
await expect(authenticate('user', 'pass')).rejects.toThrow(
|
||||
BinectAPIError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadPDF', () => {
|
||||
test('should upload PDF successfully', async () => {
|
||||
const mockResponse = {
|
||||
documentId: 'doc-123',
|
||||
status: 'received',
|
||||
uploadedAt: '2024-01-01T00:00:00Z'
|
||||
};
|
||||
|
||||
(fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse
|
||||
});
|
||||
|
||||
const pdfData = new ArrayBuffer(1024);
|
||||
const result = await uploadPDF(pdfData, 'test.pdf', 'token-123');
|
||||
|
||||
expect(result.documentId).toBe('doc-123');
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://api.binect.de/documents/upload',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer token-123'
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw on authentication failure', async () => {
|
||||
(fetch as jest.Mock).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
json: async () => ({ error: 'Invalid token' })
|
||||
});
|
||||
|
||||
const pdfData = new ArrayBuffer(1024);
|
||||
await expect(uploadPDF(pdfData, 'test.pdf', 'bad-token')).rejects.toThrow(
|
||||
BinectAPIError
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw on file size exceeded', async () => {
|
||||
(fetch as jest.Mock).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 413,
|
||||
statusText: 'Payload Too Large',
|
||||
json: async () => ({ error: 'File too large' })
|
||||
});
|
||||
|
||||
const pdfData = new ArrayBuffer(10 * 1024 * 1024); // 10MB
|
||||
await expect(uploadPDF(pdfData, 'test.pdf', 'token')).rejects.toThrow(
|
||||
'File size exceeds limit'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
64
tests/crypto.test.ts
Normal file
64
tests/crypto.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Tests for cryptographic utilities
|
||||
*/
|
||||
|
||||
import { generateEncryptionKey, exportKey, importKey, encrypt, decrypt } from '../src/utils/crypto';
|
||||
|
||||
describe('Crypto utilities', () => {
|
||||
test('should generate encryption key', async () => {
|
||||
const key = await generateEncryptionKey();
|
||||
expect(key).toBeDefined();
|
||||
expect(key.type).toBe('secret');
|
||||
});
|
||||
|
||||
test('should export and import key', async () => {
|
||||
const key = await generateEncryptionKey();
|
||||
const exported = await exportKey(key);
|
||||
expect(typeof exported).toBe('string');
|
||||
expect(exported.length).toBeGreaterThan(0);
|
||||
|
||||
const imported = await importKey(exported);
|
||||
expect(imported).toBeDefined();
|
||||
expect(imported.type).toBe('secret');
|
||||
});
|
||||
|
||||
test('should encrypt and decrypt data', async () => {
|
||||
const key = await generateEncryptionKey();
|
||||
const plaintext = 'test data';
|
||||
|
||||
const encrypted = await encrypt(plaintext, key);
|
||||
expect(encrypted.ciphertext).toBeDefined();
|
||||
expect(encrypted.iv).toBeDefined();
|
||||
expect(encrypted.ciphertext).not.toBe(plaintext);
|
||||
|
||||
const decrypted = await decrypt(encrypted, key);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
test('should encrypt same data differently each time (different IV)', async () => {
|
||||
const key = await generateEncryptionKey();
|
||||
const plaintext = 'test data';
|
||||
|
||||
const encrypted1 = await encrypt(plaintext, key);
|
||||
const encrypted2 = await encrypt(plaintext, key);
|
||||
|
||||
expect(encrypted1.ciphertext).not.toBe(encrypted2.ciphertext);
|
||||
expect(encrypted1.iv).not.toBe(encrypted2.iv);
|
||||
|
||||
const decrypted1 = await decrypt(encrypted1, key);
|
||||
const decrypted2 = await decrypt(encrypted2, key);
|
||||
|
||||
expect(decrypted1).toBe(plaintext);
|
||||
expect(decrypted2).toBe(plaintext);
|
||||
});
|
||||
|
||||
test('should fail to decrypt with wrong key', async () => {
|
||||
const key1 = await generateEncryptionKey();
|
||||
const key2 = await generateEncryptionKey();
|
||||
const plaintext = 'test data';
|
||||
|
||||
const encrypted = await encrypt(plaintext, key1);
|
||||
|
||||
await expect(decrypt(encrypted, key2)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
103
tests/pdf-detector.test.ts
Normal file
103
tests/pdf-detector.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Tests for PDF detection
|
||||
*/
|
||||
|
||||
import { getLastPDFDownload, fetchPDFBytes } from '../src/utils/pdf-detector';
|
||||
|
||||
// Chrome API is mocked in setup.ts
|
||||
|
||||
describe('PDF Detector', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should detect PDF by extension', async () => {
|
||||
const mockItems = [
|
||||
{
|
||||
id: 1,
|
||||
filename: 'document.pdf',
|
||||
url: 'https://example.com/doc.pdf',
|
||||
fileSize: 1024,
|
||||
state: 'complete',
|
||||
mime: 'application/pdf'
|
||||
}
|
||||
];
|
||||
|
||||
(chrome.downloads.search as jest.Mock).mockImplementation((query, callback) => {
|
||||
callback(mockItems);
|
||||
});
|
||||
|
||||
const pdf = await getLastPDFDownload();
|
||||
expect(pdf).toBeDefined();
|
||||
expect(pdf?.filename).toBe('document.pdf');
|
||||
expect(pdf?.sourceDomain).toBe('example.com');
|
||||
});
|
||||
|
||||
test('should return null when no PDF found', async () => {
|
||||
const mockItems = [
|
||||
{
|
||||
id: 1,
|
||||
filename: 'document.txt',
|
||||
url: 'https://example.com/doc.txt',
|
||||
fileSize: 1024,
|
||||
state: 'complete',
|
||||
mime: 'text/plain'
|
||||
}
|
||||
];
|
||||
|
||||
(chrome.downloads.search as jest.Mock).mockImplementation((query, callback) => {
|
||||
callback(mockItems);
|
||||
});
|
||||
|
||||
const pdf = await getLastPDFDownload();
|
||||
expect(pdf).toBeNull();
|
||||
});
|
||||
|
||||
test('should detect PDF by MIME type even without .pdf extension', async () => {
|
||||
const mockItems = [
|
||||
{
|
||||
id: 1,
|
||||
filename: 'document',
|
||||
url: 'https://example.com/doc',
|
||||
fileSize: 1024,
|
||||
state: 'complete',
|
||||
mime: 'application/pdf'
|
||||
}
|
||||
];
|
||||
|
||||
(chrome.downloads.search as jest.Mock).mockImplementation((query, callback) => {
|
||||
callback(mockItems);
|
||||
});
|
||||
|
||||
const pdf = await getLastPDFDownload();
|
||||
expect(pdf).toBeDefined();
|
||||
expect(pdf?.filename).toBe('document');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchPDFBytes', () => {
|
||||
test('should throw error on non-200 response', async () => {
|
||||
(fetch as jest.Mock).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found'
|
||||
});
|
||||
|
||||
await expect(fetchPDFBytes('https://example.com/doc.pdf')).rejects.toThrow(
|
||||
'Failed to fetch PDF: 404 Not Found'
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw error on non-PDF content type', async () => {
|
||||
(fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
headers: {
|
||||
get: (name: string) => (name === 'Content-Type' ? 'text/html' : null)
|
||||
}
|
||||
});
|
||||
|
||||
await expect(fetchPDFBytes('https://example.com/doc.pdf')).rejects.toThrow(
|
||||
'URL did not return a PDF'
|
||||
);
|
||||
});
|
||||
});
|
||||
93
tests/setup.ts
Normal file
93
tests/setup.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Jest test setup
|
||||
* Mocks browser APIs
|
||||
*/
|
||||
|
||||
import { webcrypto } from 'crypto';
|
||||
|
||||
// Mock Web Crypto API
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
value: webcrypto
|
||||
});
|
||||
|
||||
// Mock Chrome API
|
||||
const mockChrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
clear: jest.fn()
|
||||
}
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: jest.fn(),
|
||||
onMessage: {
|
||||
addListener: jest.fn()
|
||||
},
|
||||
onInstalled: {
|
||||
addListener: jest.fn()
|
||||
},
|
||||
onStartup: {
|
||||
addListener: jest.fn()
|
||||
},
|
||||
getURL: jest.fn((path) => `chrome-extension://test/${path}`)
|
||||
},
|
||||
downloads: {
|
||||
search: jest.fn(),
|
||||
onChanged: {
|
||||
addListener: jest.fn()
|
||||
}
|
||||
},
|
||||
action: {
|
||||
setBadgeText: jest.fn(),
|
||||
setBadgeBackgroundColor: jest.fn()
|
||||
},
|
||||
alarms: {
|
||||
create: jest.fn(),
|
||||
onAlarm: {
|
||||
addListener: jest.fn()
|
||||
}
|
||||
},
|
||||
tabs: {
|
||||
create: jest.fn(),
|
||||
onUpdated: {
|
||||
addListener: jest.fn()
|
||||
},
|
||||
query: jest.fn()
|
||||
}
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, 'chrome', {
|
||||
value: mockChrome,
|
||||
writable: true
|
||||
});
|
||||
|
||||
// Mock fetch
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
value: jest.fn(),
|
||||
writable: true
|
||||
});
|
||||
|
||||
// Mock btoa/atob
|
||||
Object.defineProperty(globalThis, 'btoa', {
|
||||
value: (str: string) => Buffer.from(str, 'binary').toString('base64'),
|
||||
writable: true
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis, 'atob', {
|
||||
value: (str: string) => Buffer.from(str, 'base64').toString('binary'),
|
||||
writable: true
|
||||
});
|
||||
|
||||
// Mock TextEncoder/TextDecoder (from util)
|
||||
import { TextEncoder, TextDecoder } from 'util';
|
||||
Object.defineProperty(globalThis, 'TextEncoder', {
|
||||
value: TextEncoder,
|
||||
writable: true
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis, 'TextDecoder', {
|
||||
value: TextDecoder,
|
||||
writable: true
|
||||
});
|
||||
153
tests/tracker.test.ts
Normal file
153
tests/tracker.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Tests for tracking system
|
||||
*/
|
||||
|
||||
import {
|
||||
addTrackingEntry,
|
||||
getAllEntries,
|
||||
getTrackingSummary,
|
||||
clearTracking,
|
||||
exportAsCSV
|
||||
} from '../src/tracking/tracker';
|
||||
|
||||
// Mock chrome storage
|
||||
const mockStorage: { [key: string]: any } = {};
|
||||
|
||||
// Setup chrome storage mocks
|
||||
(chrome.storage.local.get as jest.Mock).mockImplementation((key) => {
|
||||
return Promise.resolve({ [key]: mockStorage[key] });
|
||||
});
|
||||
|
||||
(chrome.storage.local.set as jest.Mock).mockImplementation((data) => {
|
||||
Object.assign(mockStorage, data);
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
(chrome.storage.local.remove as jest.Mock).mockImplementation((key) => {
|
||||
delete mockStorage[key];
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
describe('Tracking system', () => {
|
||||
beforeEach(() => {
|
||||
// Clear mock storage
|
||||
Object.keys(mockStorage).forEach((key) => delete mockStorage[key]);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should add tracking entry', async () => {
|
||||
await addTrackingEntry({
|
||||
timestamp: Date.now(),
|
||||
sourceDomain: 'example.com',
|
||||
destinationUrl: 'https://api.binect.de/upload',
|
||||
pdfSize: 1024,
|
||||
result: 'success'
|
||||
});
|
||||
|
||||
const entries = await getAllEntries();
|
||||
expect(entries.length).toBe(1);
|
||||
expect(entries[0].sourceDomain).toBe('example.com');
|
||||
expect(entries[0].result).toBe('success');
|
||||
});
|
||||
|
||||
test('should maintain max entries limit', async () => {
|
||||
// Add 501 entries
|
||||
for (let i = 0; i < 501; i++) {
|
||||
await addTrackingEntry({
|
||||
timestamp: Date.now(),
|
||||
sourceDomain: `example${i}.com`,
|
||||
destinationUrl: 'https://api.binect.de/upload',
|
||||
pdfSize: 1024,
|
||||
result: 'success'
|
||||
});
|
||||
}
|
||||
|
||||
const entries = await getAllEntries();
|
||||
expect(entries.length).toBe(500); // Should be capped at 500
|
||||
expect(entries[0].sourceDomain).toBe('example500.com'); // Most recent first
|
||||
});
|
||||
|
||||
test('should calculate tracking summary correctly', async () => {
|
||||
await addTrackingEntry({
|
||||
timestamp: Date.now(),
|
||||
sourceDomain: 'example.com',
|
||||
destinationUrl: 'https://api.binect.de/upload',
|
||||
pdfSize: 1024,
|
||||
result: 'success'
|
||||
});
|
||||
|
||||
await addTrackingEntry({
|
||||
timestamp: Date.now(),
|
||||
sourceDomain: 'example2.com',
|
||||
destinationUrl: 'https://api.binect.de/upload',
|
||||
pdfSize: 2048,
|
||||
result: 'failure',
|
||||
errorMessage: 'Network error'
|
||||
});
|
||||
|
||||
const summary = await getTrackingSummary();
|
||||
expect(summary.totalTransfers).toBe(2);
|
||||
expect(summary.successfulTransfers).toBe(1);
|
||||
expect(summary.failedTransfers).toBe(1);
|
||||
expect(summary.lastTransferTime).toBeDefined();
|
||||
});
|
||||
|
||||
test('should export to CSV correctly', () => {
|
||||
const entries = [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: 1640000000000,
|
||||
sourceDomain: 'example.com',
|
||||
destinationUrl: 'https://api.binect.de/upload',
|
||||
pdfSize: 1024,
|
||||
result: 'success' as const
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
timestamp: 1640000001000,
|
||||
sourceDomain: 'test.com',
|
||||
destinationUrl: 'https://api.binect.de/upload',
|
||||
pdfSize: 2048,
|
||||
result: 'failure' as const,
|
||||
errorMessage: 'Network error'
|
||||
}
|
||||
];
|
||||
|
||||
const csv = exportAsCSV(entries);
|
||||
expect(csv).toContain('Timestamp,Source Domain,Destination URL');
|
||||
expect(csv).toContain('example.com');
|
||||
expect(csv).toContain('test.com');
|
||||
expect(csv).toContain('Network error');
|
||||
});
|
||||
|
||||
test('should handle CSV escaping', () => {
|
||||
const entries = [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: 1640000000000,
|
||||
sourceDomain: 'example,with,commas.com',
|
||||
destinationUrl: 'https://api.binect.de/upload',
|
||||
pdfSize: 1024,
|
||||
result: 'success' as const
|
||||
}
|
||||
];
|
||||
|
||||
const csv = exportAsCSV(entries);
|
||||
expect(csv).toContain('"example,with,commas.com"');
|
||||
});
|
||||
|
||||
test('should clear tracking data', async () => {
|
||||
await addTrackingEntry({
|
||||
timestamp: Date.now(),
|
||||
sourceDomain: 'example.com',
|
||||
destinationUrl: 'https://api.binect.de/upload',
|
||||
pdfSize: 1024,
|
||||
result: 'success'
|
||||
});
|
||||
|
||||
await clearTracking();
|
||||
|
||||
const entries = await getAllEntries();
|
||||
expect(entries.length).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user