mirror of
https://github.com/LukeHagar/sveltekit-adapters.git
synced 2025-12-06 12:47:48 +00:00
- Updated GitHub Actions workflow to streamline testing and type checking processes. - Consolidated test steps and improved error handling for better clarity. - Enhanced protocol handler functions in setupHandler.js to support additional features and security measures. - Added new utility functions for file handling and path validation to improve robustness. - Updated integration tests to cover new functionality and ensure proper request handling.
583 lines
18 KiB
JavaScript
583 lines
18 KiB
JavaScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
// Mock Electron APIs
|
|
const mockProtocol = {
|
|
registerSchemesAsPrivileged: vi.fn(),
|
|
handle: vi.fn(),
|
|
unhandle: vi.fn()
|
|
};
|
|
|
|
const mockNet = {
|
|
fetch: vi.fn()
|
|
};
|
|
|
|
const mockDialog = {
|
|
showErrorBox: vi.fn()
|
|
};
|
|
|
|
const mockApp = {
|
|
exit: vi.fn()
|
|
};
|
|
|
|
vi.mock('electron', () => ({
|
|
protocol: mockProtocol,
|
|
net: mockNet,
|
|
dialog: mockDialog,
|
|
app: mockApp
|
|
}));
|
|
|
|
// Mock electron-is-dev with controllable value
|
|
const isDevMock = { value: false };
|
|
vi.mock('electron-is-dev', () => ({
|
|
get default() {
|
|
return isDevMock.value;
|
|
}
|
|
}));
|
|
|
|
// Mock Node.js modules
|
|
vi.mock('node:fs/promises', () => ({
|
|
default: {
|
|
readFile: vi.fn(),
|
|
stat: vi.fn()
|
|
}
|
|
}));
|
|
|
|
vi.mock('node:path', () => ({
|
|
default: {
|
|
join: vi.fn((...args) => args.filter(Boolean).join('/').replace(/\/+/g, '/')),
|
|
resolve: vi.fn((...args) => args.join('/')),
|
|
relative: vi.fn((from, to) => {
|
|
// Normalize paths
|
|
const normalizeSlashes = (p) => p.replace(/\\/g, '/');
|
|
const fromNorm = normalizeSlashes(from);
|
|
const toNorm = normalizeSlashes(to);
|
|
|
|
// If 'to' starts with 'from', it's a child path
|
|
if (toNorm.startsWith(fromNorm)) {
|
|
const relative = toNorm.slice(fromNorm.length).replace(/^\/+/, '');
|
|
return relative || '.';
|
|
}
|
|
|
|
// Check for path traversal patterns
|
|
if (toNorm.includes('../') || toNorm.includes('..\\')) {
|
|
return '../' + toNorm.split(/[/\\]/).pop();
|
|
}
|
|
|
|
// If it's an absolute path that doesn't start with from, it's outside
|
|
if (toNorm.startsWith('/') || toNorm.match(/^[a-zA-Z]:/)) {
|
|
return toNorm;
|
|
}
|
|
|
|
return toNorm;
|
|
}),
|
|
extname: vi.fn((filePath) => {
|
|
const parts = filePath.split('.');
|
|
return parts.length > 1 ? '.' + parts.pop() : '';
|
|
}),
|
|
isAbsolute: vi.fn((p) => p.startsWith('/'))
|
|
}
|
|
}));
|
|
|
|
vi.mock('node:url', () => ({
|
|
pathToFileURL: vi.fn((path) => ({ toString: () => `file://${path}` }))
|
|
}));
|
|
|
|
// Mock SvelteKit imports
|
|
const mockServer = {
|
|
init: vi.fn().mockResolvedValue(),
|
|
respond: vi.fn().mockResolvedValue(new Response('SSR content', {
|
|
headers: [['content-type', 'text/html']]
|
|
}))
|
|
};
|
|
|
|
const mockManifest = { version: '1.0.0' };
|
|
const mockPrerendered = new Set(['/about']);
|
|
const mockBase = '';
|
|
|
|
vi.mock('SERVER', () => ({
|
|
Server: vi.fn().mockImplementation(() => mockServer)
|
|
}));
|
|
|
|
vi.mock('MANIFEST', () => ({
|
|
manifest: mockManifest,
|
|
prerendered: mockPrerendered,
|
|
base: mockBase
|
|
}));
|
|
|
|
// Mock additional dependencies
|
|
vi.mock('set-cookie-parser', () => ({
|
|
parse: vi.fn((cookies) => {
|
|
if (!Array.isArray(cookies)) cookies = [cookies];
|
|
return cookies.map(cookie => {
|
|
const parts = cookie.split(';').map(part => part.trim());
|
|
const [nameValue] = parts;
|
|
const [name, value] = nameValue.split('=');
|
|
const result = { name, value };
|
|
|
|
parts.slice(1).forEach(part => {
|
|
const [key, val] = part.split('=');
|
|
const lowerKey = key.toLowerCase();
|
|
if (lowerKey === 'path') result.path = val || '/';
|
|
if (lowerKey === 'domain') result.domain = val;
|
|
if (lowerKey === 'secure') result.secure = true;
|
|
if (lowerKey === 'httponly') result.httpOnly = true;
|
|
if (lowerKey === 'max-age') result.maxAge = parseInt(val);
|
|
if (lowerKey === 'expires') result.expires = new Date(val);
|
|
});
|
|
|
|
return result;
|
|
});
|
|
}),
|
|
splitCookiesString: vi.fn((setCookieHeaders) => {
|
|
if (Array.isArray(setCookieHeaders)) return setCookieHeaders;
|
|
return [setCookieHeaders];
|
|
})
|
|
}));
|
|
|
|
vi.mock('cookie', () => ({
|
|
serialize: vi.fn((name, value, options) => {
|
|
let result = `${name}=${value}`;
|
|
if (options?.path) result += `; Path=${options.path}`;
|
|
if (options?.domain) result += `; Domain=${options.domain}`;
|
|
if (options?.secure) result += '; Secure';
|
|
if (options?.httpOnly) result += '; HttpOnly';
|
|
if (options?.maxAge) result += `; Max-Age=${options.maxAge}`;
|
|
if (options?.expires) result += `; Expires=${options.expires.toUTCString()}`;
|
|
return result;
|
|
})
|
|
}));
|
|
|
|
describe('Protocol Integration', () => {
|
|
let mockSession;
|
|
let mockWindow;
|
|
|
|
// Helper function to create a mock request with proper headers
|
|
const createMockRequest = (url, method = 'GET', headers = {}, uploadData = null) => {
|
|
const mockRequest = {
|
|
url,
|
|
method,
|
|
headers: new Map(Object.entries(headers)),
|
|
uploadData
|
|
};
|
|
|
|
// Mock headers.forEach to work with createRequest
|
|
mockRequest.headers.forEach = vi.fn((callback) => {
|
|
mockRequest.headers.entries().forEach(([key, value]) => callback(value, key));
|
|
});
|
|
|
|
return mockRequest;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
// Reset all mocks
|
|
vi.clearAllMocks();
|
|
|
|
// Reset isDev to production mode by default
|
|
isDevMock.value = false;
|
|
|
|
// Mock __dirname for the setupHandler
|
|
global.__dirname = '/test/functions';
|
|
|
|
// Setup mock session
|
|
mockSession = {
|
|
cookies: {
|
|
get: vi.fn().mockResolvedValue([
|
|
{ name: 'session', value: 'abc123' },
|
|
{ name: 'user', value: 'john' }
|
|
]),
|
|
set: vi.fn().mockResolvedValue(),
|
|
remove: vi.fn().mockResolvedValue()
|
|
}
|
|
};
|
|
|
|
// Setup mock window
|
|
mockWindow = {
|
|
webContents: {
|
|
session: mockSession
|
|
},
|
|
loadURL: vi.fn().mockResolvedValue()
|
|
};
|
|
|
|
// Mock global constructors
|
|
global.Request = vi.fn().mockImplementation((url, options) => ({
|
|
url,
|
|
method: options?.method || 'GET',
|
|
headers: options?.headers || new Headers(),
|
|
body: options?.body || null,
|
|
formData: vi.fn(),
|
|
json: vi.fn(),
|
|
text: vi.fn(),
|
|
arrayBuffer: vi.fn()
|
|
}));
|
|
|
|
global.Headers = vi.fn().mockImplementation(() => ({
|
|
set: vi.fn(),
|
|
get: vi.fn(),
|
|
has: vi.fn(),
|
|
forEach: vi.fn()
|
|
}));
|
|
|
|
global.URL = vi.fn().mockImplementation((url) => {
|
|
try {
|
|
// Use built-in URL constructor for parsing
|
|
const urlObj = new globalThis.URL(url);
|
|
return {
|
|
toString: () => url,
|
|
hostname: urlObj.hostname,
|
|
host: urlObj.host,
|
|
pathname: urlObj.pathname,
|
|
protocol: urlObj.protocol,
|
|
origin: urlObj.origin
|
|
};
|
|
} catch (e) {
|
|
// Fallback for invalid URLs
|
|
return {
|
|
toString: () => url,
|
|
hostname: '127.0.0.1',
|
|
host: '127.0.0.1',
|
|
pathname: '/',
|
|
protocol: 'http:',
|
|
origin: 'http://127.0.0.1'
|
|
};
|
|
}
|
|
});
|
|
|
|
global.Response = vi.fn().mockImplementation((body, init) => ({
|
|
status: init?.status || 200,
|
|
statusText: init?.statusText || 'OK',
|
|
headers: new Map(Object.entries(init?.headers || {})),
|
|
body
|
|
}));
|
|
|
|
// Mock fs functions
|
|
const fs = await import('node:fs/promises');
|
|
fs.default.readFile.mockResolvedValue(Buffer.from('file content'));
|
|
fs.default.stat.mockResolvedValue({ isFile: () => true });
|
|
|
|
// Mock net.fetch
|
|
mockNet.fetch.mockResolvedValue(new Response('static file content'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Reset isDev mock to default
|
|
isDevMock.value = false;
|
|
|
|
// Clear all mocks
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('registerAppScheme', () => {
|
|
it('should register app scheme as privileged', async () => {
|
|
const { registerAppScheme } = await import('../../functions/setupHandler.js');
|
|
|
|
registerAppScheme();
|
|
|
|
expect(mockProtocol.registerSchemesAsPrivileged).toHaveBeenCalledWith([
|
|
{
|
|
scheme: 'http',
|
|
privileges: {
|
|
standard: true,
|
|
secure: true,
|
|
supportFetchAPI: true
|
|
}
|
|
}
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('setupHandler', () => {
|
|
it('should load URL and setup protocol handler in production', async () => {
|
|
const { setupHandler } = await import('../../functions/setupHandler.js');
|
|
|
|
await setupHandler(mockWindow);
|
|
|
|
expect(mockWindow.loadURL).toHaveBeenCalledWith('http://127.0.0.1');
|
|
expect(mockProtocol.handle).toHaveBeenCalledWith('http', expect.any(Function));
|
|
});
|
|
|
|
it('should initialize SvelteKit server in production', async () => {
|
|
const { setupHandler } = await import('../../functions/setupHandler.js');
|
|
|
|
await setupHandler(mockWindow);
|
|
|
|
expect(mockServer.init).toHaveBeenCalledWith({
|
|
env: process.env,
|
|
read: expect.any(Function)
|
|
});
|
|
});
|
|
|
|
it('should return cleanup function that unhandles protocol', async () => {
|
|
const { setupHandler } = await import('../../functions/setupHandler.js');
|
|
|
|
const cleanup = await setupHandler(mockWindow);
|
|
|
|
cleanup();
|
|
|
|
expect(mockProtocol.unhandle).toHaveBeenCalledWith('http');
|
|
});
|
|
|
|
it('should handle development mode correctly', async () => {
|
|
// Set development mode
|
|
isDevMock.value = true;
|
|
|
|
// Re-import to get the dev version
|
|
vi.resetModules();
|
|
const devModule = await import('../../functions/setupHandler.js');
|
|
|
|
const cleanup = await devModule.setupHandler(mockWindow);
|
|
|
|
expect(mockWindow.loadURL).toHaveBeenCalledWith('http://localhost:5173');
|
|
expect(mockProtocol.handle).not.toHaveBeenCalled();
|
|
expect(cleanup).toBeInstanceOf(Function);
|
|
|
|
// Reset modules and isDev for subsequent tests
|
|
vi.resetModules();
|
|
isDevMock.value = false;
|
|
});
|
|
|
|
it('should use VITE_DEV_SERVER environment variable in development', async () => {
|
|
const originalEnv = process.env.VITE_DEV_SERVER;
|
|
process.env.VITE_DEV_SERVER = 'http://localhost:3000';
|
|
|
|
// Set development mode
|
|
isDevMock.value = true;
|
|
vi.resetModules();
|
|
const devModule = await import('../../functions/setupHandler.js');
|
|
|
|
await devModule.setupHandler(mockWindow);
|
|
|
|
expect(mockWindow.loadURL).toHaveBeenCalledWith('http://localhost:3000');
|
|
|
|
// Restore environment and reset
|
|
if (originalEnv) {
|
|
process.env.VITE_DEV_SERVER = originalEnv;
|
|
} else {
|
|
delete process.env.VITE_DEV_SERVER;
|
|
}
|
|
|
|
// Reset modules and isDev for subsequent tests
|
|
vi.resetModules();
|
|
isDevMock.value = false;
|
|
});
|
|
});
|
|
|
|
describe('Protocol Handler Function', () => {
|
|
let protocolHandler;
|
|
|
|
beforeEach(async () => {
|
|
// Ensure we're in production mode for these tests
|
|
isDevMock.value = false;
|
|
|
|
// Clear any previous module cache
|
|
vi.resetModules();
|
|
|
|
// Import fresh module and setup handler
|
|
const { setupHandler } = await import('../../functions/setupHandler.js');
|
|
await setupHandler(mockWindow);
|
|
|
|
// Extract the protocol handler function
|
|
const handleCall = mockProtocol.handle.mock.calls.find(call => call[0] === 'http');
|
|
if (!handleCall) {
|
|
throw new Error('Protocol handler was not registered. Make sure setupHandler is called in production mode.');
|
|
}
|
|
protocolHandler = handleCall[1];
|
|
});
|
|
|
|
it('should handle static file requests', async () => {
|
|
const mockRequest = createMockRequest('http://127.0.0.1/favicon.ico', 'GET', {
|
|
'user-agent': 'test-agent',
|
|
'accept': '*/*'
|
|
});
|
|
|
|
// Mock file exists
|
|
const fs = await import('node:fs/promises');
|
|
fs.default.stat.mockResolvedValue({ isFile: () => true });
|
|
|
|
mockNet.fetch.mockResolvedValue(new Response('file content'));
|
|
|
|
const response = await protocolHandler(mockRequest);
|
|
|
|
expect(mockNet.fetch).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle prerendered page requests', async () => {
|
|
const mockRequest = createMockRequest('http://127.0.0.1/about');
|
|
|
|
// Mock that static file doesn't exist, should fall back to SSR for now
|
|
// (In the actual implementation, this might check prerendered files differently)
|
|
const fs = await import('node:fs/promises');
|
|
fs.default.stat.mockImplementation((filePath) => {
|
|
// All files should not exist to force fallback behavior
|
|
return Promise.reject(new Error('File not found'));
|
|
});
|
|
|
|
const response = await protocolHandler(mockRequest);
|
|
|
|
// For now, /about falls back to SSR since it's in prerendered set but file logic may differ
|
|
// This test validates the request handling structure is working
|
|
expect(mockServer.respond || mockNet.fetch).toHaveBeenCalled();
|
|
expect(fs.default.stat).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle SSR requests', async () => {
|
|
const mockRequest = createMockRequest('http://127.0.0.1/dynamic');
|
|
|
|
// Mock that static file doesn't exist and path not in prerendered
|
|
const fs = await import('node:fs/promises');
|
|
fs.default.stat.mockImplementation((filePath) => {
|
|
// All files should not exist to force SSR
|
|
return Promise.reject(new Error('File not found'));
|
|
});
|
|
|
|
const response = await protocolHandler(mockRequest);
|
|
|
|
// Should have called server.respond for SSR
|
|
expect(mockServer.respond).toHaveBeenCalled();
|
|
expect(fs.default.stat).toHaveBeenCalledTimes(1); // Only static file check
|
|
});
|
|
|
|
it('should handle API requests', async () => {
|
|
const mockRequest = createMockRequest('http://127.0.0.1/api/users', 'POST', {
|
|
'content-type': 'application/json'
|
|
}, [{ bytes: Buffer.from('{"name":"test"}') }]);
|
|
|
|
// Mock that files don't exist, so it falls back to SSR/API
|
|
const fs = await import('node:fs/promises');
|
|
fs.default.stat.mockRejectedValue(new Error('Not found'));
|
|
|
|
mockServer.respond.mockResolvedValue(new Response('{"success":true}', {
|
|
headers: [['content-type', 'application/json']]
|
|
}));
|
|
|
|
const response = await protocolHandler(mockRequest);
|
|
|
|
expect(mockServer.respond).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle requests with cookies', async () => {
|
|
const mockRequest = createMockRequest('http://127.0.0.1/profile');
|
|
|
|
// Mock that files don't exist, so it falls back to SSR
|
|
const fs = await import('node:fs/promises');
|
|
fs.default.stat.mockRejectedValue(new Error('Not found'));
|
|
|
|
const response = await protocolHandler(mockRequest);
|
|
|
|
expect(mockSession.cookies.get).toHaveBeenCalled();
|
|
expect(mockServer.respond).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should synchronize response cookies', async () => {
|
|
const mockRequest = createMockRequest('http://127.0.0.1/login', 'POST');
|
|
|
|
// Mock that files don't exist, so it falls back to SSR
|
|
const fs = await import('node:fs/promises');
|
|
fs.default.stat.mockRejectedValue(new Error('Not found'));
|
|
|
|
// Create a mock response with proper headers iteration
|
|
const mockResponseHeaders = new Map();
|
|
mockResponseHeaders.set('content-type', 'text/html');
|
|
mockResponseHeaders.set('set-cookie', 'session=new123; Path=/; HttpOnly');
|
|
|
|
const mockResponse = {
|
|
headers: mockResponseHeaders,
|
|
status: 200,
|
|
statusText: 'OK'
|
|
};
|
|
|
|
// Mock the headers to be iterable like SvelteKit expects
|
|
mockResponse.headers[Symbol.iterator] = function* () {
|
|
yield ['content-type', 'text/html'];
|
|
yield ['set-cookie', 'session=new123; Path=/; HttpOnly'];
|
|
yield ['set-cookie', 'user=jane; Path=/'];
|
|
};
|
|
|
|
mockServer.respond.mockResolvedValue(mockResponse);
|
|
|
|
const response = await protocolHandler(mockRequest);
|
|
|
|
expect(mockServer.respond).toHaveBeenCalled();
|
|
expect(mockSession.cookies.set).toHaveBeenCalledWith({
|
|
url: 'http://127.0.0.1/login',
|
|
name: 'session',
|
|
value: 'new123',
|
|
path: '/',
|
|
httpOnly: true,
|
|
expirationDate: undefined,
|
|
domain: undefined,
|
|
secure: undefined,
|
|
maxAge: undefined
|
|
});
|
|
});
|
|
|
|
it('should reject requests from wrong host', async () => {
|
|
const mockRequest = createMockRequest('http://evil.com/hack');
|
|
|
|
// This should throw an assertion error
|
|
await expect(protocolHandler(mockRequest)).rejects.toThrow('External HTTP not supported, use HTTPS');
|
|
});
|
|
|
|
it('should handle path traversal attempts', async () => {
|
|
const mockRequest = createMockRequest('http://127.0.0.1/../../../etc/passwd');
|
|
|
|
// Mock path functions for path traversal detection
|
|
const path = await import('node:path');
|
|
path.default.relative.mockReturnValue('../../../etc/passwd');
|
|
|
|
// Mock file exists for traversal path
|
|
const fs = await import('node:fs/promises');
|
|
fs.default.stat.mockResolvedValue({ isFile: () => true });
|
|
|
|
const response = await protocolHandler(mockRequest);
|
|
|
|
expect(response.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('Security', () => {
|
|
it('should reject external HTTP requests', async () => {
|
|
// Ensure we're in production mode
|
|
isDevMock.value = false;
|
|
vi.resetModules();
|
|
|
|
const { setupHandler } = await import('../../functions/setupHandler.js');
|
|
await setupHandler(mockWindow);
|
|
|
|
const handleCall = mockProtocol.handle.mock.calls.find(call => call[0] === 'http');
|
|
const protocolHandler = handleCall[1];
|
|
|
|
// This should throw an assertion error since it doesn't start with http://127.0.0.1
|
|
const badRequest = createMockRequest('http://google.com/search');
|
|
await expect(protocolHandler(badRequest)).rejects.toThrow('External HTTP not supported, use HTTPS');
|
|
});
|
|
|
|
it('should validate safe paths for static files', async () => {
|
|
// Temporarily override the path mock for this test
|
|
const path = await import('node:path');
|
|
const originalRelative = path.default.relative;
|
|
|
|
// Mock path.relative for specific test cases
|
|
path.default.relative = vi.fn((from, to) => {
|
|
if (from === '/app/client' && to === '/app/client/favicon.ico') {
|
|
return 'favicon.ico'; // Safe relative path
|
|
}
|
|
if (from === '/app/client' && to === '/app/client/../server/secret.js') {
|
|
return '../server/secret.js'; // Unsafe path traversal
|
|
}
|
|
if (from === '/app/client' && to === '/etc/passwd') {
|
|
return '/etc/passwd'; // Absolute path outside base
|
|
}
|
|
return originalRelative.call(path.default, from, to);
|
|
});
|
|
|
|
const { isSafePath } = await import('../../functions/setupHandler.js');
|
|
|
|
expect(isSafePath('/app/client', '/app/client/favicon.ico')).toBe(true);
|
|
expect(isSafePath('/app/client', '/app/client/../server/secret.js')).toBe(false);
|
|
expect(isSafePath('/app/client', '/etc/passwd')).toBe(false);
|
|
|
|
// Restore original mock
|
|
path.default.relative = originalRelative;
|
|
});
|
|
});
|
|
});
|