Skip to content
Open
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
93 changes: 93 additions & 0 deletions src/__tests__/main/ipc/handlers/web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ describe('web handlers', () => {
let mockWebServer: any;
let webServerRef: { current: any };
let mockCreateWebServer: any;
let mockSettingsStore: any;

beforeEach(() => {
vi.clearAllMocks();
Expand All @@ -54,19 +55,25 @@ describe('web handlers', () => {
broadcastTabsChange: vi.fn(),
broadcastSessionStateChange: vi.fn(),
getWebClientCount: vi.fn().mockReturnValue(1),
getSecurityToken: vi.fn().mockReturnValue('mock-security-token'),
start: vi.fn().mockResolvedValue({ port: 8080, url: 'http://localhost:8080' }),
stop: vi.fn().mockResolvedValue(undefined),
};

webServerRef = { current: mockWebServer };
mockCreateWebServer = vi.fn().mockReturnValue(mockWebServer);
mockSettingsStore = {
get: vi.fn(),
set: vi.fn(),
};

registerWebHandlers({
getWebServer: () => webServerRef.current,
setWebServer: (server) => {
webServerRef.current = server;
},
createWebServer: mockCreateWebServer,
settingsStore: mockSettingsStore,
});
});

Expand Down Expand Up @@ -96,6 +103,11 @@ describe('web handlers', () => {
);
expect(ipcMain.handle).toHaveBeenCalledWith('live:startServer', expect.any(Function));
expect(ipcMain.handle).toHaveBeenCalledWith('live:stopServer', expect.any(Function));
expect(ipcMain.handle).toHaveBeenCalledWith('live:persistCurrentToken', expect.any(Function));
expect(ipcMain.handle).toHaveBeenCalledWith(
'live:clearPersistentToken',
expect.any(Function)
);
expect(ipcMain.handle).toHaveBeenCalledWith('live:disableAll', expect.any(Function));
expect(ipcMain.handle).toHaveBeenCalledWith('webserver:getUrl', expect.any(Function));
expect(ipcMain.handle).toHaveBeenCalledWith(
Expand Down Expand Up @@ -346,6 +358,87 @@ describe('web handlers', () => {
});
});

describe('live:persistCurrentToken', () => {
it('should write flag before token for crash safety', async () => {
const handler = registeredHandlers.get('live:persistCurrentToken');
const result = await handler!({});

expect(mockWebServer.getSecurityToken).toHaveBeenCalled();
expect(mockSettingsStore.set).toHaveBeenCalledWith('persistentWebLink', true);
expect(mockSettingsStore.set).toHaveBeenCalledWith('webAuthToken', 'mock-security-token');
expect(result).toEqual({ success: true });

// Verify crash-safe write order: flag enabled before token.
// A crash between the two writes leaves persistentWebLink=true with
// a missing token, which the factory handles by generating a fresh UUID.
const setCalls = vi.mocked(mockSettingsStore.set).mock.calls;
const flagIndex = setCalls.findIndex(([key]) => key === 'persistentWebLink');
const tokenIndex = setCalls.findIndex(([key]) => key === 'webAuthToken');
expect(flagIndex).toBeLessThan(tokenIndex);
});

it('should return failure when web server is null', async () => {
webServerRef.current = null;

const handler = registeredHandlers.get('live:persistCurrentToken');
const result = await handler!({});

expect(result).toEqual({ success: false, message: 'Web server is not running.' });
});

it('should return failure when web server is not active', async () => {
mockWebServer.isActive.mockReturnValue(false);

const handler = registeredHandlers.get('live:persistCurrentToken');
const result = await handler!({});

expect(result).toEqual({ success: false, message: 'Web server is not running.' });
expect(mockWebServer.getSecurityToken).not.toHaveBeenCalled();
});

it('should return failure when settings write throws', async () => {
mockSettingsStore.set.mockImplementationOnce(() => {
throw new Error('disk full');
});

const handler = registeredHandlers.get('live:persistCurrentToken');
const result = await handler!({});

expect(result).toEqual({ success: false, message: 'disk full' });
});
});

describe('live:clearPersistentToken', () => {
it('should clear flag before token for crash safety', async () => {
const handler = registeredHandlers.get('live:clearPersistentToken');
const result = await handler!({});

// Verify both writes are made
expect(mockSettingsStore.set).toHaveBeenCalledWith('persistentWebLink', false);
expect(mockSettingsStore.set).toHaveBeenCalledWith('webAuthToken', null);
expect(result).toEqual({ success: true });

// Verify crash-safe write order: flag cleared before token.
// A crash between the two writes must leave persistentWebLink=false
// so the factory ignores the stale token on next startup.
const setCalls = vi.mocked(mockSettingsStore.set).mock.calls;
const flagIndex = setCalls.findIndex(([key]) => key === 'persistentWebLink');
const tokenIndex = setCalls.findIndex(([key]) => key === 'webAuthToken');
expect(flagIndex).toBeLessThan(tokenIndex);
});

it('should return failure when settings write throws', async () => {
mockSettingsStore.set.mockImplementationOnce(() => {
throw new Error('disk full');
});

const handler = registeredHandlers.get('live:clearPersistentToken');
const result = await handler!({});

expect(result).toEqual({ success: false, message: 'disk full' });
});
});

describe('webserver:getUrl', () => {
it('should return web server URL', async () => {
const handler = registeredHandlers.get('webserver:getUrl');
Expand Down
83 changes: 82 additions & 1 deletion src/__tests__/main/web-server/web-server-factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ vi.mock('../../../main/web-server/WebServer', () => {
return {
WebServer: class MockWebServer {
port: number;
securityToken: string | undefined;
setGetSessionsCallback = vi.fn();
setGetSessionDetailCallback = vi.fn();
setGetThemeCallback = vi.fn();
Expand All @@ -37,8 +38,9 @@ vi.mock('../../../main/web-server/WebServer', () => {
setReorderTabCallback = vi.fn();
setToggleBookmarkCallback = vi.fn();

constructor(port: number) {
constructor(port: number, securityToken?: string) {
this.port = port;
this.securityToken = securityToken;
}
},
};
Expand Down Expand Up @@ -94,11 +96,14 @@ describe('web-server/web-server-factory', () => {
const values: Record<string, any> = {
webInterfaceUseCustomPort: false,
webInterfaceCustomPort: 8080,
persistentWebLink: false,
webAuthToken: null,
activeThemeId: 'dracula',
customAICommands: [],
};
return values[key] ?? defaultValue;
}),
set: vi.fn(),
};

mockSessionsStore = {
Expand Down Expand Up @@ -199,6 +204,82 @@ describe('web-server/web-server-factory', () => {
// Check that the server was created with custom port
expect((server as any).port).toBe(9999);
});

it('should not pass security token when persistentWebLink is false', () => {
vi.mocked(mockSettingsStore.get).mockImplementation((key: string, defaultValue?: any) => {
if (key === 'persistentWebLink') return false;
return defaultValue;
});

const createWebServer = createWebServerFactory(deps);
const server = createWebServer();

expect((server as any).securityToken).toBeUndefined();
});

it('should use stored token when persistentWebLink is true and token is a valid UUID', () => {
const validUuid = '550e8400-e29b-4bd4-a716-446655440000';
vi.mocked(mockSettingsStore.get).mockImplementation((key: string, defaultValue?: any) => {
if (key === 'persistentWebLink') return true;
if (key === 'webAuthToken') return validUuid;
return defaultValue;
});

const createWebServer = createWebServerFactory(deps);
const server = createWebServer();

expect((server as any).securityToken).toBe(validUuid);
});

it('should reject invalid stored token and generate a new UUID', () => {
vi.mocked(mockSettingsStore.get).mockImplementation((key: string, defaultValue?: any) => {
if (key === 'persistentWebLink') return true;
if (key === 'webAuthToken') return 'not-a-valid-uuid';
return defaultValue;
});

const createWebServer = createWebServerFactory(deps);
const server = createWebServer();

// Should have generated a new token, not used the invalid one
expect((server as any).securityToken).not.toBe('not-a-valid-uuid');
expect((server as any).securityToken).toBeDefined();
expect(mockSettingsStore.set).toHaveBeenCalledWith('webAuthToken', expect.any(String));
// Token written to settings must match the one given to the server
const storedToken = vi
.mocked(mockSettingsStore.set)
.mock.calls.find(([key]) => key === 'webAuthToken')?.[1];
expect((server as any).securityToken).toBe(storedToken);
// Generated replacement must be a valid UUID v4
expect(storedToken).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
);
});

it('should generate and store new token when persistentWebLink is true and no token exists', () => {
vi.mocked(mockSettingsStore.get).mockImplementation((key: string, defaultValue?: any) => {
if (key === 'persistentWebLink') return true;
if (key === 'webAuthToken') return null;
return defaultValue;
});

const createWebServer = createWebServerFactory(deps);
const server = createWebServer();

// Should have generated a token and stored it
expect((server as any).securityToken).toBeDefined();
expect(typeof (server as any).securityToken).toBe('string');
expect(mockSettingsStore.set).toHaveBeenCalledWith('webAuthToken', expect.any(String));
// Token written to settings must match the one given to the server
const storedToken = vi
.mocked(mockSettingsStore.set)
.mock.calls.find(([key]) => key === 'webAuthToken')?.[1];
expect((server as any).securityToken).toBe(storedToken);
// Generated token must be a valid UUID v4
expect(storedToken).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
);
});
});

describe('callback registrations', () => {
Expand Down
133 changes: 132 additions & 1 deletion src/__tests__/renderer/stores/settingsStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1636,7 +1636,138 @@ describe('settingsStore', () => {
});

// ========================================================================
// 13. Non-React Access
// 13. setPersistentWebLink race-condition and rollback tests
// ========================================================================

describe('setPersistentWebLink', () => {
beforeEach(() => {
useSettingsStore.setState({ persistentWebLink: false });
});

it('should optimistically set persistentWebLink to true and call persistCurrentToken', async () => {
const { setPersistentWebLink } = useSettingsStore.getState();
await setPersistentWebLink(true);

expect(useSettingsStore.getState().persistentWebLink).toBe(true);
expect(window.maestro.live.persistCurrentToken).toHaveBeenCalledOnce();
});

it('should rollback to false on soft IPC failure (result.success === false)', async () => {
vi.mocked(window.maestro.live.persistCurrentToken).mockResolvedValueOnce({
success: false,
message: 'Web server is not running.',
});

const { setPersistentWebLink } = useSettingsStore.getState();
await setPersistentWebLink(true);

expect(useSettingsStore.getState().persistentWebLink).toBe(false);
});

it('should rollback to false on hard IPC failure (thrown exception)', async () => {
vi.mocked(window.maestro.live.persistCurrentToken).mockRejectedValueOnce(
new Error('IPC timeout')
);

const { setPersistentWebLink } = useSettingsStore.getState();
await setPersistentWebLink(true);

expect(useSettingsStore.getState().persistentWebLink).toBe(false);
});

it('should call clearPersistentToken when disabling', async () => {
useSettingsStore.setState({ persistentWebLink: true });

const { setPersistentWebLink } = useSettingsStore.getState();
await setPersistentWebLink(false);

expect(useSettingsStore.getState().persistentWebLink).toBe(false);
expect(window.maestro.live.clearPersistentToken).toHaveBeenCalledOnce();
});

it('should rollback to true on clearPersistentToken hard failure (thrown exception)', async () => {
useSettingsStore.setState({ persistentWebLink: true });
vi.mocked(window.maestro.live.clearPersistentToken).mockRejectedValueOnce(
new Error('IPC timeout')
);

const { setPersistentWebLink } = useSettingsStore.getState();
await setPersistentWebLink(false);

expect(useSettingsStore.getState().persistentWebLink).toBe(true);
});

it('should rollback to true on clearPersistentToken soft failure (result.success === false)', async () => {
useSettingsStore.setState({ persistentWebLink: true });
vi.mocked(window.maestro.live.clearPersistentToken).mockResolvedValueOnce({
success: false,
message: 'Settings write failed.',
} as any);

const { setPersistentWebLink } = useSettingsStore.getState();
await setPersistentWebLink(false);

expect(useSettingsStore.getState().persistentWebLink).toBe(true);
});

it('should handle rapid double-toggle (enable then disable) correctly', async () => {
// Simulate enable call that resolves slowly
let resolveEnable: (value: any) => void;
const slowEnable = new Promise((resolve) => {
resolveEnable = resolve;
});
vi.mocked(window.maestro.live.persistCurrentToken).mockReturnValueOnce(slowEnable as any);

const { setPersistentWebLink } = useSettingsStore.getState();

// Start enable (will be in-flight)
const enablePromise = setPersistentWebLink(true);
// Immediately disable (supersedes the enable)
const disablePromise = setPersistentWebLink(false);

// Resolve the slow enable after disable was called
resolveEnable!({ success: true });

await enablePromise;
await disablePromise;

// Final state should reflect the last user intent: disabled
expect(useSettingsStore.getState().persistentWebLink).toBe(false);
expect(window.maestro.live.clearPersistentToken).toHaveBeenCalled();
});

it('should handle rapid reverse toggle (disable then enable) correctly', async () => {
// Start with enabled state
useSettingsStore.setState({ persistentWebLink: true });

// Simulate disable call that resolves slowly
let resolveClear: (value: any) => void;
const slowClear = new Promise((resolve) => {
resolveClear = resolve;
});
vi.mocked(window.maestro.live.clearPersistentToken).mockReturnValueOnce(slowClear as any);

const { setPersistentWebLink } = useSettingsStore.getState();

// Start disable (will be in-flight)
const disablePromise = setPersistentWebLink(false);
// Immediately re-enable (supersedes the disable)
const enablePromise = setPersistentWebLink(true);

// Resolve the slow clear after enable was called
resolveClear!({ success: true });

await disablePromise;
await enablePromise;

// Final state should reflect the last user intent: enabled
expect(useSettingsStore.getState().persistentWebLink).toBe(true);
expect(window.maestro.live.persistCurrentToken).toHaveBeenCalled();
});
});

// ========================================================================
// 14. Non-React Access
// ========================================================================

describe('non-React access', () => {
Expand Down
Loading