mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 17:05:48 +00:00
* chore(plugins): re-bundle document-mode and document Stage A path Reverts the unbundling fromb0cae69ffe. Stage 0 (gzip + throttle, shipped in84625be849) handles size; document-mode remains opt-in per context, so cross-context conflict risk is bounded. Stage A (keyed plugin-persistence API, issue #7749) is the documented future path for closing the LWW gap on different-context concurrent edits — picked up when conflicts are observed in practice. Design sketch with multi-reviewed phasing lives in docs/plans/2026-05-23-stage-a-keyed-plugin-persistence.md. Predecessor plan's "Future work" section now links to it. * test(plugins): cover document-mode bundled load and PLUGIN_USER_DATA LWW Follow-ups from the multi-review of 199e816479's re-bundling decision: - E2E smoke test asserts document-mode appears in plugin management so a typo in BUNDLED_PLUGIN_PATHS fails loudly. - Spec exercises PLUGIN_USER_DATA conflict resolution end-to-end, which previously relied on analogy to REMINDER (same array+null branch) but was never directly asserted after the migration off 'virtual'. - Stage A plan risks: stale-editor-view gap surfaced by the review; PluginHooks.PERSISTED_DATA_UPDATE already exists in the API but is never dispatched host-side — wiring it is the path to a fix. - background.ts: comment marks the known gap at the registerHook site. * docs(plugins): plan for wiring PERSISTED_DATA_CHANGED hook Designs the host-side wiring for the currently-dead PluginHooks.PERSISTED_DATA_CHANGED so plugins can react to remote-driven changes to their persisted data. Multi-reviewed twice; v4 trims scope to host-only (no plugin adoption in this design) and preserves the multi-review insights as seeds for the follow-up doc-mode adoption tracked at issue #7752. Implementation lands in a separate PR. * test: strengthen unit test assertions and revive disabled plugin specs Replace tautological assertions, setTimeout-without-expect patterns, and placeholder `expect(true).toBe(true)` tests with real assertions across ~30 spec files. Revive 5 plugin spec files that were fully commented out on master with live tests covering core behavior. Production-side: extract pure helpers for testability: - app.component: getBackgroundOverlayOpacity, getBackgroundImageBlur - android-sync-bridge.effects: getSuperSyncCredentialBridgeCommand - super-sync-server: export escapeHtml, SERVER_HELMET_CONFIG Delete the always-skipped xdescribe placeholder src/app/imex/sync/sync-fixes.spec.ts (412 LOC). Rename operation-log-stress.spec.ts to .benchmark.ts to match its header comment ("excluded from regular test runs"). No production behavior changes; no master commits reverted.
478 lines
14 KiB
TypeScript
478 lines
14 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import Fastify, { FastifyInstance } from 'fastify';
|
|
import helmet from '@fastify/helmet';
|
|
import {
|
|
escapeHtml,
|
|
sanitizeRequestUrlForLog,
|
|
SERVER_HELMET_CONFIG,
|
|
} from '../src/server';
|
|
|
|
describe('Server Security Configuration', () => {
|
|
describe('request URL log sanitization', () => {
|
|
it('should redact sensitive query params without removing non-sensitive context', () => {
|
|
expect(
|
|
sanitizeRequestUrlForLog(
|
|
'/api/sync/ws?token=secret-jwt&clientId=B_AEh6&limit=10',
|
|
),
|
|
).toBe('/api/sync/ws?token=redacted&clientId=B_AEh6&limit=10');
|
|
});
|
|
|
|
it('should redact sensitive query params case-insensitively', () => {
|
|
expect(sanitizeRequestUrlForLog('/reset-password?resetPasswordToken=secret')).toBe(
|
|
'/reset-password?resetPasswordToken=redacted',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Content Security Policy', () => {
|
|
let app: FastifyInstance;
|
|
|
|
beforeEach(async () => {
|
|
app = Fastify();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (app) {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it('should include CSP headers in response', async () => {
|
|
await app.register(helmet, SERVER_HELMET_CONFIG);
|
|
|
|
app.get('/test', async () => ({ status: 'ok' }));
|
|
await app.ready();
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/test',
|
|
});
|
|
|
|
// Check that CSP header is present
|
|
const cspHeader = response.headers['content-security-policy'];
|
|
expect(cspHeader).toBeDefined();
|
|
|
|
// Verify key CSP directives
|
|
expect(cspHeader).toContain("default-src 'self'");
|
|
expect(cspHeader).toContain("script-src 'self'");
|
|
expect(cspHeader).toContain("object-src 'none'");
|
|
expect(cspHeader).toContain("frame-ancestors 'none'");
|
|
});
|
|
|
|
it('should include X-Frame-Options header', async () => {
|
|
await app.register(helmet);
|
|
app.get('/test', async () => ({ status: 'ok' }));
|
|
await app.ready();
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/test',
|
|
});
|
|
|
|
// Helmet sets X-Frame-Options by default
|
|
expect(response.headers['x-frame-options']).toBeDefined();
|
|
});
|
|
|
|
it('should include X-Content-Type-Options header', async () => {
|
|
await app.register(helmet);
|
|
app.get('/test', async () => ({ status: 'ok' }));
|
|
await app.ready();
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/test',
|
|
});
|
|
|
|
expect(response.headers['x-content-type-options']).toBe('nosniff');
|
|
});
|
|
});
|
|
|
|
describe('HTML Escape Function', () => {
|
|
it('should escape < and > characters', () => {
|
|
const input = '<script>alert("xss")</script>';
|
|
const escaped = escapeHtml(input);
|
|
expect(escaped).toBe('<script>alert("xss")</script>');
|
|
expect(escaped).not.toContain('<');
|
|
expect(escaped).not.toContain('>');
|
|
});
|
|
|
|
it('should escape ampersand', () => {
|
|
const input = 'Tom & Jerry';
|
|
const escaped = escapeHtml(input);
|
|
expect(escaped).toBe('Tom & Jerry');
|
|
});
|
|
|
|
it('should escape double quotes', () => {
|
|
const input = 'He said "hello"';
|
|
const escaped = escapeHtml(input);
|
|
expect(escaped).toBe('He said "hello"');
|
|
});
|
|
|
|
it('should escape single quotes', () => {
|
|
const input = "It's a test";
|
|
const escaped = escapeHtml(input);
|
|
expect(escaped).toBe('It's a test');
|
|
});
|
|
|
|
it('should handle multiple special characters', () => {
|
|
const input = '<div class="test" data-value=\'a & b\'>content</div>';
|
|
const escaped = escapeHtml(input);
|
|
expect(escaped).toBe(
|
|
'<div class="test" data-value='a & b'>content</div>',
|
|
);
|
|
});
|
|
|
|
it('should handle empty string', () => {
|
|
expect(escapeHtml('')).toBe('');
|
|
});
|
|
|
|
it('should handle string with no special characters', () => {
|
|
const input = 'Hello World';
|
|
expect(escapeHtml(input)).toBe('Hello World');
|
|
});
|
|
|
|
it('should escape quotes to prevent attribute injection', () => {
|
|
// An attacker might try to break out of an attribute and add an event handler
|
|
const input = '" onmouseover="alert(1)"';
|
|
const escaped = escapeHtml(input);
|
|
// The quotes are escaped, so even though 'onmouseover' appears, it's harmless text
|
|
// because the quote before it is escaped and won't break out of the attribute
|
|
expect(escaped).toBe('" onmouseover="alert(1)"');
|
|
// The key protection is that " is escaped to "
|
|
expect(escaped).not.toContain('"');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Password Reset Page', () => {
|
|
let app: FastifyInstance;
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (app) {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it('should render password reset form with token', async () => {
|
|
const { pageRoutes } = await import('../src/pages');
|
|
|
|
app = Fastify();
|
|
await app.register(pageRoutes, { prefix: '/' });
|
|
await app.ready();
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/reset-password?token=test-token-123',
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.headers['content-type']).toContain('text/html');
|
|
|
|
const html = response.body;
|
|
expect(html).toContain('<title>Reset Password</title>');
|
|
expect(html).toContain('<form id="resetForm">');
|
|
expect(html).toContain('type="password"');
|
|
expect(html).toContain('Minimum 12 characters');
|
|
// Token should be escaped in the JavaScript
|
|
expect(html).toContain('test-token-123');
|
|
});
|
|
|
|
it('should return 400 when token is missing', async () => {
|
|
const { pageRoutes } = await import('../src/pages');
|
|
|
|
app = Fastify();
|
|
await app.register(pageRoutes, { prefix: '/' });
|
|
await app.ready();
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/reset-password',
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.body).toBe('Token is required');
|
|
});
|
|
|
|
it('should escape malicious token in data attribute', async () => {
|
|
const { pageRoutes } = await import('../src/pages');
|
|
|
|
app = Fastify();
|
|
await app.register(pageRoutes, { prefix: '/' });
|
|
await app.ready();
|
|
|
|
// Test attribute breakout attempt - double quotes should be escaped
|
|
const maliciousToken = '"><script>alert(1)</script>';
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: `/reset-password?token=${encodeURIComponent(maliciousToken)}`,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const html = response.body;
|
|
|
|
// Token is in data-token attribute, escapeHtml prevents attribute breakout
|
|
expect(html).toContain('data-token="');
|
|
// The raw attack string should not appear unescaped
|
|
expect(html).not.toContain('"><script>');
|
|
// Double quotes are escaped as "
|
|
expect(html).toContain('"');
|
|
});
|
|
|
|
it('should escape script tags in token to prevent XSS', async () => {
|
|
const { pageRoutes } = await import('../src/pages');
|
|
|
|
app = Fastify();
|
|
await app.register(pageRoutes, { prefix: '/' });
|
|
await app.ready();
|
|
|
|
const maliciousToken = '</script><script>alert("xss")</script>';
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: `/reset-password?token=${encodeURIComponent(maliciousToken)}`,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const html = response.body;
|
|
|
|
// escapeHtml escapes < as < to prevent injection
|
|
expect(html).not.toContain('</script><script>');
|
|
expect(html).toContain('<'); // < escaped as HTML entity
|
|
});
|
|
});
|
|
|
|
describe('Email Verification Page', () => {
|
|
let app: FastifyInstance;
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (app) {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it('should await verifyEmail before sending response', async () => {
|
|
// Mock the verifyEmail function to track if it was awaited
|
|
let verifyEmailCompleted = false;
|
|
vi.doMock('../src/auth', () => ({
|
|
verifyEmail: vi.fn().mockImplementation(async () => {
|
|
// Simulate async work
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
verifyEmailCompleted = true;
|
|
return true;
|
|
}),
|
|
}));
|
|
|
|
const { pageRoutes } = await import('../src/pages');
|
|
|
|
app = Fastify();
|
|
await app.register(pageRoutes, { prefix: '/' });
|
|
await app.ready();
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/verify-email?token=valid-token',
|
|
});
|
|
|
|
// The response should only be sent after verifyEmail completes
|
|
expect(response.statusCode).toBe(200);
|
|
expect(verifyEmailCompleted).toBe(true);
|
|
expect(response.body).toContain('Email Verified');
|
|
});
|
|
|
|
it('should handle verification errors properly', async () => {
|
|
vi.doMock('../src/auth', () => ({
|
|
verifyEmail: vi.fn().mockRejectedValue(new Error('Invalid verification token')),
|
|
}));
|
|
|
|
const { pageRoutes } = await import('../src/pages');
|
|
|
|
app = Fastify();
|
|
await app.register(pageRoutes, { prefix: '/' });
|
|
await app.ready();
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/verify-email?token=invalid-token',
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.body).toContain('Verification failed');
|
|
});
|
|
|
|
it('should return 400 when token is missing', async () => {
|
|
const { pageRoutes } = await import('../src/pages');
|
|
|
|
app = Fastify();
|
|
await app.register(pageRoutes, { prefix: '/' });
|
|
await app.ready();
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/verify-email',
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.body).toBe('Token is required');
|
|
});
|
|
});
|
|
|
|
describe('CORS with wildcard origins', () => {
|
|
let app: FastifyInstance;
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (app) {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it('should allow requests from subdomain matching wildcard pattern', async () => {
|
|
const cors = await import('@fastify/cors');
|
|
|
|
app = Fastify();
|
|
await app.register(cors.default, {
|
|
origin: [/^https:\/\/[^\/]+\.preview\.example\.com$/],
|
|
methods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'OPTIONS'],
|
|
credentials: true,
|
|
});
|
|
|
|
app.get('/health', async () => ({ status: 'ok' }));
|
|
await app.ready();
|
|
|
|
const response = await app.inject({
|
|
method: 'OPTIONS',
|
|
url: '/health',
|
|
headers: {
|
|
origin: 'https://abc123.preview.example.com',
|
|
'access-control-request-method': 'GET',
|
|
},
|
|
});
|
|
|
|
expect(response.headers['access-control-allow-origin']).toBe(
|
|
'https://abc123.preview.example.com',
|
|
);
|
|
expect(response.headers['access-control-allow-credentials']).toBe('true');
|
|
});
|
|
|
|
it('should reject requests from non-matching origin', async () => {
|
|
const cors = await import('@fastify/cors');
|
|
|
|
app = Fastify();
|
|
await app.register(cors.default, {
|
|
origin: [/^https:\/\/[^\/]+\.preview\.example\.com$/],
|
|
methods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'OPTIONS'],
|
|
credentials: true,
|
|
});
|
|
|
|
app.get('/health', async () => ({ status: 'ok' }));
|
|
await app.ready();
|
|
|
|
const response = await app.inject({
|
|
method: 'OPTIONS',
|
|
url: '/health',
|
|
headers: {
|
|
origin: 'https://evil.com',
|
|
'access-control-request-method': 'GET',
|
|
},
|
|
});
|
|
|
|
expect(response.headers['access-control-allow-origin']).toBeUndefined();
|
|
});
|
|
|
|
it('should allow multiple wildcard patterns', async () => {
|
|
const cors = await import('@fastify/cors');
|
|
|
|
app = Fastify();
|
|
await app.register(cors.default, {
|
|
origin: [
|
|
/^https:\/\/[^\/]+\.preview\.example\.com$/,
|
|
/^https:\/\/[^\/]+\.staging\.example\.com$/,
|
|
],
|
|
methods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'OPTIONS'],
|
|
credentials: true,
|
|
});
|
|
|
|
app.get('/health', async () => ({ status: 'ok' }));
|
|
await app.ready();
|
|
|
|
// Test first pattern
|
|
const response1 = await app.inject({
|
|
method: 'OPTIONS',
|
|
url: '/health',
|
|
headers: {
|
|
origin: 'https://feature-123.preview.example.com',
|
|
'access-control-request-method': 'GET',
|
|
},
|
|
});
|
|
|
|
expect(response1.headers['access-control-allow-origin']).toBe(
|
|
'https://feature-123.preview.example.com',
|
|
);
|
|
|
|
// Test second pattern
|
|
const response2 = await app.inject({
|
|
method: 'OPTIONS',
|
|
url: '/health',
|
|
headers: {
|
|
origin: 'https://test.staging.example.com',
|
|
'access-control-request-method': 'GET',
|
|
},
|
|
});
|
|
|
|
expect(response2.headers['access-control-allow-origin']).toBe(
|
|
'https://test.staging.example.com',
|
|
);
|
|
});
|
|
|
|
it('should work with mixed string and RegExp origins', async () => {
|
|
const cors = await import('@fastify/cors');
|
|
|
|
app = Fastify();
|
|
await app.register(cors.default, {
|
|
origin: ['https://app.example.com', /^https:\/\/[^\/]+\.preview\.example\.com$/],
|
|
methods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'OPTIONS'],
|
|
credentials: true,
|
|
});
|
|
|
|
app.get('/health', async () => ({ status: 'ok' }));
|
|
await app.ready();
|
|
|
|
// Test string origin
|
|
const response1 = await app.inject({
|
|
method: 'OPTIONS',
|
|
url: '/health',
|
|
headers: {
|
|
origin: 'https://app.example.com',
|
|
'access-control-request-method': 'GET',
|
|
},
|
|
});
|
|
|
|
expect(response1.headers['access-control-allow-origin']).toBe(
|
|
'https://app.example.com',
|
|
);
|
|
|
|
// Test RegExp origin
|
|
const response2 = await app.inject({
|
|
method: 'OPTIONS',
|
|
url: '/health',
|
|
headers: {
|
|
origin: 'https://pr-456.preview.example.com',
|
|
'access-control-request-method': 'GET',
|
|
},
|
|
});
|
|
|
|
expect(response2.headers['access-control-allow-origin']).toBe(
|
|
'https://pr-456.preview.example.com',
|
|
);
|
|
});
|
|
});
|