feat(public-share): Oeffentliche Share-Zugriffsseite fuer externe Besucher + SPA-Links im DMS-ShareDialog — Modul 13/16 des UI-Backlogs

This commit is contained in:
Agent Zero
2026-09-15 23:16:30 +02:00
parent 7097e28578
commit 00f8f100d7
7 changed files with 452 additions and 2 deletions
@@ -0,0 +1,157 @@
/**
* PublicShare page tests — external share link access UI (UI-Backlog M13).
*
* Covers: loading, unlocked state with download, password flow (required,
* invalid, valid), error states (404 not found, 410 expired, other).
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { PublicSharePage } from '@/pages/PublicShare';
import type { PublicShareInfo } from '@/api/publicShare';
const { fetchInfo, verifyPw } = vi.hoisted(() => ({
fetchInfo: vi.fn(),
verifyPw: vi.fn(),
}));
const makeInfo = (overrides: Partial<PublicShareInfo> = {}): PublicShareInfo => ({
file_name: 'Angebot.pdf',
file_size: 102400,
mime_type: 'application/pdf',
access_level: 'download',
requires_password: false,
expires_at: null,
...overrides,
});
vi.mock('@/api/publicShare', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api/publicShare')>();
return {
...actual,
fetchPublicShareInfo: fetchInfo,
verifyPublicSharePassword: verifyPw,
};
});
function renderSharePage(token = 'test-token-123') {
return render(
<MemoryRouter initialEntries={[`/share/${token}`]}>
<Routes>
<Route path="/share/:token" element={<PublicSharePage />} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
fetchInfo.mockReset();
verifyPw.mockReset();
});
describe('PublicSharePage', () => {
it('shows loading state initially', () => {
fetchInfo.mockReturnValue(new Promise(() => {}));
renderSharePage();
expect(screen.getByTestId('public-share-loading')).toBeInTheDocument();
});
it('shows unlocked file card with download link for unprotected shares', async () => {
fetchInfo.mockResolvedValue(makeInfo());
renderSharePage();
await waitFor(() => {
expect(screen.getByTestId('public-share-unlocked')).toBeInTheDocument();
});
expect(screen.getByTestId('public-share-file-name')).toHaveTextContent('Angebot.pdf');
const dl = screen.getByTestId('public-share-download-btn');
expect(dl).toHaveAttribute('href', '/api/v1/public/share/test-token-123/download');
});
it('shows expiry date when the link expires', async () => {
fetchInfo.mockResolvedValue(makeInfo({ expires_at: '2026-12-01T12:00:00Z' }));
renderSharePage();
await waitFor(() => {
expect(screen.getByTestId('public-share-unlocked')).toBeInTheDocument();
});
expect(screen.getByTestId('public-share-expiry')).toBeInTheDocument();
});
it('requires a password for protected shares and unlocks on valid password', async () => {
fetchInfo.mockResolvedValue(makeInfo({ requires_password: true }));
verifyPw.mockResolvedValue({ valid: true });
renderSharePage();
await waitFor(() => {
expect(screen.getByTestId('public-share-password-input')).toBeInTheDocument();
});
expect(screen.queryByTestId('public-share-download-btn')).not.toBeInTheDocument();
fireEvent.change(screen.getByTestId('public-share-password-input'), {
target: { value: 'geheim123' },
});
fireEvent.click(screen.getByTestId('public-share-unlock-btn'));
await waitFor(() => {
expect(screen.getByTestId('public-share-unlocked')).toBeInTheDocument();
});
expect(verifyPw).toHaveBeenCalledWith('test-token-123', 'geheim123');
expect(screen.getByTestId('public-share-download-btn')).toBeInTheDocument();
});
it('shows an error message on invalid password', async () => {
fetchInfo.mockResolvedValue(makeInfo({ requires_password: true }));
verifyPw.mockRejectedValue(new Error('403'));
renderSharePage();
await waitFor(() => {
expect(screen.getByTestId('public-share-password-input')).toBeInTheDocument();
});
fireEvent.change(screen.getByTestId('public-share-password-input'), {
target: { value: 'falsch' },
});
fireEvent.click(screen.getByTestId('public-share-unlock-btn'));
await waitFor(() => {
expect(screen.getByTestId('public-share-password-error')).toBeInTheDocument();
});
expect(screen.queryByTestId('public-share-unlocked')).not.toBeInTheDocument();
});
it('shows not-found error for 404 responses', async () => {
fetchInfo.mockRejectedValue({ response: { status: 404 } });
renderSharePage();
await waitFor(() => {
expect(screen.getByTestId('public-share-error')).toBeInTheDocument();
});
expect(screen.getByText('Link nicht gefunden')).toBeInTheDocument();
});
it('shows expired error for 410 responses', async () => {
fetchInfo.mockRejectedValue({ response: { status: 410 } });
renderSharePage();
await waitFor(() => {
expect(screen.getByTestId('public-share-error')).toBeInTheDocument();
});
expect(screen.getByText('Link abgelaufen')).toBeInTheDocument();
});
it('shows generic error for other failures', async () => {
fetchInfo.mockRejectedValue(new Error('network'));
renderSharePage();
await waitFor(() => {
expect(screen.getByTestId('public-share-error')).toBeInTheDocument();
});
expect(screen.getByText('Fehler')).toBeInTheDocument();
});
it('unlock button stays disabled without a password', async () => {
fetchInfo.mockResolvedValue(makeInfo({ requires_password: true }));
renderSharePage();
await waitFor(() => {
expect(screen.getByTestId('public-share-unlock-btn')).toBeInTheDocument();
});
expect(screen.getByTestId('public-share-unlock-btn')).toBeDisabled();
});
});