Skip to content
Open

Fix 920 #1027

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
1 change: 0 additions & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// @ts-nocheck
import cors from 'cors';
import express, { Request, Response } from 'express';
import { createServer } from 'http';
Expand Down
85 changes: 85 additions & 0 deletions backend/src/middleware/__tests__/errorHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Request, Response, NextFunction } from 'express';
import { errorHandler, LocalizedError } from '../errorHandler.js';
import * as sentry from '../../utils/sentry.js';

jest.mock('../../utils/sentry.js', () => ({
captureException: jest.fn(),
}));

describe('errorHandler', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let next: NextFunction;

beforeEach(() => {
req = {
t: jest.fn((key: string) => `translated:${key}`),
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
next = jest.fn();
jest.clearAllMocks();

// Silence console.error for clean test output
jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterAll(() => {
jest.restoreAllMocks();
});

it('translates LocalizedError and uses its status code', () => {
const error = new LocalizedError('auth.unauthorized', 401);

errorHandler(error, req as Request, res as Response, next);

expect(sentry.captureException).toHaveBeenCalledWith(error);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
status: 'error',
message: 'translated:auth.unauthorized',
});
});

it('falls back to key if translation function is missing on LocalizedError', () => {
req.t = undefined;
const error = new LocalizedError('auth.forbidden', 403);

errorHandler(error, req as Request, res as Response, next);

expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
status: 'error',
message: 'auth.forbidden',
});
});

it('handles standard Error by returning 500 and a generic message', () => {
const error = new Error('Database connection failed (sensitive info)');

errorHandler(error, req as Request, res as Response, next);

expect(sentry.captureException).toHaveBeenCalledWith(error);
expect(res.status).toHaveBeenCalledWith(500);
// Does not leak 'Database connection failed (sensitive info)'
expect(res.json).toHaveBeenCalledWith({
status: 'error',
message: 'translated:error.internal',
});
});

it('handles standard Error when translation function is missing', () => {
req.t = undefined;
const error = new Error('Some error');

errorHandler(error, req as Request, res as Response, next);

expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
status: 'error',
message: 'Internal server error',
});
});
});
80 changes: 80 additions & 0 deletions backend/src/middleware/__tests__/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Request, Response, NextFunction } from 'express';
import { createI18nMiddleware, resolveLocale } from '../i18n.js';
import { InMemoryTranslationRepository } from '../../services/i18n/translation.repository.js';

describe('resolveLocale', () => {
it('resolves locale from query parameter if supported', () => {
const req = { query: { locale: 'es' }, headers: {} } as unknown as Request;
const locale = resolveLocale(req, ['en', 'es', 'fr'], 'en');
expect(locale).toBe('es');
});

it('falls back to header if query param unsupported', () => {
const req = { query: { locale: 'de' }, headers: { 'accept-language': 'fr-FR,fr;q=0.9' } } as unknown as Request;
const locale = resolveLocale(req, ['en', 'es', 'fr'], 'en');
expect(locale).toBe('fr');
});

it('falls back to default if header and query are unsupported', () => {
const req = { query: {}, headers: { 'accept-language': 'de-DE,de;q=0.9' } } as unknown as Request;
const locale = resolveLocale(req, ['en', 'es', 'fr'], 'en');
expect(locale).toBe('en');
});

it('handles array headers safely', () => {
const req = { query: {}, headers: { 'accept-language': ['es-ES,es;q=0.9', 'en-US'] } } as unknown as Request;
const locale = resolveLocale(req, ['en', 'es', 'fr'], 'en');
expect(locale).toBe('es');
});
});

describe('createI18nMiddleware', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let next: NextFunction;

beforeEach(() => {
req = {
headers: {},
query: {},
};
res = {};
next = jest.fn();
});

it('adds locale and translation function to request', async () => {
const repo = new InMemoryTranslationRepository([
{ locale: 'en', namespace: 'platform', key: 'welcome', value: 'Welcome' },
{ locale: 'es', namespace: 'platform', key: 'welcome', value: 'Bienvenido' },
]);

const middleware = createI18nMiddleware({
repository: repo,
supportedLocales: ['en', 'es'],
defaultLocale: 'en',
});

req.headers = { 'accept-language': 'es-ES' };

await middleware(req as Request, res as Response, next);

expect(req.locale).toBe('es');
expect(req.translationNamespace).toBe('platform');
expect(req.t).toBeDefined();
expect(req.t!('welcome')).toBe('Bienvenido');
expect(next).toHaveBeenCalled();
});

it('safely handles missing translation keys by returning the key', async () => {
const repo = new InMemoryTranslationRepository([]);

const middleware = createI18nMiddleware({
repository: repo,
});

await middleware(req as Request, res as Response, next);

expect(req.t!('missing.key')).toBe('missing.key');
expect(next).toHaveBeenCalled();
});
});
21 changes: 20 additions & 1 deletion backend/src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { Request, Response, NextFunction } from 'express';
import { captureException } from '../utils/sentry.js';

export class LocalizedError extends Error {
constructor(
public readonly key: string,
public readonly status: number = 400
) {
super(key);
this.name = 'LocalizedError';
}
}

export const asyncHandler = (
fn: (req: Request, res: Response, next: NextFunction) => Promise<void>
) => {
Expand All @@ -13,8 +23,17 @@ export const asyncHandler = (
export const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => {
captureException(err);
console.error('Error:', err instanceof Error ? err.stack || err.message : err);

if (err instanceof LocalizedError) {
const message = req.t ? req.t(err.key) : err.key;
return res.status(err.status).json({
status: 'error',
message,
});
}

res.status(500).json({
status: 'error',
message: 'Internal server error',
message: req.t ? req.t('error.internal') : 'Internal server error',
});
};
10 changes: 8 additions & 2 deletions backend/src/middleware/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,18 @@ export interface I18nOptions {

const FALLBACK_LOCALE = 'en';

function parseAcceptLanguage(headerValue: string | undefined): string | null {
function parseAcceptLanguage(headerValue: string | string[] | undefined): string | null {
if (!headerValue) {
return null;
}

const first = headerValue.split(',')[0]?.trim();
const headerStr = Array.isArray(headerValue) ? headerValue[0] : headerValue;

if (!headerStr) {
return null;
}

const first = headerStr.split(',')[0]?.trim();
if (!first) {
return null;
}
Expand Down
1 change: 0 additions & 1 deletion backend/src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// @ts-nocheck
import { Router } from 'express';
import dashboardRoutes from '../dashboard/dashboard.routes.js';
import activityLogRouter from '../dashboard/activityLog.routes.js';
Expand Down
72 changes: 68 additions & 4 deletions frontend/src/components/collaboration/EncryptedRoomChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import {
} from '@/lib/p2p-crypto';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
import { Copy, KeyRound, Lock, Send, ShieldCheck } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Copy, KeyRound, Lock, Send, ShieldCheck, ShieldAlert, Shield } from 'lucide-react';
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { getItem, setItem } from '@/lib/localStorage';

interface RoomPayload {
id: string;
Expand Down Expand Up @@ -52,6 +53,13 @@ export function EncryptedRoomChat({ roomId }: { roomId: string }) {
const [draft, setDraft] = useState('');
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
const [error, setError] = useState<string | null>(null);
const [verifiedPeers, setVerifiedPeers] = useState<Set<string>>(new Set());
const [mismatchedPeers, setMismatchedPeers] = useState<Set<string>>(new Set());

useEffect(() => {
const stored = getItem<string[]>('p2p-verified-peers', []);
setVerifiedPeers(new Set(stored));
}, []);

useEffect(() => {
let mounted = true;
Expand All @@ -76,16 +84,20 @@ export function EncryptedRoomChat({ roomId }: { roomId: string }) {
const syncPeers = async () => {
const ownKeyId = localIdentityRef.current?.keyId;
const validPeers: P2PPublicIdentity[] = [];
const newMismatched = new Set<string>();

for (const identity of Array.from(identities.values())) {
if (identity.keyId === ownKeyId) continue;
if (await verifyP2PIdentity(identity)) {
validPeers.push(identity);
} else {
newMismatched.add(identity.keyId);
}
}

if (!mounted) return;
setPeers(validPeers);
setMismatchedPeers(newMismatched);
setSelectedPeerId((current) => current || validPeers[0]?.keyId || '');
};

Expand Down Expand Up @@ -170,6 +182,20 @@ export function EncryptedRoomChat({ roomId }: { roomId: string }) {
}
}

const togglePeerVerification = useCallback(() => {
if (!selectedPeerId) return;
setVerifiedPeers((prev) => {
const next = new Set(prev);
if (next.has(selectedPeerId)) {
next.delete(selectedPeerId);
} else {
next.add(selectedPeerId);
}
setItem('p2p-verified-peers', Array.from(next));
return next;
});
}, [selectedPeerId]);

async function copyIdentity() {
if (!localIdentity) return;
await navigator.clipboard.writeText(JSON.stringify(localIdentity));
Expand Down Expand Up @@ -231,16 +257,54 @@ export function EncryptedRoomChat({ roomId }: { roomId: string }) {
onChange={(event) => setSelectedPeerId(event.target.value)}
className="mt-2 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-gray-700 dark:bg-gray-950 dark:text-white"
>
<option value="">No verified peer online</option>
<option value="">No valid peer online</option>
{peers.map((peer) => (
<option key={peer.keyId} value={peer.keyId}>
{shortKey(peer.keyId)}
{shortKey(peer.keyId)} {verifiedPeers.has(peer.keyId) ? '(Verified)' : ''}
</option>
))}
</select>
</label>

{selectedPeer && (
<div className="mt-3 flex items-center justify-between rounded-md bg-gray-50 p-3 dark:bg-gray-800">
<div>
<div className="flex items-center gap-2 text-xs font-semibold text-gray-500 dark:text-gray-400">
{verifiedPeers.has(selectedPeer.keyId) ? (
<ShieldCheck className="h-3.5 w-3.5 text-emerald-500" />
) : (
<Shield className="h-3.5 w-3.5 text-gray-400" />
)}
Peer Fingerprint
</div>
<p className="mt-1 break-all font-mono text-xs text-gray-700 dark:text-gray-200" title={selectedPeer.keyId}>
{shortKey(selectedPeer.keyId)}
</p>
</div>
<button
type="button"
onClick={togglePeerVerification}
className={`text-xs font-semibold px-2 py-1.5 rounded-md border transition-colors ${
verifiedPeers.has(selectedPeer.keyId)
? 'border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-800/30 dark:bg-emerald-900/20 dark:text-emerald-400 dark:hover:bg-emerald-900/40'
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
{verifiedPeers.has(selectedPeer.keyId) ? 'Verified' : 'Verify Identity'}
</button>
</div>
)}
</div>

{mismatchedPeers.size > 0 && (
<div className="mx-4 mt-4 flex items-start gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/50 dark:text-amber-200">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
<p>
<strong>Warning:</strong> One or more peers in this room have an invalid identity fingerprint. Their messages have been blocked.
</p>
</div>
)}

{error && (
<div className="mx-4 mt-4 rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-200">
{error}
Expand Down
Loading