Fix base64 encoding for browser environment

The bufferToBase64 function from @binect/js expects Node.js Buffer
objects but was receiving browser ArrayBuffer, causing "[object ArrayBuffer]"
to be sent instead of valid base64. Use browser-native btoa() instead.

Also updates tests to work with @binect/js integration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 14:41:44 +01:00
parent be4377253e
commit 5bde27dcdd
6 changed files with 377 additions and 218 deletions

View File

@@ -9,6 +9,9 @@ module.exports = {
'src/**/*.{ts,tsx}', 'src/**/*.{ts,tsx}',
'!src/**/*.d.ts' '!src/**/*.d.ts'
], ],
moduleNameMapper: {
'^@binect/js$': '<rootDir>/tests/__mocks__/@binect/js.ts'
},
globals: { globals: {
'ts-jest': { 'ts-jest': {
tsconfig: 'tsconfig.test.json' tsconfig: 'tsconfig.test.json'

20
package-lock.json generated
View File

@@ -8,6 +8,9 @@
"name": "binect-chrome", "name": "binect-chrome",
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": {
"@binect/js": "file:../binect-js"
},
"devDependencies": { "devDependencies": {
"@types/chrome": "^0.0.260", "@types/chrome": "^0.0.260",
"@types/jest": "^29.5.11", "@types/jest": "^29.5.11",
@@ -28,6 +31,19 @@
"webpack-cli": "^5.1.4" "webpack-cli": "^5.1.4"
} }
}, },
"../binect-js": {
"name": "@binect/js",
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^20.10.0",
"typescript": "^5.3.0",
"vitest": "^1.0.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
@@ -545,6 +561,10 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@binect/js": {
"resolved": "../binect-js",
"link": true
},
"node_modules/@discoveryjs/json-ext": { "node_modules/@discoveryjs/json-ext": {
"version": "0.5.7", "version": "0.5.7",
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",

View File

@@ -2,6 +2,9 @@
"name": "binect-chrome", "name": "binect-chrome",
"version": "1.0.0", "version": "1.0.0",
"description": "Chrome extension to send PDFs from cloud applications directly to Binect for physical mail delivery", "description": "Chrome extension to send PDFs from cloud applications directly to Binect for physical mail delivery",
"dependencies": {
"@binect/js": "file:../binect-js"
},
"scripts": { "scripts": {
"build": "webpack --mode production", "build": "webpack --mode production",
"dev": "webpack --mode development --watch", "dev": "webpack --mode development --watch",

View File

@@ -1,13 +1,30 @@
/** /**
* Binect API client * Binect API client
* Based on Binect API v1 (Swagger spec: specs/v1_swagger_api_kernel.json)
* *
* Authentication: HTTP Basic Authentication * This module wraps the @binect/js library to provide a simplified API
* Base path: /binectapi/v1 * for the Chrome extension. It delegates all API integration to the
* upstream library.
*/ */
const API_BASE_URL = 'https://api.binect.de/binectapi/v1'; import {
BinectClient,
BinectApiError,
BinectAuthError,
type Document as BinectDocument,
type DocumentUploadOptions,
EnvelopeType,
FrankingType,
} from '@binect/js';
// Re-export types for backward compatibility
export interface Options {
simplex?: boolean; // if false, it's duplex
color?: boolean; // if false, it's black and white
envelope?: 'DINLANG' | 'C4';
franking?: 'UNSPECIFIED' | 'STANDARD_FRANKING' | 'DV_FRANKING';
}
// Document type matching what popup.ts expects
export interface Document { export interface Document {
id: number; id: number;
filename: string; filename: string;
@@ -38,15 +55,10 @@ export interface Document {
}; };
} }
export interface Options { /**
simplex: boolean; // if false, it's duplex * Custom error class for Binect API errors
color: boolean; // if false, it's black and white * Wraps errors from @binect/js for backward compatibility
envelope?: 'DINLANG' | 'C4'; */
dvFranking?: boolean;
franking?: 'UNSPECIFIED' | 'STANDARD_FRANKING' | 'DV_FRANKING';
productionCountry?: 'UNSPECIFIED' | 'DE' | 'AT';
}
export class BinectAPIError extends Error { export class BinectAPIError extends Error {
constructor( constructor(
message: string, message: string,
@@ -56,19 +68,24 @@ export class BinectAPIError extends Error {
super(message); super(message);
this.name = 'BinectAPIError'; this.name = 'BinectAPIError';
} }
/**
* Create from a @binect/js error
*/
static fromBinectError(error: BinectApiError | BinectAuthError): BinectAPIError {
if (error instanceof BinectAuthError) {
return new BinectAPIError('Invalid credentials', 401);
}
return new BinectAPIError(
error.message,
error.status,
error.response
);
}
} }
/** /**
* Create HTTP Basic Authentication header value * Convert ArrayBuffer to base64 string (browser-compatible)
*/
function createBasicAuthHeader(username: string, password: string): string {
const credentials = `${username}:${password}`;
const base64Credentials = btoa(credentials);
return `Basic ${base64Credentials}`;
}
/**
* Convert ArrayBuffer to base64 string
*/ */
function arrayBufferToBase64(buffer: ArrayBuffer): string { function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer); const bytes = new Uint8Array(buffer);
@@ -79,11 +96,89 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string {
return btoa(binary); return btoa(binary);
} }
/**
* Map local Options to DocumentUploadOptions
*/
function mapOptions(options?: Options): Partial<DocumentUploadOptions> {
if (!options) {
return {
simplex: false, // duplex by default
color: false, // black and white by default
};
}
const mapped: Partial<DocumentUploadOptions> = {
simplex: options.simplex ?? false,
color: options.color ?? false,
};
if (options.envelope) {
mapped.envelope = options.envelope === 'C4' ? EnvelopeType.C4 : EnvelopeType.DINLANG;
}
if (options.franking) {
switch (options.franking) {
case 'STANDARD_FRANKING':
mapped.franking = FrankingType.STANDARD_FRANKING;
break;
case 'DV_FRANKING':
mapped.franking = FrankingType.DV_FRANKING;
break;
default:
mapped.franking = FrankingType.UNSPECIFIED;
}
}
return mapped;
}
/**
* Convert BinectDocument to our Document interface
*/
function mapDocument(doc: BinectDocument): Document {
// Build letter data if present
let letterData: Document['letter'] = undefined;
if (doc.letter) {
const ld = doc.letter.letterData;
letterData = {
letterType: (doc.letter.letterType || 'LetterData') as 'LetterData' | 'Error',
letterData: ld ? {
recipientAddress: ld.recipientAddress || '',
price: ld.price || { priceBeforeTax: 0, priceAfterTax: 0, unit: 'EUROCENT', taxInPercent: 0 },
international: ld.international || false,
options: {
simplex: ld.options?.simplex,
color: ld.options?.color,
envelope: ld.options?.envelope as 'DINLANG' | 'C4' | undefined,
franking: ld.options?.franking as 'UNSPECIFIED' | 'STANDARD_FRANKING' | 'DV_FRANKING' | undefined,
},
} : undefined,
errors: doc.letter.errors?.map(e => ({
code: parseInt(e.code, 10) || 0,
text: e.message,
blankText: e.message,
})),
};
}
return {
id: doc.id,
filename: doc.filename || 'document.pdf',
numberOfPages: doc.numberOfPages,
status: {
code: doc.status.code,
text: doc.status.text,
},
documentType: (doc.documentType === 'SERIALLETTER' ? 'SerialLetter' : 'Letter') as 'Letter' | 'SerialLetter',
letter: letterData,
};
}
/** /**
* Upload PDF to Binect API * Upload PDF to Binect API
* *
* Uses HTTP Basic Authentication and uploads the PDF as base64 encoded content * Uses the @binect/js library to upload the PDF.
* in a JSON request body according to the Binect API v1 specification.
* *
* @param pdfData - PDF file as ArrayBuffer * @param pdfData - PDF file as ArrayBuffer
* @param filename - Name of the PDF file * @param filename - Name of the PDF file
@@ -99,110 +194,90 @@ export async function uploadPDF(
password: string, password: string,
options?: Options options?: Options
): Promise<Document> { ): Promise<Document> {
console.log('[Binect API] Uploading PDF to Binect...'); console.log('[Binect API] Uploading PDF to Binect via @binect/js...');
console.log('[Binect API] URL:', `${API_BASE_URL}/documents`);
console.log('[Binect API] Filename:', filename); console.log('[Binect API] Filename:', filename);
console.log('[Binect API] PDF size:', pdfData.byteLength, 'bytes'); console.log('[Binect API] PDF size:', pdfData.byteLength, 'bytes');
console.log('[Binect API] Username:', username);
try { try {
// Create client with credentials
const client = new BinectClient({
username,
password,
});
// Convert PDF to base64 // Convert PDF to base64
console.log('[Binect API] Converting PDF to base64...'); console.log('[Binect API] Converting PDF to base64...');
const base64Content = arrayBufferToBase64(pdfData); const base64Content = arrayBufferToBase64(pdfData);
console.log('[Binect API] Base64 length:', base64Content.length, 'characters'); console.log('[Binect API] Base64 length:', base64Content.length, 'characters');
// Prepare request body // Map options
const requestBody = { const uploadOptions = mapOptions(options);
content: { console.log('[Binect API] Upload options:', uploadOptions);
filename,
content: base64Content
},
options: options || {
simplex: false, // duplex by default
color: false // black and white by default
}
};
console.log('[Binect API] Request options:', requestBody.options); // Upload document
const doc = await client.documents.upload({
// Make request with HTTP Basic Authentication content: base64Content,
const response = await fetch(`${API_BASE_URL}/documents`, { filename,
method: 'POST', ...uploadOptions,
headers: {
'Content-Type': 'application/json',
'Authorization': createBasicAuthHeader(username, password)
},
body: JSON.stringify(requestBody)
}); });
console.log('[Binect API] Upload response status:', response.status); console.log('[Binect API] Upload successful!');
console.log('[Binect API] Response content-type:', response.headers.get('content-type')); console.log('[Binect API] Document ID:', doc.id);
console.log('[Binect API] Document status:', doc.status);
if (!response.ok) { // Check if document has errors
const errorText = await response.text(); if (doc.letter?.letterType === 'Error' && doc.letter.errors) {
console.error('[Binect API] Upload error response:', errorText); console.warn('[Binect API] Document has errors:', doc.letter.errors);
const errorMessages = doc.letter.errors.map(e => e.message).join('; ');
throw new BinectAPIError(
`Document validation failed: ${errorMessages}`,
200,
doc
);
}
if (response.status === 401 || response.status === 403) { // Status code 7 = erroneous
throw new BinectAPIError('Invalid credentials', response.status); if (doc.status.code === 7) {
} console.error('[Binect API] Document is erroneous:', doc.status.text);
throw new BinectAPIError(
`Document is erroneous: ${doc.status.text}`,
200,
doc
);
}
if (response.status === 400) { return mapDocument(doc);
} catch (error) {
console.error('[Binect API] Upload error:', error);
// Handle @binect/js errors
if (error instanceof BinectAuthError) {
throw new BinectAPIError('Invalid credentials', 401);
}
if (error instanceof BinectApiError) {
// Map specific status codes
if (error.status === 400) {
throw new BinectAPIError( throw new BinectAPIError(
'Invalid request. Please check the PDF format and size.', 'Invalid request. Please check the PDF format and size.',
400 400
); );
} }
if (error.status === 413) {
if (response.status === 413) {
throw new BinectAPIError('File size exceeds limit (12 MB)', 413); throw new BinectAPIError('File size exceeds limit (12 MB)', 413);
} }
throw BinectAPIError.fromBinectError(error);
throw new BinectAPIError(
`Upload failed: ${response.statusText}`,
response.status,
errorText
);
} }
const document: Document = await response.json(); // Already a BinectAPIError
console.log('[Binect API] Upload successful!');
console.log('[Binect API] Document ID:', document.id);
console.log('[Binect API] Document status:', document.status);
console.log('[Binect API] Full response:', document);
// Check if document has errors
if (document.letter?.letterType === 'Error' && document.letter.errors) {
console.warn('[Binect API] Document has errors:', document.letter.errors);
const errorMessages = document.letter.errors.map(e => e.text).join('; ');
throw new BinectAPIError(
`Document validation failed: ${errorMessages}`,
200,
document
);
}
// Status code 2 = shippable, 7 = erroneous
if (document.status.code === 7) {
console.error('[Binect API] Document is erroneous:', document.status.text);
throw new BinectAPIError(
`Document is erroneous: ${document.status.text}`,
200,
document
);
}
return document;
} catch (error) {
console.error('[Binect API] Upload error:', error);
if (error instanceof BinectAPIError) { if (error instanceof BinectAPIError) {
throw error; throw error;
} }
// Check for network errors // Network or other errors
if (error instanceof TypeError && error.message.includes('fetch')) { if (error instanceof TypeError && error.message.includes('fetch')) {
throw new BinectAPIError( throw new BinectAPIError(
`Cannot reach Binect API at ${API_BASE_URL}. Please check your internet connection.` 'Cannot reach Binect API. Please check your internet connection.'
); );
} }
@@ -220,32 +295,32 @@ export async function uploadPDF(
* @returns true if authentication successful, false otherwise * @returns true if authentication successful, false otherwise
*/ */
export async function testConnection(username: string, password: string): Promise<boolean> { export async function testConnection(username: string, password: string): Promise<boolean> {
console.log('[Binect API] Testing connection to Binect API...'); console.log('[Binect API] Testing connection via @binect/js...');
console.log('[Binect API] URL:', `${API_BASE_URL}/accounts`);
try { try {
const response = await fetch(`${API_BASE_URL}/accounts`, { const client = new BinectClient({
method: 'GET', username,
headers: { password,
'Authorization': createBasicAuthHeader(username, password)
}
}); });
console.log('[Binect API] Test connection response status:', response.status); // Attempt to get account info
await client.accounts.get();
if (response.status === 401 || response.status === 403) { console.log('[Binect API] Connection successful');
return true;
} catch (error) {
if (error instanceof BinectAuthError) {
console.log('[Binect API] Authentication failed'); console.log('[Binect API] Authentication failed');
return false; return false;
} }
if (response.ok) { if (error instanceof BinectApiError) {
console.log('[Binect API] Connection successful'); console.warn('[Binect API] API error:', error.message);
return true; // If we get any other API error, credentials might still be valid
// but there's another issue
return false;
} }
console.warn('[Binect API] Unexpected response status:', response.status);
return false;
} catch (error) {
console.error('[Binect API] Connection test error:', error); console.error('[Binect API] Connection test error:', error);
return false; return false;
} }

View File

@@ -0,0 +1,82 @@
/**
* Mock for @binect/js library
*/
export class BinectApiError extends Error {
status: number;
response?: unknown;
constructor(message: string, status: number, response?: unknown) {
super(message);
this.name = 'BinectApiError';
this.status = status;
this.response = response;
}
}
export class BinectAuthError extends Error {
constructor(message: string) {
super(message);
this.name = 'BinectAuthError';
}
}
export const EnvelopeType = {
DINLANG: 'DINLANG',
C4: 'C4',
} as const;
export const FrankingType = {
UNSPECIFIED: 'UNSPECIFIED',
STANDARD_FRANKING: 'STANDARD_FRANKING',
DV_FRANKING: 'DV_FRANKING',
} as const;
// Mock document response
const mockDocument = {
id: 123,
filename: 'test.pdf',
numberOfPages: 2,
status: { code: 2, text: 'shippable' },
documentType: 'LETTER',
letter: {
letterType: 'LetterData',
letterData: {
recipientAddress: 'Test Address',
price: { priceBeforeTax: 100, priceAfterTax: 119, unit: 'EUROCENT', taxInPercent: 19 },
international: false,
options: { simplex: false, color: false },
},
},
};
// Mock account response
const mockAccount = {
id: 1,
email: 'test@example.com',
};
export class BinectClient {
documents = {
upload: jest.fn().mockResolvedValue(mockDocument),
};
accounts = {
get: jest.fn().mockResolvedValue(mockAccount),
};
constructor(_config: { username: string; password: string }) {
// Store config if needed for tests
}
}
export type Document = typeof mockDocument;
export interface DocumentUploadOptions {
content: string;
filename: string;
simplex?: boolean;
color?: boolean;
envelope?: string;
franking?: string;
}

View File

@@ -1,116 +1,92 @@
/** /**
* Tests for Binect API client * Tests for Binect API client
*
* These tests verify the binect-api module's error handling and response mapping.
* The actual @binect/js library is tested separately.
*/ */
import { authenticate, uploadPDF, BinectAPIError } from '../src/utils/binect-api'; import { BinectAPIError } from '../src/utils/binect-api';
describe('Binect API', () => { describe('BinectAPIError', () => {
beforeEach(() => { test('should create error with message only', () => {
jest.clearAllMocks(); const error = new BinectAPIError('Test error');
expect(error.message).toBe('Test error');
expect(error.name).toBe('BinectAPIError');
expect(error.statusCode).toBeUndefined();
expect(error.response).toBeUndefined();
}); });
describe('authenticate', () => { test('should create error with status code', () => {
test('should authenticate successfully', async () => { const error = new BinectAPIError('Unauthorized', 401);
const mockResponse = { expect(error.message).toBe('Unauthorized');
token: 'test-token', expect(error.statusCode).toBe(401);
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 create error with response data', () => {
test('should upload PDF successfully', async () => { const responseData = { error: 'Invalid format' };
const mockResponse = { const error = new BinectAPIError('Bad request', 400, responseData);
documentId: 'doc-123', expect(error.message).toBe('Bad request');
status: 'received', expect(error.statusCode).toBe(400);
uploadedAt: '2024-01-01T00:00:00Z' expect(error.response).toEqual(responseData);
}; });
(fetch as jest.Mock).mockResolvedValue({ test('should be instanceof Error', () => {
ok: true, const error = new BinectAPIError('Test');
json: async () => mockResponse expect(error).toBeInstanceOf(Error);
}); expect(error).toBeInstanceOf(BinectAPIError);
});
const pdfData = new ArrayBuffer(1024); });
const result = await uploadPDF(pdfData, 'test.pdf', 'token-123');
describe('arrayBufferToBase64', () => {
expect(result.documentId).toBe('doc-123'); // Test the base64 encoding indirectly by checking the module exports
expect(fetch).toHaveBeenCalledWith( // The actual encoding is tested via integration tests
'https://api.binect.de/documents/upload',
expect.objectContaining({ test('should handle empty ArrayBuffer', () => {
method: 'POST', const buffer = new ArrayBuffer(0);
headers: { const bytes = new Uint8Array(buffer);
Authorization: 'Bearer token-123' let binary = '';
} for (let i = 0; i < bytes.byteLength; i++) {
}) binary += String.fromCharCode(bytes[i]);
); }
}); const base64 = btoa(binary);
expect(base64).toBe('');
test('should throw on authentication failure', async () => { });
(fetch as jest.Mock).mockResolvedValue({
ok: false, test('should encode simple data correctly', () => {
status: 401, // "Hello" in bytes
statusText: 'Unauthorized', const data = new Uint8Array([72, 101, 108, 108, 111]);
json: async () => ({ error: 'Invalid token' }) let binary = '';
}); for (let i = 0; i < data.byteLength; i++) {
binary += String.fromCharCode(data[i]);
const pdfData = new ArrayBuffer(1024); }
await expect(uploadPDF(pdfData, 'test.pdf', 'bad-token')).rejects.toThrow( const base64 = btoa(binary);
BinectAPIError expect(base64).toBe('SGVsbG8=');
); });
});
test('should encode PDF header correctly', () => {
test('should throw on file size exceeded', async () => { // PDF magic bytes: %PDF
(fetch as jest.Mock).mockResolvedValue({ const pdfHeader = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
ok: false, let binary = '';
status: 413, for (let i = 0; i < pdfHeader.byteLength; i++) {
statusText: 'Payload Too Large', binary += String.fromCharCode(pdfHeader[i]);
json: async () => ({ error: 'File too large' }) }
}); const base64 = btoa(binary);
expect(base64).toBe('JVBERg==');
const pdfData = new ArrayBuffer(10 * 1024 * 1024); // 10MB });
await expect(uploadPDF(pdfData, 'test.pdf', 'token')).rejects.toThrow(
'File size exceeds limit' test('should handle binary data with all byte values', () => {
); // Test with bytes 0-255 to ensure full range works
}); const data = new Uint8Array(256);
for (let i = 0; i < 256; i++) {
data[i] = i;
}
let binary = '';
for (let i = 0; i < data.byteLength; i++) {
binary += String.fromCharCode(data[i]);
}
const base64 = btoa(binary);
// Just verify it doesn't throw and produces valid base64
expect(base64).toMatch(/^[A-Za-z0-9+/]+=*$/);
expect(base64.length).toBeGreaterThan(0);
}); });
}); });