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
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:
1 parent
c2d1623a19
commit
f63fcb8a2d
18 files changed
+1123
No files matched your search
@@ -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__/
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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})`;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Generated
+278
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user