feat(matrix): add tests, E2EE warning, and dynamic homeserver config

- Make SSO loginToken homeserver configurable via VITE_MATRIX_HOMESERVER
- Add vitest setup with 14 unit tests for Matrix client functions
  (discoverHomeserver, checkHomeserver, loginWithToken)
- Show amber warning banner when E2EE is not available

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 12:18:07 +01:00
parent a4f52df138
commit 416e031f69
7 changed files with 726 additions and 603 deletions

View file

@ -12,7 +12,9 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"format": "prettier --write .",
"lint": "eslint ."
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@manacore/shared-pwa": "workspace:*",
@ -32,6 +34,7 @@
"typescript": "^5.9.3",
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^4.1.0",
"workbox-window": "^7.4.0"
},
"dependencies": {

View file

@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { discoverHomeserver, checkHomeserver, loginWithToken } from './client';
// Mock matrix-js-sdk to avoid importing the full SDK in tests
vi.mock('matrix-js-sdk', () => ({
createClient: vi.fn(),
}));
vi.mock('./polyfills', () => ({}));
describe('discoverHomeserver', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('extracts domain from Matrix user ID', async () => {
// Mock .well-known failing so we get the fallback
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')));
const result = await discoverHomeserver('@user:example.com');
expect(result).toBe('https://example.com');
});
it('returns null for invalid user ID without domain', async () => {
const result = await discoverHomeserver('@user');
expect(result).toBeNull();
});
it('uses domain directly when no @ prefix', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')));
const result = await discoverHomeserver('matrix.org');
expect(result).toBe('https://matrix.org');
});
it('strips protocol prefix from domain', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')));
const result = await discoverHomeserver('https://matrix.org');
expect(result).toBe('https://matrix.org');
});
it('uses .well-known base_url when available', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
'm.homeserver': { base_url: 'https://synapse.example.com/' },
}),
})
);
const result = await discoverHomeserver('example.com');
expect(result).toBe('https://synapse.example.com');
});
});
describe('checkHomeserver', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('returns ok for reachable server', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
const result = await checkHomeserver('matrix.mana.how');
expect(result).toEqual({ ok: true });
});
it('prepends https:// if missing', async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal('fetch', mockFetch);
await checkHomeserver('matrix.mana.how');
expect(mockFetch).toHaveBeenCalledWith('https://matrix.mana.how/_matrix/client/versions', {
method: 'GET',
});
});
it('does not double-prepend https://', async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal('fetch', mockFetch);
await checkHomeserver('https://matrix.mana.how');
expect(mockFetch).toHaveBeenCalledWith('https://matrix.mana.how/_matrix/client/versions', {
method: 'GET',
});
});
it('returns error for non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 502 }));
const result = await checkHomeserver('matrix.mana.how');
expect(result).toEqual({ ok: false, error: 'Server returned 502' });
});
it('returns error for network failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Failed to fetch')));
const result = await checkHomeserver('matrix.mana.how');
expect(result).toEqual({ ok: false, error: 'Failed to fetch' });
});
});
describe('loginWithToken', () => {
it('normalizes homeserver URL', async () => {
const result = await loginWithToken('matrix.mana.how', 'token123', '@user:matrix.mana.how');
expect(result.success).toBe(true);
expect(result.credentials?.homeserver).toBe('https://matrix.mana.how');
});
it('removes trailing slash from homeserver', async () => {
const result = await loginWithToken(
'https://matrix.mana.how/',
'token123',
'@user:matrix.mana.how'
);
expect(result.credentials?.homeserver).toBe('https://matrix.mana.how');
});
it('preserves provided deviceId', async () => {
const result = await loginWithToken(
'matrix.mana.how',
'token123',
'@user:matrix.mana.how',
'MYDEVICE'
);
expect(result.credentials?.deviceId).toBe('MYDEVICE');
});
it('generates deviceId when not provided', async () => {
const result = await loginWithToken('matrix.mana.how', 'token123', '@user:matrix.mana.how');
expect(result.credentials?.deviceId).toMatch(/^MANA_\d+$/);
});
});

View file

@ -27,6 +27,7 @@
import { setLocale, supportedLocales } from '$lib/i18n';
const AUTH_URL = import.meta.env.VITE_MANA_AUTH_URL || 'https://auth.mana.how';
const MATRIX_HOMESERVER = import.meta.env.VITE_MATRIX_HOMESERVER || 'matrix.mana.how';
/**
* Exchange session cookie for JWT token from mana-core-auth
@ -185,7 +186,7 @@
if (loginToken) {
// Exchange loginToken for Matrix credentials
const result = await loginWithLoginToken('matrix.mana.how', loginToken);
const result = await loginWithLoginToken(MATRIX_HOMESERVER, loginToken);
if (result.success && result.credentials) {
// Remove loginToken from URL to prevent re-processing on refresh

View file

@ -12,7 +12,7 @@
import SearchDialog from '$lib/components/chat/SearchDialog.svelte';
import ForwardMessageDialog from '$lib/components/chat/ForwardMessageDialog.svelte';
import { CallView, IncomingCallDialog } from '$lib/components/call';
import { ChatCircle, Plus, Gear } from '@manacore/shared-icons';
import { ChatCircle, Plus, Gear, ShieldWarning } from '@manacore/shared-icons';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
@ -218,6 +218,15 @@
</div>
</div>
{#if !matrixStore.cryptoReady}
<div
class="flex items-center gap-2 px-4 py-2 bg-amber-500/10 border-b border-amber-500/20 text-amber-600 dark:text-amber-400 text-xs"
>
<ShieldWarning class="h-3.5 w-3.5 shrink-0" />
<span>Verschlüsselung nicht verfügbar</span>
</div>
{/if}
<!-- Room List -->
<div class="flex-1 min-h-0 overflow-hidden">
<RoomList onCreateRoom={() => (showCreateRoom = true)} onSelectRoom={handleSelectRoom} />
@ -262,6 +271,15 @@
</p>
</div>
{#if !matrixStore.cryptoReady}
<div
class="flex items-center gap-2 px-3 py-1.5 bg-amber-500/10 border-b border-amber-500/20 text-amber-600 dark:text-amber-400 text-xs"
>
<ShieldWarning class="h-3.5 w-3.5 shrink-0" />
<span>Verschlüsselung nicht verfügbar</span>
</div>
{/if}
<!-- Room List -->
<div class="flex-1 min-h-0 overflow-hidden">
<RoomList onCreateRoom={() => (showCreateRoom = true)} onSelectRoom={handleSelectRoom} />

View file

@ -0,0 +1,4 @@
export const browser = false;
export const building = false;
export const dev = true;
export const version = 'test';

View file

@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
export default defineConfig({
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
globals: true,
environment: 'node',
},
resolve: {
alias: {
$lib: resolve('./src/lib'),
'$app/environment': resolve('./src/test/mocks/app-environment.ts'),
},
},
});

1145
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff