UX-03: anhang-vorschau
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Mail-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s

Frontend (web/mail-search): Anhang-Vorschau fuer PDF (Canvas-Rendering
via pdfjs-dist, gleiches Muster wie DOC-05s web/pdf-viewer/PdfViewer.tsx
-- kein aequivalentes PdfCanvasPreview im Repo gefunden, daher PdfViewer
als Vorlage uebernommen statt neu erfunden) und Bilder (<img> auf einem
authentifiziert geladenen Blob). Die Vorschauart haengt ausschliesslich
vom serverseitig per Magic-Bytes bestimmten contentType ab, nie von
einem Dateinamen (den es an der Schnittstelle gar nicht gibt); nicht
unterstuetzte Typen werden nur als Hinweistext angezeigt, nie
eingebettet. Kein iframe/<object>/dangerouslySetInnerHTML -- durch
automatisierten Sicherheitsreview-Test abgesichert. Neue lib/http.ts
(authentifizierter Zugriff ueber @nexarch/shl, identisch zu
web/pdf-viewer/lib/http.ts) und lib/attachments.ts (Anhangsliste/
-inhalt der Mail-API). Pruefprotokoll in mail/docs/UX-03-PRUEFPROTOKOLL.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-10-16 06:51:29 +00:00
1 parent c2d1623a19
commit f63fcb8a2d
18 files changed
+1123

No files matched your search

+1
View File
@@ -5,4 +5,5 @@ web/*/.next/
web/*/next-env.d.ts
web/*/tsconfig.tsbuildinfo
web/pdf-viewer/public/pdf.worker.min.mjs
web/mail-search/public/pdf.worker.min.mjs
__pycache__/
+227
View File
@@ -0,0 +1,227 @@
# UX-03 — Anhang-Vorschau: Prüfprotokoll
Datum: 2026-10-16
Host: 192.168.1.204 (Build/Test), frischer Klon in eigenem Arbeitsverzeichnis
Pakete: `mail/internal/mailapi` (geändert), `mail/internal/attachments` (Test ergänzt), `web/mail-search` (neu: Anhang-Vorschau)
## Umsetzung
### Backend (`mail/internal/mailapi`)
- Neue Route `GET /api/v1/mail/messages/{messageID}/attachments` —
liefert die Anhangsliste als `{index, contentType, size}[]`. Es gibt
keinen eigenen Anhang-Katalog (keine DB-Tabelle mit Dateinamen); wie
`mailexport.fetchAttachments` tastet der Handler die Indizes ab 0 ab,
bis `storage.ErrNotFound` den ersten fehlenden Index meldet
(`maxAttachmentProbe = 200`, dieselbe Schutzgrenze wie in
`mailexport`).
- `contentType` wird für JEDEN Anhang mit `net/http.DetectContentType`
auf den tatsächlich gelesenen Bytes bestimmt (Akzeptanzkriterium 2).
Es gibt an dieser Stelle gar keinen Dateinamen, der stattdessen
herangezogen werden könnte — `attachmentListItem` hat bewusst kein
Filename-Feld.
- `handleGetAttachment` (bestehende Route, INT-01) ist geändert: der
bisher hartkodierte `Content-Type: application/octet-stream` wird
durch `http.DetectContentType(content)` ersetzt; zusätzlich
`Content-Disposition: inline` (vorher kein Header) statt eines
erzwungenen Downloads — Grundlage für Akzeptanzkriterium 1 ("ohne
externen Download").
- `openapi.yaml` um den neuen Pfad `/messages/{messageID}/attachments`
samt Schema `AttachmentListResponse`/`AttachmentListItem` ergänzt;
`openapi_test.go`s Endpunkt-Abgleich (`TestOpenAPIDocument_MatchesActualEndpoints`)
und der Auth-Pflichtprüfungstest (`TestRoutes_RejectMissingOrInvalidToken`)
um die neue Route erweitert.
### Backend (`mail/internal/attachments`, IMP-02)
- Kein Verhaltens-, nur ein Testzugang: neuer Test
`TestExtract_WrongExtensionRealPDFContentDetectedByMagicBytes` belegt,
dass `VerifiedContentType` bereits vor UX-03 korrekt per Magic-Bytes
bestimmt wurde (`http.DetectContentType` auf den echten Bytes) und
dabei sowohl eine falsche Dateiendung (`.txt`) als auch einen falsch
deklarierten MIME-Typ (`text/plain`) ignoriert.
### Frontend (`web/mail-search`)
- `lib/http.ts` (neu): authentifizierter Zugriff auf Core-Modul-Token-
geschützte APIs über `@nexarch/shl` (`createSession`/`apiFetch`) —
identisches Muster wie `web/pdf-viewer/lib/http.ts` (DOC-05/UX-10),
nicht neu erfunden. `NEXT_PUBLIC_AUTH_MODE`, `NEXT_PUBLIC_CORE_API_BASE`,
`NEXT_PUBLIC_LOGIN_URL` (alles Umgebungsvariablen, keine Zugangsdaten
im Code).
- `lib/attachments.ts` (neu): dünner Client für die Anhangsliste/den
Anhanginhalt der Mail-API (`NEXT_PUBLIC_MAIL_API_URL`). Direkter
authentifizierter Aufruf wie bei `web/pdf-viewer/lib/api.ts`
(`documentContentUrl`) — kein Backend-for-Frontend-Proxy, weil die
Mail-API ein Bearer-Modul-Token verlangt, das ein Next.js-Server-Route-
Handler nicht eigenständig ausstellen kann. Liefert bei Ausfall
`{status:"unavailable"}` statt zu werfen (gleiche Konvention wie
`lib/caseContext.ts`).
- `lib/attachmentKind.ts` (neu): `classifyAttachment(contentType)` ist
die EINZIGE Stelle, die über die Vorschauart entscheidet — Eingabe
ausschließlich der serverseitig bestimmte `contentType`.
- `app/mail/[messageId]/PdfCanvasPreview.tsx` (neu): PDF-Vorschau per
Canvas-Rendering (pdfjs-dist, wie DOC-05s `PdfViewer.tsx` — gleiches
Grundmuster, hier auf Laden + Seitennavigation reduziert, ohne
Suche/Zoom). **Kein äquivalentes, bereits existierendes
`PdfCanvasPreview` wurde im Repository gefunden** (durchsucht:
gesamtes Repo inkl. aller Branches, auch DMS/dms-browser) — DOC-05s
`web/pdf-viewer/components/PdfViewer.tsx` ist das einzige vorhandene
Canvas-PDF-Muster und wurde als Vorlage übernommen (`workerSrc` aus
`public/pdf.worker.min.mjs`, `copy-pdf-worker`-Skript in
`package.json`, identisch zu pdf-viewer).
- `app/mail/[messageId]/ImagePreview.tsx` (neu): Bild-Vorschau per
`<img>` auf einem via `apiFetch` geladenen Blob (nativ sicheres
Bild-Decoding, kein Skript-/HTML-Rendering wie bei iframe — anders
als PDF ist hier kein Canvas-Zwang nötig, das Ticket verlangt Canvas
ausdrücklich nur für PDF).
- `app/mail/[messageId]/AttachmentPreview.tsx` (neu): wählt anhand
`classifyAttachment` zwischen PDF-/Bild-Vorschau oder einem neutralen
Hinweis ("Für diesen Dateityp ist keine Vorschau verfügbar.") — nie
iframe/`<object>`/`dangerouslySetInnerHTML`.
- `app/mail/[messageId]/AttachmentList.tsx` (neu): lädt die Anhangsliste
und rendert je Anhang eine `AttachmentPreview`; in `page.tsx` unter
`CaseContextPanel` eingebunden.
- `.gitignore`: `web/mail-search/public/pdf.worker.min.mjs` ergänzt
(gleiche Ausnahme wie für `web/pdf-viewer`).
## Prüfung 1: Test — Anhang mit falscher Dateiendung, aber echtem PDF-Inhalt wird korrekt als PDF erkannt und angezeigt
- Backend, Erkennung bei Extraktion (IMP-02):
`TestExtract_WrongExtensionRealPDFContentDetectedByMagicBytes`
(`mail/internal/attachments/attachments_test.go`) — Anhang mit Endung
`.txt`, deklariertem MIME-Typ `text/plain`, echtem PDF-Inhalt.
`VerifiedContentType` == `application/pdf`. **BESTANDEN.**
- Backend, Erkennung beim Abruf (UX-03):
`TestGetAttachment_ContentTypeFromMagicBytesIgnoresDeclaredType` und
`TestListAttachments_ContentTypesDetectedFromMagicBytes`
(`mail/internal/mailapi/mailapi_test.go`) — Anhang mit real im
Speicher abgelegtem PDF-Inhalt, aber als `text/plain` bzw.
`application/octet-stream` deklariert abgelegt. `GET .../attachments/0`
liefert `Content-Type: application/pdf`; `GET .../attachments` listet
`contentType: "application/pdf"`. **BESTANDEN.**
- Frontend: `lib/attachmentKind.test.ts` — `classifyAttachment` liefert
`"pdf"` für `application/pdf`, unabhängig von jeder Namensinformation
(`AttachmentInfo` trägt ohnehin keinen Dateinamen). `AttachmentPreview.test.tsx`
zeigt, dass ein Anhang mit `contentType: "application/pdf"` tatsächlich
die Canvas-PDF-Vorschau rendert. **BESTANDEN.**
## Prüfung 2: Sicherheitsreview bestätigt: keine iframe-Einbindung von Anhangsinhalt
- Automatisiert statt nur behauptet:
`app/mail/[messageId]/attachment-preview.security.test.ts` liest den
Quelltext von `PdfCanvasPreview.tsx`, `ImagePreview.tsx`,
`AttachmentPreview.tsx`, `AttachmentList.tsx` (Kommentare vor dem
Abgleich entfernt) und prüft die Abwesenheit von `<iframe`, `<object`
und `dangerouslySetInnerHTML` im tatsächlichen Code. **BESTANDEN.**
- Manuelles Review: `PdfCanvasPreview` rendert ausschließlich über
`<canvas>` + `CanvasRenderingContext2D` (pdfjs-dist `page.render`);
`ImagePreview` ausschließlich über `<img src={objectURL}>` (natives
Decoding, kein HTML-Parsing von Anhangsinhalt); `AttachmentPreview`
bettet für nicht unterstützte Typen ausschließlich Text ein, nie eine
Fremdinhalt-Einbettung. Kein `handleGetAttachment`-Aufrufer im
gesamten Frontend navigiert direkt zur Anhang-URL (kein `<a>`/`<iframe
src=...>`) — jeder Abruf läuft über `apiFetch` + Blob/ArrayBuffer.
**BESTANDEN.**
## Prüfung 3: Test — manipulierter Dateiname mit abweichendem Inhalt löst keine Fehlanzeige/-ausführung aus
- Backend: dieselben Tests wie Prüfung 1
(`TestGetAttachment_ContentTypeFromMagicBytesIgnoresDeclaredType`,
`TestExtract_WrongExtensionRealPDFContentDetectedByMagicBytes`)
belegen bereits, dass ein abweichender Dateiname/deklarierter Typ
NIE zum ausgelieferten `Content-Type` wird — die API hat an der
Download-/Listen-Schnittstelle technisch gar kein Dateinamensfeld.
- Frontend: `AttachmentPreview.test.tsx`, Testfall "rendert bei nicht
unterstütztem, manipuliertem Content-Type weder Bild- noch
PDF-Vorschau" — ein (hypothetisch vom Server zusätzlich mitgesendetes)
`filename: "harmlos.png"` bei tatsächlichem `contentType: "text/html"`
führt NICHT zu einer Bild- oder Canvas-Vorschau, sondern zum neutralen
Hinweistext. Strukturell abgesichert: `AttachmentInfo` hat kein
`filename`-Feld, `classifyAttachment`/`AttachmentPreview` lesen nie
eines. **BESTANDEN.**
## Akzeptanzkriterien
1. **Vorschau funktioniert für PDF- und Bildanhänge ohne externen
Download**: `AttachmentList`/`AttachmentPreview` laden Anhänge per
authentifiziertem `apiFetch` (Blob/ArrayBuffer) und rendern sie
inline (Canvas bzw. `<img>`) — kein Browser-Download-Dialog, keine
Navigation zur Rohdaten-URL. Backend liefert zusätzlich
`Content-Disposition: inline`. Belegt durch Prüfung 1 + die Tests in
`lib/attachments.test.ts`, `AttachmentList.test.tsx`,
`AttachmentPreview.test.tsx`. **ERFÜLLT.**
2. **Content-Type wird serverseitig über Magic-Bytes bestimmt, nicht
über die Dateiendung**: `net/http.DetectContentType` auf den
tatsächlich gelesenen Bytes in `handleGetAttachment` und
`handleListAttachments`; es existiert an dieser Schnittstelle gar
kein Dateiname. Belegt durch Prüfung 1. **ERFÜLLT.**
3. **PDF-Vorschau nutzt Canvas-Rendering, kein iframe/HTML-Rendering
von Anhangsinhalt**: `PdfCanvasPreview.tsx` rendert ausschließlich
über `<canvas>`/pdfjs-dist. Belegt durch Prüfung 2. **ERFÜLLT.**
## Nicht Bestandteil dieser Kachel (laut Ticket)
UX-07 (Barrierefreiheit) — Schnittstelle vorbereitet: `aria-label` auf
Canvas/Buttons/Bereichen, `role="alert"` für Fehlertexte, `role="toolbar"`
für die Seitennavigation; keine vollständige WCAG-Prüfung in diesem
Ticket.
## Bekannte Einschränkung, nicht Bestandteil dieser Kachel
Die Mail-API (`mailapi.RegisterRoutes`, INT-01) ist — wie im
INT-01-Prüfprotokoll dokumentiert — noch nicht in einen laufenden
Core-Router (`cmd/...`) eingebunden. `web/mail-search`s neue
Anhang-Vorschau ruft die Mail-API daher gegen die dokumentierte,
vertragsgetestete Schnittstelle auf (`NEXT_PUBLIC_MAIL_API_URL`), kann
aber erst nach dieser (vorbestehenden, nicht UX-03 zugehörigen)
Core-Verdrahtung produktiv gegen einen echten laufenden Dienst getestet
werden. Gleiche Einschränkung besteht bereits für `web/mail-search`s
andere Backend-Aufrufe (z. B. `app/api/case-context/route.ts` gegen die
Workflow-Rückkanal-API).
## Build/Test — Gesamtmodul
### Go (`mail`)
```
go build ./... → OK
go vet ./... → OK
go test ./... -p 1 → alle Pakete ok (inkl. internal/mailapi, internal/attachments),
keine Regression in bestehenden Paketen
```
Neue/geänderte Tests:
`TestListAttachments_ContentTypesDetectedFromMagicBytes`,
`TestListAttachments_UnknownMessageReturns404`,
`TestGetAttachment_ContentTypeFromMagicBytesIgnoresDeclaredType`
(`mail/internal/mailapi`); `TestExtract_WrongExtensionRealPDFContentDetectedByMagicBytes`
(`mail/internal/attachments`); `TestRoutes_RejectMissingOrInvalidToken`
und `TestOpenAPIDocument_MatchesActualEndpoints` um die neue Route
erweitert.
### Frontend (`web/mail-search`)
```
npm install → OK (pdfjs-dist ^4.10.38 neu)
npx tsc --noEmit → OK
npx vitest run → 13 Testdateien, 59 Tests, alle bestanden
NEXT_PUBLIC_MAIL_API_URL=... NEXT_PUBLIC_CORE_API_BASE=... npm run build
→ Next.js Produktionsbuild erfolgreich
```
Neue Testdateien: `lib/attachmentKind.test.ts`, `lib/attachments.test.ts`,
`app/mail/[messageId]/AttachmentPreview.test.tsx`,
`app/mail/[messageId]/AttachmentList.test.tsx`,
`app/mail/[messageId]/attachment-preview.security.test.ts`.
## Ergebnis
UX-03 erfüllt alle drei Akzeptanzkriterien mit echten, ausgeführten
Nachweisen (Go- und Vitest-Tests, Produktionsbuild). Content-Type-
Erkennung ist durchgängig Magic-Bytes-basiert (IMP-02-Extraktion UND
neue mailapi-Endpunkte), PDF-Vorschau ausschließlich Canvas-Rendering,
keine iframe-Einbindung an irgendeiner Stelle. Bekannte, vorbestehende
Einschränkung (Mail-API noch nicht in einen Core-Router eingebunden)
betrifft die gesamte `web/mail-search`-App und ist nicht Teil dieser
Kachel.
@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { AttachmentList } from "./AttachmentList";
vi.mock("./AttachmentPreview", () => ({
AttachmentPreview: ({ attachment }: { attachment: { index: number; contentType: string } }) => (
<div data-testid={`attachment-${attachment.index}`}>{attachment.contentType}</div>
),
}));
const ORIGINAL_ENV = process.env;
beforeEach(() => {
process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_MAIL_API_URL: "http://mail-api.test" };
});
afterEach(() => {
vi.restoreAllMocks();
process.env = ORIGINAL_ENV;
});
describe("AttachmentList", () => {
// Akzeptanzkriterium 1: Vorschau funktioniert für PDF- und
// Bildanhänge -- diese Komponente lädt die Liste und übergibt jeden
// Eintrag unverändert (contentType) an AttachmentPreview weiter.
it("rendert je Anhang eine Vorschau, für PDF und Bild gleichermaßen", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
attachments: [
{ index: 0, contentType: "application/pdf", size: 10 },
{ index: 1, contentType: "image/png", size: 20 },
],
}),
});
render(<AttachmentList messageId="m-1" />);
expect(await screen.findByTestId("attachment-0")).toHaveTextContent("application/pdf");
expect(await screen.findByTestId("attachment-1")).toHaveTextContent("image/png");
});
it("zeigt einen Hinweis statt zu werfen, wenn die Anhangsliste nicht verfügbar ist", async () => {
global.fetch = vi.fn().mockRejectedValue(new Error("network down"));
render(<AttachmentList messageId="m-2" />);
expect(await screen.findByText("Anhänge konnten nicht geladen werden.")).toBeInTheDocument();
});
it("rendert nichts, wenn die Nachricht keine Anhänge hat", async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ attachments: [] }) });
render(<AttachmentList messageId="m-3" />);
await vi.waitFor(() => expect(global.fetch).toHaveBeenCalled());
expect(screen.queryByLabelText("Anhänge")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,50 @@
"use client";
import { useEffect, useState } from "react";
import { fetchAttachmentList, type AttachmentListResult } from "../../../lib/attachments";
import { AttachmentPreview } from "./AttachmentPreview";
export interface AttachmentListProps {
messageId: string;
}
// UX-03 Akzeptanzkriterium 1: laedt die Anhangsliste und zeigt je Anhang
// eine Vorschau. Ein Ausfall der Mail-API blockiert die uebrige
// Mailanzeige nicht (gleiche Konvention wie CaseContextPanel).
export function AttachmentList({ messageId }: AttachmentListProps) {
const [result, setResult] = useState<AttachmentListResult | null>(null);
useEffect(() => {
let cancelled = false;
setResult(null);
fetchAttachmentList(messageId).then((r) => {
if (!cancelled) setResult(r);
});
return () => {
cancelled = true;
};
}, [messageId]);
if (result === null) {
return null;
}
if (result.status === "unavailable") {
return (
<section aria-label="Anhänge" style={{ marginTop: 24 }}>
<p>Anhänge konnten nicht geladen werden.</p>
</section>
);
}
if (result.attachments.length === 0) {
return null;
}
return (
<section aria-label="Anhänge" style={{ marginTop: 24 }}>
<h2>Anhänge</h2>
{result.attachments.map((attachment) => (
<AttachmentPreview key={attachment.index} messageId={messageId} attachment={attachment} />
))}
</section>
);
}
@@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { AttachmentPreview } from "./AttachmentPreview";
import type { AttachmentInfo } from "../../../lib/attachments";
const ORIGINAL_ENV = process.env;
beforeEach(() => {
process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_MAIL_API_URL: "http://mail-api.test" };
});
afterEach(() => {
process.env = ORIGINAL_ENV;
});
// PdfCanvasPreview/ImagePreview laden pdfjs-dist bzw. echte Bilddaten --
// in diesem Dispatch-Test genuegt ein Platzhalter, der zeigt, DASS die
// richtige Komponente gewaehlt wurde (die eigentliche Rendering-Logik
// ist Sache der jeweils eigenen Komponente).
vi.mock("./PdfCanvasPreview", () => ({
PdfCanvasPreview: ({ contentUrl }: { contentUrl: string }) => <div data-testid="pdf-canvas-preview">{contentUrl}</div>,
}));
vi.mock("./ImagePreview", () => ({
ImagePreview: ({ contentUrl }: { contentUrl: string }) => <div data-testid="image-preview">{contentUrl}</div>,
}));
function attachment(overrides: Partial<AttachmentInfo> & Record<string, unknown> = {}): AttachmentInfo {
return { index: 0, contentType: "application/pdf", size: 100, ...overrides } as AttachmentInfo;
}
describe("AttachmentPreview", () => {
// Akzeptanzkriterium 1 + 3: PDF-Anhang -> Canvas-Vorschau (PdfCanvasPreview), kein iframe.
it("zeigt die Canvas-PDF-Vorschau für application/pdf", () => {
render(<AttachmentPreview messageId="m-1" attachment={attachment({ contentType: "application/pdf" })} />);
expect(screen.getByTestId("pdf-canvas-preview")).toBeInTheDocument();
expect(screen.queryByTestId("image-preview")).not.toBeInTheDocument();
});
// Akzeptanzkriterium 1: Bildanhang -> Bild-Vorschau.
it("zeigt die Bild-Vorschau für image/*", () => {
render(<AttachmentPreview messageId="m-1" attachment={attachment({ index: 1, contentType: "image/png" })} />);
expect(screen.getByTestId("image-preview")).toBeInTheDocument();
expect(screen.queryByTestId("pdf-canvas-preview")).not.toBeInTheDocument();
});
// Prüfung 3 (Pflichtprüfung): ein manipulierter Anhang, dessen
// (hypothetischer, vom Server fälschlich mitgesendeter) Dateiname
// "harmlos.png" behauptet, aber dessen echter, serverseitig per
// Magic-Bytes bestimmter Content-Type "text/html" ist, löst KEINE
// Bild-/Canvas-Vorschau aus -- die Entscheidung hängt ausschließlich
// von contentType ab, ein mitgesendeter Dateiname wird nie gelesen.
it("rendert bei nicht unterstütztem, manipuliertem Content-Type weder Bild- noch PDF-Vorschau", () => {
render(
<AttachmentPreview
messageId="m-1"
attachment={attachment({ index: 2, contentType: "text/html", filename: "harmlos.png" })}
/>
);
expect(screen.queryByTestId("image-preview")).not.toBeInTheDocument();
expect(screen.queryByTestId("pdf-canvas-preview")).not.toBeInTheDocument();
expect(screen.getByText("Für diesen Dateityp ist keine Vorschau verfügbar.")).toBeInTheDocument();
});
});
@@ -0,0 +1,33 @@
"use client";
import { classifyAttachment, describeAttachmentKind } from "../../../lib/attachmentKind";
import { attachmentContentUrl, type AttachmentInfo } from "../../../lib/attachments";
import { PdfCanvasPreview } from "./PdfCanvasPreview";
import { ImagePreview } from "./ImagePreview";
export interface AttachmentPreviewProps {
messageId: string;
attachment: AttachmentInfo;
}
// UX-03 Akzeptanzkriterium 2/3: die Vorschauart haengt AUSSCHLIESSLICH
// von attachment.contentType ab (serverseitig per Magic-Bytes bestimmt,
// siehe mail/internal/mailapi) -- AttachmentInfo hat gar keinen
// Dateinamen, der stattdessen herangezogen werden koennte. Ein nicht
// unterstuetzter Typ wird NIE eingebettet/ausgefuehrt (kein iframe, kein
// <object>, kein dangerouslySetInnerHTML), nur als Hinweis angezeigt.
export function AttachmentPreview({ messageId, attachment }: AttachmentPreviewProps) {
const contentUrl = attachmentContentUrl(messageId, attachment.index);
const kind = classifyAttachment(attachment.contentType);
return (
<section aria-label={`Anhang ${attachment.index + 1}`} style={{ border: "1px solid #ccc", borderRadius: 4, padding: 12, marginTop: 12 }}>
<p>
<strong>Anhang {attachment.index + 1}</strong> — {describeAttachmentKind(attachment.contentType)}
</p>
{kind === "pdf" && <PdfCanvasPreview contentUrl={contentUrl} />}
{kind === "image" && <ImagePreview contentUrl={contentUrl} alt={`Anhang ${attachment.index + 1}`} />}
{kind === "unsupported" && <p>Für diesen Dateityp ist keine Vorschau verfügbar.</p>}
</section>
);
}
@@ -0,0 +1,54 @@
"use client";
// UX-03: Bild-Anhang-Vorschau. <img> dekodiert Bilddaten nativ im
// Browser (kein Skript-/HTML-Rendering von Fremdinhalt wie bei einem
// iframe) -- fuer Bilder reicht das, anders als bei PDF ist hier kein
// Canvas-Zwang nötig (Akzeptanzkriterium 3 betrifft ausdrücklich nur
// die PDF-Vorschau).
import { useEffect, useState } from "react";
import { apiFetch } from "../../../lib/http";
export interface ImagePreviewProps {
contentUrl: string;
alt: string;
}
export function ImagePreview({ contentUrl, alt }: ImagePreviewProps) {
const [src, setSrc] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let objectUrl: string | null = null;
setError(null);
setSrc(null);
apiFetch(contentUrl)
.then((res) => {
if (!res.ok) throw new Error(`Bild konnte nicht geladen werden (${res.status}).`);
return res.blob();
})
.then((blob) => {
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setSrc(objectUrl);
})
.catch((err: unknown) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : "Bild konnte nicht geladen werden.");
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [contentUrl]);
if (error) {
return <p role="alert">{error}</p>;
}
if (!src) {
return <p>Bild wird geladen…</p>;
}
return <img src={src} alt={alt} style={{ maxWidth: "100%" }} />;
}
@@ -0,0 +1,106 @@
"use client";
// UX-03: Canvas-Rendering fuer die PDF-Anhang-Vorschau -- kein
// iframe/HTML-Rendering von Fremdinhalt (archivmail hatte das testweise
// mit iframe versucht, als unsicher verworfen). Gleiches Grundmuster wie
// DOC-05s web/pdf-viewer/components/PdfViewer.tsx, hier auf eine reine
// Vorschau reduziert (kein Suchen/Zoomen -- das ist der volle
// Dokumentbetrachter, nicht Bestandteil dieser Kachel).
import { useEffect, useRef, useState } from "react";
import type { PDFDocumentProxy } from "pdfjs-dist";
import { apiFetch } from "../../../lib/http";
export interface PdfCanvasPreviewProps {
contentUrl: string;
}
export function PdfCanvasPreview({ contentUrl }: PdfCanvasPreviewProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [pdf, setPdf] = useState<PDFDocumentProxy | null>(null);
const [pageNumber, setPageNumber] = useState(1);
const [error, setError] = useState<string | null>(null);
// Akzeptanzkriterium 1: der Anhang wird per authentifiziertem Abruf
// geladen (apiFetch), nie ueber einen Browser-Download.
useEffect(() => {
let cancelled = false;
setError(null);
setPdf(null);
Promise.all([
import("pdfjs-dist"),
apiFetch(contentUrl).then((res) => {
if (!res.ok) throw new Error(`Anhang konnte nicht geladen werden (${res.status}).`);
return res.arrayBuffer();
}),
])
.then(([pdfjs, data]) => {
if (cancelled) return undefined;
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
return pdfjs.getDocument({ data }).promise.then((doc) => {
if (cancelled) return;
setPdf(doc);
setPageNumber(1);
});
})
.catch((err: unknown) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : "PDF konnte nicht geladen werden.");
});
return () => {
cancelled = true;
};
}, [contentUrl]);
useEffect(() => {
if (!pdf) return;
let cancelled = false;
pdf
.getPage(pageNumber)
.then((page) => {
if (cancelled) return;
const viewport = page.getViewport({ scale: 1 });
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
canvas.width = viewport.width;
canvas.height = viewport.height;
return page.render({ canvasContext: context, viewport }).promise;
})
.catch((err: unknown) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : "Seite konnte nicht gerendert werden.");
});
return () => {
cancelled = true;
};
}, [pdf, pageNumber]);
if (error) {
return <p role="alert">{error}</p>;
}
const numPages = pdf?.numPages ?? 0;
return (
<div>
{numPages > 1 && (
<div role="toolbar" aria-label="PDF-Vorschau-Werkzeuge" style={{ display: "flex", gap: 8, marginBottom: 4, alignItems: "center" }}>
<button type="button" onClick={() => setPageNumber((p) => Math.max(1, p - 1))} disabled={pageNumber <= 1} aria-label="Vorherige Seite">
◀
</button>
<span>
Seite {pageNumber} von {numPages}
</span>
<button type="button" onClick={() => setPageNumber((p) => Math.min(numPages, p + 1))} disabled={pageNumber >= numPages} aria-label="Nächste Seite">
▶
</button>
</div>
)}
{/* Nur Canvas -- kein iframe, kein <object>, kein dangerouslySetInnerHTML. */}
<canvas ref={canvasRef} aria-label={`PDF-Vorschau, Seite ${pageNumber}`} style={{ maxWidth: "100%", border: "1px solid #ddd" }} />
</div>
);
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const dir = dirname(fileURLToPath(import.meta.url));
// UX-03 Pflichtprüfung 2: Sicherheitsreview bestätigt automatisiert (statt
// nur behauptet): keine iframe-Einbindung von Anhangsinhalt, kein <object>,
// kein dangerouslySetInnerHTML -- über alle an der Anhang-Vorschau
// beteiligten Dateien. archivmail hatte testweise eine iframe-PDF-Vorschau,
// als unsicher verworfen; dieser Test verhindert ein Wiederauftreten.
const previewFiles = ["PdfCanvasPreview.tsx", "ImagePreview.tsx", "AttachmentPreview.tsx", "AttachmentList.tsx"];
const forbiddenPatterns = [/<iframe/i, /<object/i, /dangerouslysetinnerhtml/i];
// Kommentare erklären absichtlich, WARUM kein iframe/object verwendet wird
// (enthalten die Begriffe also selbst als Prosa) -- der Review prüft den
// tatsächlichen Code, daher werden Kommentare vor dem Abgleich entfernt.
function stripComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
}
describe("Sicherheitsreview: Anhang-Vorschau rendert nie Fremdinhalt unkontrolliert", () => {
for (const file of previewFiles) {
it(`${file} enthält kein iframe/object/dangerouslySetInnerHTML im tatsächlichen Code`, () => {
const code = stripComments(readFileSync(join(dir, file), "utf-8"));
for (const pattern of forbiddenPatterns) {
expect(code).not.toMatch(pattern);
}
});
}
});
@@ -5,6 +5,7 @@ import { useSearchParams } from "next/navigation";
import { splitHighlighted } from "../../../lib/highlight";
import { HIGHLIGHT_BG_LIGHT, HIGHLIGHT_FG_LIGHT } from "../../../lib/highlightColors";
import CaseContextPanel from "./CaseContextPanel";
import { AttachmentList } from "./AttachmentList";
// SRC-04 Akzeptanzkriterium 2: Klick auf einen Suchtreffer öffnet die Mail
// direkt an der Fundstelle. Der vollständige Mail-Inhaltsabruf (Betreff/
@@ -54,6 +55,7 @@ export default function MailDetailPage({ params }: { params: Promise<{ messageId
</p>
)}
<CaseContextPanel messageId={messageId} tenant={tenant} />
<AttachmentList messageId={messageId} />
</main>
);
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { classifyAttachment, describeAttachmentKind } from "./attachmentKind";
// UX-03 Akzeptanzkriterium 2/3: classifyAttachment ist die EINZIGE
// Stelle, die ueber die Vorschauart entscheidet, und entscheidet
// ausschliesslich anhand des (serverseitig per Magic-Bytes bestimmten)
// Content-Type -- nie anhand eines Dateinamens/einer Endung.
describe("classifyAttachment", () => {
it("erkennt PDF ausschliesslich am Content-Type 'application/pdf'", () => {
expect(classifyAttachment("application/pdf")).toBe("pdf");
});
it("erkennt Bilder an jedem 'image/*'-Content-Type", () => {
expect(classifyAttachment("image/png")).toBe("image");
expect(classifyAttachment("image/jpeg")).toBe("image");
expect(classifyAttachment("image/gif")).toBe("image");
});
it("stuft jeden anderen Content-Type als nicht unterstützt ein, auch potenziell gefährliche", () => {
expect(classifyAttachment("text/html")).toBe("unsupported");
expect(classifyAttachment("application/octet-stream")).toBe("unsupported");
expect(classifyAttachment("application/x-msdownload")).toBe("unsupported");
});
});
describe("describeAttachmentKind", () => {
it("liefert deutschsprachige Bezeichnungen je Art", () => {
expect(describeAttachmentKind("application/pdf")).toBe("PDF-Dokument");
expect(describeAttachmentKind("image/png")).toBe("Bild (PNG)");
expect(describeAttachmentKind("text/html")).toContain("Nicht unterstützter Dateityp");
});
});
+22
View File
@@ -0,0 +1,22 @@
// UX-03 Akzeptanzkriterium 2/3: Diese Zuordnung ist die EINZIGE Stelle,
// die ueber die Vorschauart entscheidet -- Eingabe ist ausschliesslich
// der serverseitig per Magic-Bytes bestimmte contentType (nie ein
// Dateiname/eine Endung, die es an dieser Schnittstelle gar nicht gibt).
export type AttachmentKind = "pdf" | "image" | "unsupported";
export function classifyAttachment(contentType: string): AttachmentKind {
if (contentType === "application/pdf") return "pdf";
if (contentType.startsWith("image/")) return "image";
return "unsupported";
}
export function describeAttachmentKind(contentType: string): string {
switch (classifyAttachment(contentType)) {
case "pdf":
return "PDF-Dokument";
case "image":
return `Bild (${contentType.slice("image/".length).toUpperCase()})`;
default:
return `Nicht unterstützter Dateityp (${contentType})`;
}
}
+62
View File
@@ -0,0 +1,62 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { attachmentContentUrl, attachmentsUrl, fetchAttachmentList } from "./attachments";
const ORIGINAL_ENV = process.env;
afterEach(() => {
vi.restoreAllMocks();
process.env = ORIGINAL_ENV;
});
describe("attachmentsUrl / attachmentContentUrl", () => {
it("baut die URLs aus NEXT_PUBLIC_MAIL_API_URL ohne doppelten Schrägstrich", () => {
process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_MAIL_API_URL: "http://mail-api.test/" };
expect(attachmentsUrl("m-1")).toBe("http://mail-api.test/messages/m-1/attachments");
expect(attachmentContentUrl("m-1", 2)).toBe("http://mail-api.test/messages/m-1/attachments/2");
});
it("wirft einen klaren Fehler ohne gesetzte Umgebungsvariable", () => {
process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_MAIL_API_URL: undefined };
expect(() => attachmentsUrl("m-1")).toThrow(/NEXT_PUBLIC_MAIL_API_URL/);
});
});
describe("fetchAttachmentList", () => {
// Akzeptanzkriterium 1/2: liefert die vom Server per Magic-Bytes
// bestimmten Content-Types unveraendert durch.
it("liefert die Anhangsliste bei Erfolg", async () => {
process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_MAIL_API_URL: "http://mail-api.test" };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ attachments: [{ index: 0, contentType: "application/pdf", size: 1234 }] }),
});
const result = await fetchAttachmentList("m-1");
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.attachments).toEqual([{ index: 0, contentType: "application/pdf", size: 1234 }]);
}
});
// Akzeptanzkriterium 1: ein Ausfall der Mail-API blockiert die uebrige
// Mailanzeige nicht -- liefert "unavailable" statt zu werfen.
it("liefert 'unavailable' statt zu werfen, wenn die Mail-API nicht erreichbar ist", async () => {
process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_MAIL_API_URL: "http://mail-api.test" };
global.fetch = vi.fn().mockRejectedValue(new Error("network down"));
const result = await fetchAttachmentList("m-1");
expect(result.status).toBe("unavailable");
});
it("liefert 'unavailable' bei einer Fehlerantwort (z. B. 404/502)", async () => {
process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_MAIL_API_URL: "http://mail-api.test" };
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404, json: async () => ({ error: "nicht gefunden" }) });
const result = await fetchAttachmentList("m-1");
expect(result.status).toBe("unavailable");
});
});
+58
View File
@@ -0,0 +1,58 @@
// UX-03: duenner Client fuer die Anhangsliste/den Anhanginhalt der
// Mail-API (mailapi, INT-01). Direkter authentifizierter Aufruf ueber
// apiFetch (dasselbe Muster wie web/pdf-viewer/lib/api.ts fuer die
// DMS-Dokumentinhalts-URL) -- keine eigene Kopie der Backend-Logik.
import { apiFetch } from "./http";
function apiBase(): string {
const base = process.env.NEXT_PUBLIC_MAIL_API_URL;
if (!base) {
throw new Error("NEXT_PUBLIC_MAIL_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
}
return base.replace(/\/$/, "");
}
// AttachmentInfo hat BEWUSST keinen Dateinamen -- die Mail-API liefert
// keinen (siehe mailapi.attachmentListItem) und die Vorschau darf sich
// nur auf contentType stuetzen (Akzeptanzkriterium 2/3).
export interface AttachmentInfo {
index: number;
contentType: string;
size: number;
}
interface AttachmentListResponseBody {
attachments: AttachmentInfo[];
}
export function attachmentsUrl(messageId: string): string {
return `${apiBase()}/messages/${encodeURIComponent(messageId)}/attachments`;
}
export function attachmentContentUrl(messageId: string, index: number): string {
return `${apiBase()}/messages/${encodeURIComponent(messageId)}/attachments/${index}`;
}
export type AttachmentListResult =
| { status: "ok"; attachments: AttachmentInfo[] }
| { status: "unavailable" };
// Akzeptanzkriterium 1: ein Ausfall der Mail-API darf die uebrige
// Mailanzeige nicht blockieren (gleiche Konvention wie
// lib/caseContext.ts#fetchCaseContext) -- daher wirft diese Funktion nie.
export async function fetchAttachmentList(messageId: string): Promise<AttachmentListResult> {
let res: Response;
try {
res = await apiFetch(attachmentsUrl(messageId));
} catch {
return { status: "unavailable" };
}
if (!res.ok) {
return { status: "unavailable" };
}
const body: AttachmentListResponseBody | null = await res.json().catch(() => null);
if (!body || !Array.isArray(body.attachments)) {
return { status: "unavailable" };
}
return { status: "ok", attachments: body.attachments };
}
+33
View File
@@ -0,0 +1,33 @@
// UX-03: Zugriff auf die Mail-API (mailapi, Bearer/Core-Modul-Token) mit
// demselben Muster wie DOC-05/UX-10 im pdf-viewer (web/pdf-viewer/lib/http.ts)
// -- zentral ueber @nexarch/shl (SHL-01), nicht neu erfunden.
import { createSession, redirectToLogin, type Session } from "@nexarch/shl";
export function authMode(): "token" | "caller" {
return process.env.NEXT_PUBLIC_AUTH_MODE === "caller" ? "caller" : "token";
}
let session: Session | undefined;
export function getSession(): Session {
if (!session) {
const coreBase = process.env.NEXT_PUBLIC_CORE_API_BASE;
if (!coreBase) {
throw new Error("NEXT_PUBLIC_CORE_API_BASE ist nicht gesetzt (Umgebungsvariable erforderlich)");
}
session = createSession({
coreBase,
onLoginRequired: () => redirectToLogin(process.env.NEXT_PUBLIC_LOGIN_URL),
});
}
return session;
}
export function resetSessionForTests(): void {
session = undefined;
}
export function apiFetch(input: string, init?: RequestInit): Promise<Response> {
if (authMode() === "caller") return fetch(input, init);
return getSession().fetch(input, init);
}
+278
View File
@@ -8,6 +8,7 @@
"dependencies": {
"@nexarch/shl": "file:../shl",
"next": "16.3.5",
"pdfjs-dist": "^4.10.38",
"react": "19.3.0",
"react-dom": "19.3.0"
},
@@ -735,6 +736,271 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz",
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.100",
"@napi-rs/canvas-darwin-arm64": "0.1.100",
"@napi-rs/canvas-darwin-x64": "0.1.100",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@nexarch/shl": {
"version": "0.1.0",
"resolved": "file:../shl",
@@ -2445,6 +2711,18 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/pdfjs-dist": {
"version": "4.10.38",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz",
"integrity": "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.65"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+4
View File
@@ -2,6 +2,9 @@
"name": "nexarch-mail-search",
"private": true,
"scripts": {
"copy-pdf-worker": "mkdir -p public && cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
"predev": "npm run copy-pdf-worker",
"prebuild": "npm run copy-pdf-worker",
"dev": "next dev",
"build": "next build",
"start": "next start",
@@ -10,6 +13,7 @@
"dependencies": {
"@nexarch/shl": "file:../shl",
"next": "16.3.5",
"pdfjs-dist": "^4.10.38",
"react": "19.3.0",
"react-dom": "19.3.0"
},
+5
View File
@@ -4,5 +4,10 @@ export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
// UX-03: Tests mocken global.fetch direkt (wie die uebrigen lib/*.test.ts
// dieser App) -- "caller" ueberspringt die @nexarch/shl-Session, die ein
// echtes NEXT_PUBLIC_CORE_API_BASE braeuchte (gleiches Muster wie
// web/pdf-viewer/vitest.config.ts).
env: { NEXT_PUBLIC_AUTH_MODE: "caller" },
},
});