Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions tests/unit/background-auth-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/**
* Test scaffolding for background authentication handlers.
* Addresses Issue #105: no test files exist for background handler modules.
* Addresses Issue #40: establishes coverage baseline for auth boundary.
*
* These tests exercise the AUTH_INITIATE, AUTH_STATUS, AUTH_LOGOUT, and
* OFFSCREEN_TOKEN_REFRESH handlers registered in src/background/index.ts
* by importing the module (which registers handlers on the MessageBus)
* and invoking them through the chrome.runtime.onMessage listener.
*/

import { StorageService } from '../../src/services/storage';
import { SalesforceAuth } from '../../src/services/salesforce/auth';
import type { ExtensionMessage, MessageResponse } from '../../src/core/types/messaging';
import type { SalesforceOrg } from '../../src/core/types/salesforce';

// Import background module to register handlers on the MessageBus.
// The module creates singleton service instances and registers all
// messageBus.on() handlers at import time.
import '../../src/background/index';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stub worker-only Chrome APIs before importing the worker

Importing the full background module during Jest evaluation immediately executes chrome.commands.onCommand.addListener and later chrome.alarms.onAlarm.addListener, but tests/mocks/chromeMock.ts defines neither commands nor alarms. Consequently this suite, and the storage-handler suite with the same import, aborts before any test runs with an undefined-property error; extend the Chrome mock or isolate the handler registration from service-worker startup.

Useful? React with 👍 / 👎.


const mockOrg: SalesforceOrg = {
id: '00Dxx0000000001',
orgId: '00Dxx0000000001',
instanceUrl: 'https://test.my.salesforce.com',
environment: 'sandbox',
username: 'test@example.com',
displayName: 'Test User',
accessToken: 'mock-access-token',
tokenExpiresAt: Date.now() + 7200000,
apiVersion: 'v65.0',
connectedAt: Date.now(),
lastUsedAt: Date.now(),
};

function makeMessage<T>(type: string, payload: T): ExtensionMessage {
return {
type: type as ExtensionMessage['type'],
payload: payload as ExtensionMessage['payload'],
requestId: `req-${Date.now()}`,
timestamp: Date.now(),
source: 'popup',
};
}

function getRegisteredHandler(type: string) {
// The MessageBus registers listeners via chrome.runtime.onMessage.addListener.
// We capture the listener from the mock and invoke it directly.
const listeners = (chrome.runtime.onMessage.addListener as jest.Mock).mock.calls;
// The last registered listener is the background handler (singleton MessageBus).
const listener = listeners[listeners.length - 1]?.[0];
Comment on lines +49 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Select the MessageBus listener instead of the last listener

Even after the missing Chrome APIs are stubbed, the last runtime.onMessage listener registered by src/background/index.ts is the raw scheduler-control listener at line 2452, not the MessageBus listener created near startup. Every auth message passed here therefore returns false and is converted to NOT_HANDLED; the duplicated helper in background-storage-handlers.test.ts has the same problem. Capture the MessageBus listener explicitly rather than relying on registration order.

Useful? React with 👍 / 👎.

if (!listener) throw new Error(`No listener registered for ${type}`);

return async (message: ExtensionMessage): Promise<MessageResponse> => {
return new Promise((resolve) => {
const result = listener(message, {}, resolve);
// If the handler returns false or undefined synchronously, it was not handled.
if (result === false || result === undefined) {
resolve({ success: false, error: { code: 'NOT_HANDLED', message: 'No handler' }, requestId: message.requestId });
}
});
};
}

describe('Background Auth Handlers', () => {
let storage: StorageService;

beforeEach(async () => {
await chrome.storage.local.clear();
await chrome.storage.session.clear();
jest.restoreAllMocks();
storage = new StorageService();
});

describe('AUTH_STATUS', () => {
it('returns authenticated: false when no active org exists', async () => {
const handler = getRegisteredHandler('AUTH_STATUS');
const message = makeMessage('AUTH_STATUS', {});
const response = await handler(message);

expect(response.success).toBe(true);
expect(response.data).toEqual({ authenticated: false });
});

it('returns authenticated: true with org data when active org exists and token is valid', async () => {
await storage.saveOrg(mockOrg);
await storage.setActiveOrgId(mockOrg.orgId);

jest.spyOn(SalesforceAuth.prototype, 'ensureValidToken').mockResolvedValue(mockOrg);

const handler = getRegisteredHandler('AUTH_STATUS');
const message = makeMessage('AUTH_STATUS', {});
const response = await handler(message);

expect(response.success).toBe(true);
expect(response.data).toEqual({
authenticated: true,
org: {
orgId: mockOrg.orgId,
username: mockOrg.username,
instanceUrl: mockOrg.instanceUrl,
},
});
});

it('returns error when auth status check throws', async () => {
await storage.saveOrg(mockOrg);
await storage.setActiveOrgId(mockOrg.orgId);

jest.spyOn(SalesforceAuth.prototype, 'ensureValidToken').mockRejectedValue(new Error('Network failure'));

const handler = getRegisteredHandler('AUTH_STATUS');
const message = makeMessage('AUTH_STATUS', {});
const response = await handler(message);

expect(response.success).toBe(false);
expect(response.error?.code).toBe('AUTH_STATUS_ERROR');
});
});

describe('AUTH_LOGOUT', () => {
it('removes stored org and returns success', async () => {
await storage.saveOrg(mockOrg);
await storage.setActiveOrgId(mockOrg.orgId);

const handler = getRegisteredHandler('AUTH_LOGOUT');
const message = makeMessage('AUTH_LOGOUT', { orgId: mockOrg.orgId });
const response = await handler(message);

expect(response.success).toBe(true);
const storedOrg = await storage.getOrg(mockOrg.orgId);
expect(storedOrg).toBeUndefined();
Comment on lines +131 to +132

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Expect the storage service's null sentinel

When the logout handler successfully removes this org, StorageService.getOrg() returns null for a missing entry (orgs[orgId] ?? null), not undefined. Thus this assertion fails in the exact successful-logout scenario the test is intended to verify; assert null or use a nullish matcher.

Useful? React with 👍 / 👎.

});

it('succeeds even when org does not exist', async () => {
const handler = getRegisteredHandler('AUTH_LOGOUT');
const message = makeMessage('AUTH_LOGOUT', { orgId: 'nonexistent' });
const response = await handler(message);

expect(response.success).toBe(true);
});
});

describe('OFFSCREEN_TOKEN_REFRESH', () => {
it('returns refreshed token for known orgId', async () => {
await storage.saveOrg(mockOrg);
await storage.setActiveOrgId(mockOrg.orgId);

const refreshedOrg = { ...mockOrg, accessToken: 'refreshed-token' };
jest.spyOn(SalesforceAuth.prototype, 'ensureValidToken').mockResolvedValue(refreshedOrg);

const handler = getRegisteredHandler('OFFSCREEN_TOKEN_REFRESH');
const message = makeMessage('OFFSCREEN_TOKEN_REFRESH', { orgId: mockOrg.orgId });
const response = await handler(message);

expect(response.success).toBe(true);
expect(response.data).toEqual({ accessToken: 'refreshed-token' });
});

it('returns error when orgId is unknown and no instanceUrl provided', async () => {
const handler = getRegisteredHandler('OFFSCREEN_TOKEN_REFRESH');
const message = makeMessage('OFFSCREEN_TOKEN_REFRESH', {});
const response = await handler(message);

expect(response.success).toBe(false);
expect(response.error?.code).toBe('TOKEN_REFRESH_FAILED');
});
});
});
218 changes: 218 additions & 0 deletions tests/unit/background-services.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/**
* Test scaffolding for background service modules (MessageBus, SalesforceAuth).
* Addresses Issue #105: no test files exist for service modules used by background handlers.
* Addresses Issue #40: establishes coverage baseline for service layer boundaries.
*
* These tests exercise the MessageBus messaging abstraction and SalesforceAuth
* cookie-based authentication in isolation, without requiring the full background
* handler registration.
*/

import { MessageBus } from '../../src/services/messaging';
import { SalesforceAuth } from '../../src/services/salesforce/auth';
import type { ExtensionMessage, MessageResponse } from '../../src/core/types/messaging';

describe('MessageBus', () => {
let bus: MessageBus;

beforeEach(() => {
jest.restoreAllMocks();
bus = new MessageBus('background');
});

afterEach(() => {
bus.destroy();
});

it('registers and invokes a handler for a message type', async () => {
const handler = jest.fn(async (message: ExtensionMessage): Promise<MessageResponse> => ({
success: true,
data: { echo: message.payload },
requestId: message.requestId,
}));

bus.on('UI_SETTINGS_GET', handler);

// Simulate an incoming message through the chrome.runtime.onMessage listener
const listeners = (chrome.runtime.onMessage.addListener as jest.Mock).mock.calls;
const listener = listeners[listeners.length - 1]?.[0];
expect(listener).toBeDefined();

const message: ExtensionMessage = {
type: 'UI_SETTINGS_GET',
payload: {},
requestId: 'req-test-1',
timestamp: Date.now(),
source: 'popup',
};

const response = await new Promise<MessageResponse>((resolve) => {
listener(message, {}, resolve);
});

expect(handler).toHaveBeenCalledTimes(1);
expect(response.success).toBe(true);
});

it('returns false for unhandled message types', () => {
const listeners = (chrome.runtime.onMessage.addListener as jest.Mock).mock.calls;
const listener = listeners[listeners.length - 1]?.[0];

const message: ExtensionMessage = {
type: 'UI_SETTINGS_GET',
payload: {},
requestId: 'req-test-2',
timestamp: Date.now(),
source: 'popup',
};

const sendResponse = jest.fn();
const result = listener(message, {}, sendResponse);

// No handler registered, should return false
expect(result).toBe(false);
expect(sendResponse).not.toHaveBeenCalled();
});

it('removes a handler with off()', async () => {
const handler = jest.fn(async (): Promise<MessageResponse> => ({
success: true,
requestId: 'req-test-3',
}));

bus.on('UI_SETTINGS_GET', handler);
bus.off('UI_SETTINGS_GET');

const listeners = (chrome.runtime.onMessage.addListener as jest.Mock).mock.calls;
const listener = listeners[listeners.length - 1]?.[0];

const message: ExtensionMessage = {
type: 'UI_SETTINGS_GET',
payload: {},
requestId: 'req-test-3',
timestamp: Date.now(),
source: 'popup',
};

const sendResponse = jest.fn();
const result = listener(message, {}, sendResponse);

expect(result).toBe(false);
expect(handler).not.toHaveBeenCalled();
});

it('handles handler errors gracefully', async () => {
bus.on('UI_SETTINGS_GET', async (): Promise<MessageResponse> => {
throw new Error('Handler exploded');
});

const listeners = (chrome.runtime.onMessage.addListener as jest.Mock).mock.calls;
const listener = listeners[listeners.length - 1]?.[0];

const message: ExtensionMessage = {
type: 'UI_SETTINGS_GET',
payload: {},
requestId: 'req-test-4',
timestamp: Date.now(),
source: 'popup',
};

const response = await new Promise<MessageResponse>((resolve) => {
listener(message, {}, resolve);
});

expect(response.success).toBe(false);
expect(response.error?.message).toContain('Handler exploded');
});

it('broadcast sends to runtime and all tabs', () => {
bus.broadcast('DATA_PUSH_PROGRESS', {
pushId: 'push-1',
processedRecords: 5,
totalRecords: 10,
status: 'processing',
});

expect(chrome.runtime.sendMessage).toHaveBeenCalled();
expect(chrome.tabs.query).toHaveBeenCalledWith({}, expect.any(Function));
});
});

describe('SalesforceAuth', () => {
let auth: SalesforceAuth;

beforeEach(() => {
jest.restoreAllMocks();
auth = new SalesforceAuth();
});

describe('isTokenExpired', () => {
it('returns false when token is well within TTL', () => {
const org = {
id: '00D',
orgId: '00D',
instanceUrl: 'https://test.my.salesforce.com',
environment: 'sandbox' as const,
username: 'user@test.com',
displayName: 'User',
accessToken: 'token',
tokenExpiresAt: Date.now() + 3600000, // 1 hour from now
apiVersion: 'v65.0' as const,
connectedAt: Date.now(),
lastUsedAt: Date.now(),
};

expect(auth.isTokenExpired(org)).toBe(false);
});

it('returns true when token is past expiry minus buffer', () => {
const org = {
id: '00D',
orgId: '00D',
instanceUrl: 'https://test.my.salesforce.com',
environment: 'sandbox' as const,
username: 'user@test.com',
displayName: 'User',
accessToken: 'token',
tokenExpiresAt: Date.now() - 1000, // already expired
apiVersion: 'v65.0' as const,
connectedAt: Date.now(),
lastUsedAt: Date.now(),
};

expect(auth.isTokenExpired(org)).toBe(true);
});
});

describe('login', () => {
it('throws AuthError when no Salesforce tab is open', async () => {
(chrome.tabs.query as jest.Mock).mockImplementation(
(_queryInfo: unknown, callback: (tabs: unknown[]) => void) => {
callback([]);
},
Comment on lines +189 to +192

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return a promise from the tabs.query mock

SalesforceAuth.getActiveSalesforceTab() calls the promise form await chrome.tabs.query(queryInfo) without a callback, so this mock invokes undefined as a function and auth.login() rejects with a TypeError rather than the expected AuthError. Use mockResolvedValue([]) (or otherwise support the promise overload) so the test reaches the no-Salesforce-tab branch.

Useful? React with 👍 / 👎.

);

await expect(auth.login()).rejects.toThrow(/Open an authenticated Salesforce tab/);
});
});

describe('logout', () => {
it('resolves without error (no-op disconnect)', async () => {
const org = {
id: '00D',
orgId: '00D',
instanceUrl: 'https://test.my.salesforce.com',
environment: 'sandbox' as const,
username: 'user@test.com',
displayName: 'User',
accessToken: 'token',
tokenExpiresAt: Date.now() + 3600000,
apiVersion: 'v65.0' as const,
connectedAt: Date.now(),
lastUsedAt: Date.now(),
};

await expect(auth.logout(org)).resolves.toBeUndefined();
});
});
});
Loading
Loading