From e124c68cf4f6e2e2a36bc62a05706bc670584dd7 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Mon, 18 May 2026 12:51:29 +0200
Subject: [PATCH 01/10] feat(import): validate PDF magic bytes before S3 upload
Reads first 4 bytes of each candidate file before upload; rejects any
file whose header does not match %PDF (0x25 0x50 0x44 0x46). Skipped
files are counted and collected in ImportStatus.skippedFiles so
operators can see what was rejected without querying Loki.
Breaking: ImportStatus record gains skipped + skippedFiles fields.
Co-Authored-By: Claude Sonnet 4.6
---
.../importing/MassImportService.java | 68 +++++++++----
.../importing/MassImportServiceTest.java | 95 +++++++++++++++++--
.../user/AdminControllerTest.java | 5 +-
3 files changed, 141 insertions(+), 27 deletions(-)
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java b/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java
index d6453e19..d2f1e276 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java
@@ -31,6 +31,7 @@ import javax.xml.parsers.DocumentBuilderFactory;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
+import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -53,9 +54,13 @@ public class MassImportService {
public enum State { IDLE, RUNNING, DONE, FAILED }
- public record ImportStatus(State state, String statusCode, @JsonIgnore String message, int processed, LocalDateTime startedAt) {}
+ public record SkippedFile(String filename, String reason) {}
- private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
+ public record ImportStatus(State state, String statusCode, @JsonIgnore String message, int processed, int skipped, List skippedFiles, LocalDateTime startedAt) {}
+
+ record ProcessResult(int processed, int skipped, List skippedFiles) {}
+
+ private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, 0, List.of(), null);
public ImportStatus getStatus() {
return currentStatus;
@@ -117,22 +122,22 @@ public class MassImportService {
if (currentStatus.state() == State.RUNNING) {
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
}
- currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, LocalDateTime.now());
+ currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, 0, List.of(), LocalDateTime.now());
try {
File spreadsheet = findSpreadsheetFile();
log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath());
- int processed = processRows(readSpreadsheet(spreadsheet));
+ ProcessResult result = processRows(readSpreadsheet(spreadsheet));
currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE",
- "Import abgeschlossen. " + processed + " Dokumente verarbeitet.",
- processed, currentStatus.startedAt());
+ "Import abgeschlossen. " + result.processed() + " Dokumente verarbeitet.",
+ result.processed(), result.skipped(), result.skippedFiles(), currentStatus.startedAt());
} catch (NoSpreadsheetException e) {
log.error("Massenimport fehlgeschlagen: keine Tabellendatei", e);
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET",
- "Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
+ "Fehler: " + e.getMessage(), 0, 0, List.of(), currentStatus.startedAt());
} catch (Exception e) {
log.error("Massenimport fehlgeschlagen", e);
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL",
- "Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
+ "Fehler: " + e.getMessage(), 0, 0, List.of(), currentStatus.startedAt());
}
}
@@ -254,8 +259,10 @@ public class MassImportService {
// --- Import logic (works on neutral List rows) ---
- private int processRows(List> rows) {
- int count = 0;
+ private ProcessResult processRows(List> rows) {
+ int processed = 0;
+ List skippedFiles = new ArrayList<>();
+
for (int i = 1; i < rows.size(); i++) { // skip header row
List cells = rows.get(i);
String index = getCell(cells, colIndex);
@@ -266,18 +273,46 @@ public class MassImportService {
if (fileOnDisk.isEmpty()) {
log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename);
}
- importSingleDocument(cells, fileOnDisk, filename, index);
- count++;
+
+ if (fileOnDisk.isPresent()) {
+ try {
+ if (!isPdfMagicBytes(fileOnDisk.get())) {
+ log.warn("Überspringe {}: Datei beginnt nicht mit %PDF-Signatur", filename);
+ skippedFiles.add(new SkippedFile(filename, "Keine gültige PDF-Signatur"));
+ continue;
+ }
+ } catch (IOException e) {
+ log.error("Fehler beim Prüfen der Magic-Bytes für {}", filename, e);
+ skippedFiles.add(new SkippedFile(filename, "Fehler beim Lesen der Datei"));
+ continue;
+ }
+ }
+
+ boolean imported = importSingleDocument(cells, fileOnDisk, filename, index);
+ if (imported) {
+ processed++;
+ }
+ }
+ return new ProcessResult(processed, skippedFiles.size(), skippedFiles);
+ }
+
+ private boolean isPdfMagicBytes(File file) throws IOException {
+ try (InputStream is = new FileInputStream(file)) {
+ byte[] header = is.readNBytes(4);
+ return header.length == 4
+ && header[0] == 0x25 // %
+ && header[1] == 0x50 // P
+ && header[2] == 0x44 // D
+ && header[3] == 0x46; // F
}
- return count;
}
@Transactional
- protected void importSingleDocument(List cells, Optional file, String originalFilename, String index) {
+ protected boolean importSingleDocument(List cells, Optional file, String originalFilename, String index) {
Optional existing = documentService.findByOriginalFilename(originalFilename);
if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) {
log.info("Dokument {} existiert bereits, überspringe.", originalFilename);
- return;
+ return false;
}
String archiveBox = getCell(cells, colBox);
@@ -313,7 +348,7 @@ public class MassImportService {
status = DocumentStatus.UPLOADED;
} catch (Exception e) {
log.error("S3 Upload Fehler für {}", file.get().getName(), e);
- return;
+ return false;
}
}
@@ -355,6 +390,7 @@ public class MassImportService {
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
}
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
+ return true;
}
// --- Helpers ---
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java
index dcd7c707..eef66a71 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java
@@ -135,7 +135,7 @@ class MassImportServiceTest {
@Test
void runImportAsync_throwsConflict_whenAlreadyRunning() {
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
- MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, LocalDateTime.now());
+ MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, 0, List.of(), LocalDateTime.now());
ReflectionTestUtils.setField(service, "currentStatus", running);
assertThatThrownBy(() -> service.runImportAsync())
@@ -325,8 +325,8 @@ class MassImportServiceTest {
@Test
void processRows_returnsZero_whenOnlyHeaderRow() {
List> rows = List.of(List.of("header", "col1"));
- Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
- assertThat(result).isEqualTo(0);
+ MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
+ assertThat(result.processed()).isEqualTo(0);
}
@Test
@@ -335,8 +335,8 @@ class MassImportServiceTest {
List.of("header"),
minimalCells("") // blank index
);
- Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
- assertThat(result).isEqualTo(0);
+ MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
+ assertThat(result.processed()).isEqualTo(0);
verify(documentService, never()).findByOriginalFilename(any());
}
@@ -349,9 +349,9 @@ class MassImportServiceTest {
List.of("header"),
minimalCells("doc001") // no dot → appends ".pdf"
);
- Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
+ MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
- assertThat(result).isEqualTo(1);
+ assertThat(result.processed()).isEqualTo(1);
verify(documentService).findByOriginalFilename("doc001.pdf");
}
@@ -364,9 +364,9 @@ class MassImportServiceTest {
List.of("header"),
minimalCells("doc002.pdf") // has dot → used as-is
);
- Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
+ MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
- assertThat(result).isEqualTo(1);
+ assertThat(result.processed()).isEqualTo(1);
verify(documentService).findByOriginalFilename("doc002.pdf");
}
@@ -525,6 +525,69 @@ class MassImportServiceTest {
assertThat(result).isEqualTo("hello");
}
+ // ─── PDF magic byte validation regression ─────────────────────────────────
+
+ @Test
+ void runImportAsync_uploadsValidPdf_andSkipsFakeOne(@TempDir Path tempDir) throws Exception {
+ byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
+ Files.write(tempDir.resolve("real.pdf"), pdfHeader);
+ Files.writeString(tempDir.resolve("fake.pdf"), "not a pdf");
+ buildMinimalImportXlsx(tempDir, "real.pdf", "fake.pdf");
+ ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
+
+ when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
+ when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ service.runImportAsync();
+
+ verify(s3Client, times(1)).putObject(any(PutObjectRequest.class), any(RequestBody.class));
+ }
+
+ @Test
+ void runImportAsync_setsSkippedCount_toOne_whenOneFakeFile(@TempDir Path tempDir) throws Exception {
+ byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
+ Files.write(tempDir.resolve("real.pdf"), pdfHeader);
+ Files.writeString(tempDir.resolve("fake.pdf"), "not a pdf");
+ buildMinimalImportXlsx(tempDir, "real.pdf", "fake.pdf");
+ ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
+
+ when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
+ when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ service.runImportAsync();
+
+ assertThat(service.getStatus().skipped()).isEqualTo(1);
+ }
+
+ @Test
+ void runImportAsync_includesRejectedFilename_inSkippedFiles(@TempDir Path tempDir) throws Exception {
+ byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
+ Files.write(tempDir.resolve("real.pdf"), pdfHeader);
+ Files.writeString(tempDir.resolve("fake.pdf"), "not a pdf");
+ buildMinimalImportXlsx(tempDir, "real.pdf", "fake.pdf");
+ ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
+
+ when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
+ when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
+
+ service.runImportAsync();
+
+ assertThat(service.getStatus().skippedFiles())
+ .extracting(MassImportService.SkippedFile::filename)
+ .contains("fake.pdf");
+ }
+
+ @Test
+ void runImportAsync_skipsFile_whenShorterThanFourBytes(@TempDir Path tempDir) throws Exception {
+ Files.write(tempDir.resolve("tiny.pdf"), new byte[]{0x25, 0x50, 0x44}); // only 3 bytes
+ buildMinimalImportXlsx(tempDir, "tiny.pdf");
+ ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
+
+ service.runImportAsync();
+
+ assertThat(service.getStatus().skipped()).isEqualTo(1);
+ }
+
// ─── readOds — XXE security regression ───────────────────────────────────
// Security regression — do not remove.
@@ -621,4 +684,18 @@ class MassImportServiceTest {
}
return destination.toFile();
}
+
+ private void buildMinimalImportXlsx(Path dir, String... filenames) throws Exception {
+ Path xlsx = dir.resolve("import.xlsx");
+ try (XSSFWorkbook wb = new XSSFWorkbook()) {
+ org.apache.poi.ss.usermodel.Sheet sheet = wb.createSheet("Sheet1");
+ sheet.createRow(0).createCell(0).setCellValue("Index");
+ for (int i = 0; i < filenames.length; i++) {
+ sheet.createRow(i + 1).createCell(0).setCellValue(filenames[i]);
+ }
+ try (OutputStream out = Files.newOutputStream(xlsx)) {
+ wb.write(out);
+ }
+ }
+ }
}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java
index 533aa7b3..cc2b885a 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/user/AdminControllerTest.java
@@ -19,6 +19,7 @@ import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.List;
+import java.util.List;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.verify;
@@ -46,7 +47,7 @@ class AdminControllerTest {
@WithMockUser(authorities = "ADMIN")
void importStatus_returns200_withStatusCode_whenAdmin() throws Exception {
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
- MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
+ MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, 0, List.of(), null);
when(massImportService.getStatus()).thenReturn(status);
mockMvc.perform(get("/api/admin/import-status"))
@@ -60,7 +61,7 @@ class AdminControllerTest {
@WithMockUser(authorities = "ADMIN")
void importStatus_messageField_notPresentInApiResponse() throws Exception {
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
- MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
+ MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, 0, List.of(), null);
when(massImportService.getStatus()).thenReturn(status);
mockMvc.perform(get("/api/admin/import-status"))
--
2.49.1
From 22589e4729baaecbdbf7a60c2a26194d6a541fab Mon Sep 17 00:00:00 2001
From: Marcel
Date: Mon, 18 May 2026 13:14:32 +0200
Subject: [PATCH 02/10] feat(admin): surface skipped file count in
ImportStatusCard
Adds SkippedFile to the local ImportStatus type and updates
ImportStatusCard to show an amber skipped-count section with a
collapsible filename list in the DONE state. Only rendered when
skipped > 0. i18n keys added for de/en/es.
Co-Authored-By: Claude Sonnet 4.6
---
frontend/messages/de.json | 1 +
frontend/messages/en.json | 1 +
frontend/messages/es.json | 1 +
.../admin/system/ImportStatusCard.svelte | 15 ++++++
.../system/ImportStatusCard.svelte.test.ts | 51 +++++++++++++++++++
frontend/src/routes/admin/system/types.ts | 7 +++
6 files changed, 76 insertions(+)
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index 03d63f48..55e93d40 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -350,6 +350,7 @@
"admin_system_import_status_running": "Import läuft…",
"admin_system_import_status_done": "Import abgeschlossen",
"admin_system_import_status_done_label": "Dokumente verarbeitet",
+ "admin_system_import_skipped_label": "übersprungen",
"admin_system_import_status_failed": "Import fehlgeschlagen",
"admin_system_import_failed_no_spreadsheet": "Keine Tabellendatei gefunden.",
"admin_system_import_failed_internal": "Interner Fehler beim Import.",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index d52ddbf5..1ace8196 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -350,6 +350,7 @@
"admin_system_import_status_running": "Import running…",
"admin_system_import_status_done": "Import complete",
"admin_system_import_status_done_label": "Documents processed",
+ "admin_system_import_skipped_label": "skipped",
"admin_system_import_status_failed": "Import failed",
"admin_system_import_failed_no_spreadsheet": "No spreadsheet file found.",
"admin_system_import_failed_internal": "Import failed due to an internal error.",
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index 177dcdff..286faf5b 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -350,6 +350,7 @@
"admin_system_import_status_running": "Importación en curso…",
"admin_system_import_status_done": "Importación completada",
"admin_system_import_status_done_label": "Documentos procesados",
+ "admin_system_import_skipped_label": "omitidos",
"admin_system_import_status_failed": "Importación fallida",
"admin_system_import_failed_no_spreadsheet": "No se encontró ninguna hoja de cálculo.",
"admin_system_import_failed_internal": "Error interno durante la importación.",
diff --git a/frontend/src/routes/admin/system/ImportStatusCard.svelte b/frontend/src/routes/admin/system/ImportStatusCard.svelte
index 01b565c4..a7cbba65 100644
--- a/frontend/src/routes/admin/system/ImportStatusCard.svelte
+++ b/frontend/src/routes/admin/system/ImportStatusCard.svelte
@@ -48,6 +48,21 @@ const failureMessage = $derived(
{m.admin_system_import_status_done()}
+ {#if importStatus.skipped > 0}
+
+
+ {importStatus.skipped}
+
+ {m.admin_system_import_skipped_label()}
+
+
+
+ {#each importStatus.skippedFiles as { filename, reason } (filename)}
+ - {filename} — {reason}
+ {/each}
+
+
+ {/if}
{/if}
+
+
diff --git a/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts b/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts
index 7f28de0a..a786b787 100644
--- a/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts
+++ b/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts
@@ -140,9 +140,9 @@ describe('ImportStatusCard', () => {
processed: 10,
skipped: 3,
skippedFiles: [
- { filename: 'fake.pdf', reason: 'Keine gültige PDF-Signatur' },
- { filename: 'other.pdf', reason: 'Keine gültige PDF-Signatur' },
- { filename: 'tiny.pdf', reason: 'Keine gültige PDF-Signatur' }
+ { filename: 'fake.pdf', reason: 'INVALID_PDF_SIGNATURE' },
+ { filename: 'other.pdf', reason: 'INVALID_PDF_SIGNATURE' },
+ { filename: 'tiny.pdf', reason: 'INVALID_PDF_SIGNATURE' }
]
}),
ontrigger: () => {}
@@ -160,7 +160,7 @@ describe('ImportStatusCard', () => {
statusCode: 'IMPORT_DONE',
processed: 5,
skipped: 1,
- skippedFiles: [{ filename: 'fake.pdf', reason: 'Keine gültige PDF-Signatur' }]
+ skippedFiles: [{ filename: 'fake.pdf', reason: 'INVALID_PDF_SIGNATURE' }]
}),
ontrigger: () => {}
}
--
2.49.1
From e770b81ea50b7be217ad5020612c843979bd736c Mon Sep 17 00:00:00 2001
From: Marcel
Date: Mon, 18 May 2026 14:58:34 +0200
Subject: [PATCH 04/10] fix(test): skip IOException test when running as root
setReadable(false) silently no-ops as root; check canRead() to guard
the assumption correctly so the test is skipped in Docker CI.
Co-Authored-By: Claude Sonnet 4.6
---
.../familienarchiv/importing/MassImportServiceTest.java | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java
index 4442e15d..e6f81f57 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java
@@ -575,7 +575,8 @@ class MassImportServiceTest {
File unreadable = tempDir.resolve("unreadable.pdf").toFile();
buildMinimalImportXlsx(tempDir, "unreadable.pdf");
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
- assumeTrue(unreadable.setReadable(false), "Requires non-root file permissions");
+ unreadable.setReadable(false);
+ assumeTrue(!unreadable.canRead(), "Requires non-root file permissions");
try {
service.runImportAsync();
assertThat(service.getStatus().skipped()).isEqualTo(1);
--
2.49.1
From 53dba772ae1289e88883843b474168a7e65490e2 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Mon, 18 May 2026 15:12:02 +0200
Subject: [PATCH 05/10] fix(import): add @Schema annotations and fix
IOException test coverage
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add @Schema(requiredMode = REQUIRED) to SkippedFile and ImportStatus
record components so TypeScript codegen produces non-optional fields
when generate:api is next run
- Extract openFileStream(File) as package-private method so the
IOException path can be tested deterministically without relying on
OS-level file permissions (which are bypassed when running as root)
- Replace assumeTrue-based IOException test with Mockito spy that stubs
openFileStream — test now runs in CI unconditionally (45 tests, 0 skipped)
Co-Authored-By: Claude Sonnet 4.6
---
.../importing/MassImportService.java | 22 ++++++++++++++---
.../importing/MassImportServiceTest.java | 24 +++++++++----------
2 files changed, 30 insertions(+), 16 deletions(-)
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java b/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java
index b4873b41..1ae77541 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.importing;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.*;
@@ -55,9 +56,20 @@ public class MassImportService {
public enum State { IDLE, RUNNING, DONE, FAILED }
- public record SkippedFile(String filename, String reason) {}
+ public record SkippedFile(
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String filename,
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String reason
+ ) {}
- public record ImportStatus(State state, String statusCode, @JsonIgnore String message, int processed, List skippedFiles, LocalDateTime startedAt) {
+ public record ImportStatus(
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED) State state,
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String statusCode,
+ @JsonIgnore String message,
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int processed,
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List skippedFiles,
+ LocalDateTime startedAt
+ ) {
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@JsonProperty("skipped")
public int skipped() { return skippedFiles.size(); }
}
@@ -300,8 +312,12 @@ public class MassImportService {
return new ProcessResult(processed, skippedFiles);
}
+ InputStream openFileStream(File file) throws IOException {
+ return new FileInputStream(file);
+ }
+
private boolean isPdfMagicBytes(File file) throws IOException {
- try (InputStream is = new FileInputStream(file)) {
+ try (InputStream is = openFileStream(file)) {
byte[] header = is.readNBytes(4);
return header.length == 4
&& header[0] == 0x25 // %
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java
index e6f81f57..31cd2842 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java
@@ -40,7 +40,6 @@ import java.util.zip.ZipOutputStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.junit.jupiter.api.Assumptions.assumeTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@@ -572,20 +571,19 @@ class MassImportServiceTest {
@Test
void runImportAsync_skipsFile_whenMagicBytesCheckThrowsIOException(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("unreadable.pdf"), "some content");
- File unreadable = tempDir.resolve("unreadable.pdf").toFile();
buildMinimalImportXlsx(tempDir, "unreadable.pdf");
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
- unreadable.setReadable(false);
- assumeTrue(!unreadable.canRead(), "Requires non-root file permissions");
- try {
- service.runImportAsync();
- assertThat(service.getStatus().skipped()).isEqualTo(1);
- assertThat(service.getStatus().skippedFiles())
- .extracting(MassImportService.SkippedFile::reason)
- .containsExactly("FILE_READ_ERROR");
- } finally {
- unreadable.setReadable(true);
- }
+ lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
+
+ MassImportService spyService = spy(service);
+ doThrow(new java.io.IOException("simulated read error")).when(spyService).openFileStream(any(File.class));
+
+ spyService.runImportAsync();
+
+ assertThat(spyService.getStatus().skipped()).isEqualTo(1);
+ assertThat(spyService.getStatus().skippedFiles())
+ .extracting(MassImportService.SkippedFile::reason)
+ .containsExactly("FILE_READ_ERROR");
}
// ─── readOds — XXE security regression ───────────────────────────────────
--
2.49.1
From 93da5914a8deffb2745f0e777eed2d8ed84ae759 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Mon, 18 May 2026 16:30:43 +0200
Subject: [PATCH 06/10] test(admin): add regression test for skipped section
hidden during RUNNING
Co-Authored-By: Claude Sonnet 4.6
---
.../system/ImportStatusCard.svelte.test.ts | 20 +++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts b/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts
index a786b787..ab7acacf 100644
--- a/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts
+++ b/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts
@@ -179,4 +179,24 @@ describe('ImportStatusCard', () => {
await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
});
+
+ it('does not show skipped section when RUNNING even with skipped > 0', async () => {
+ const { getByTestId } = render(ImportStatusCard, {
+ props: {
+ importStatus: makeStatus({
+ state: 'RUNNING',
+ statusCode: 'IMPORT_RUNNING',
+ processed: 5,
+ skipped: 2,
+ skippedFiles: [
+ { filename: 'a.pdf', reason: 'INVALID_PDF_SIGNATURE' },
+ { filename: 'b.pdf', reason: 'INVALID_PDF_SIGNATURE' }
+ ]
+ }),
+ ontrigger: () => {}
+ }
+ });
+
+ await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
+ });
});
--
2.49.1
From b38b8c39b863e9aeb225032fdfc6829595bf4edc Mon Sep 17 00:00:00 2001
From: Marcel
Date: Mon, 18 May 2026 16:31:30 +0200
Subject: [PATCH 07/10] fix(admin): address round-2 review concerns on
ImportStatusCard
- Use loop index as each key (handles duplicate filenames)
- Increase skipped filename font from text-xs to text-sm
- Add motion-safe guard to details chevron transition
- Replace text-warning with text-amber-900 to meet WCAG AA contrast
Co-Authored-By: Claude Sonnet 4.6
---
.../src/routes/admin/system/ImportStatusCard.svelte | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/frontend/src/routes/admin/system/ImportStatusCard.svelte b/frontend/src/routes/admin/system/ImportStatusCard.svelte
index 69e267c5..176b90f5 100644
--- a/frontend/src/routes/admin/system/ImportStatusCard.svelte
+++ b/frontend/src/routes/admin/system/ImportStatusCard.svelte
@@ -55,10 +55,10 @@ function reasonLabel(code: string): string {
{m.admin_system_import_status_done()}
{#if importStatus.skipped > 0}
-
+
- {#each importStatus.skippedFiles as { filename, reason } (filename)}
- - {filename} — {reasonLabel(reason)}
+ {#each importStatus.skippedFiles as skipped, i (i)}
+ -
+ {skipped.filename} — {reasonLabel(skipped.reason)}
+
{/each}
--
2.49.1
From f144b025b08d5573604d11881d2bd7d54cc1f9f2 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Mon, 18 May 2026 19:04:06 +0200
Subject: [PATCH 08/10] fix(import): address round-3 review concerns
- Add comment to openFileStream() explaining package-private visibility
is intentional (Mockito spy seam for IOException test)
- Key {#each} skippedFiles by filename instead of array index
- Add test: skipped section hidden when state is FAILED
- Add test: reasonLabel returns raw code for unknown reason strings
Co-Authored-By: Claude Sonnet 4.6
---
.../importing/MassImportService.java | 1 +
.../admin/system/ImportStatusCard.svelte | 2 +-
.../system/ImportStatusCard.svelte.test.ts | 33 +++++++++++++++++++
3 files changed, 35 insertions(+), 1 deletion(-)
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java b/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java
index 1ae77541..58d6493a 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java
@@ -312,6 +312,7 @@ public class MassImportService {
return new ProcessResult(processed, skippedFiles);
}
+ // package-private: Mockito spy in tests can override to inject IOException
InputStream openFileStream(File file) throws IOException {
return new FileInputStream(file);
}
diff --git a/frontend/src/routes/admin/system/ImportStatusCard.svelte b/frontend/src/routes/admin/system/ImportStatusCard.svelte
index 176b90f5..f6e9ab82 100644
--- a/frontend/src/routes/admin/system/ImportStatusCard.svelte
+++ b/frontend/src/routes/admin/system/ImportStatusCard.svelte
@@ -78,7 +78,7 @@ function reasonLabel(code: string): string {
- {#each importStatus.skippedFiles as skipped, i (i)}
+ {#each importStatus.skippedFiles as skipped (skipped.filename)}
-
{skipped.filename} — {reasonLabel(skipped.reason)}
diff --git a/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts b/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts
index ab7acacf..d7470cda 100644
--- a/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts
+++ b/frontend/src/routes/admin/system/ImportStatusCard.svelte.test.ts
@@ -199,4 +199,37 @@ describe('ImportStatusCard', () => {
await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
});
+
+ it('does not show skipped section when FAILED even with skipped > 0', async () => {
+ const { getByTestId } = render(ImportStatusCard, {
+ props: {
+ importStatus: makeStatus({
+ state: 'FAILED',
+ statusCode: 'IMPORT_FAILED_INTERNAL',
+ skipped: 1,
+ skippedFiles: [{ filename: 'bad.pdf', reason: 'INVALID_PDF_SIGNATURE' }]
+ }),
+ ontrigger: () => {}
+ }
+ });
+
+ await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
+ });
+
+ it('shows raw reason code for unknown skip reasons', async () => {
+ const { getByText } = render(ImportStatusCard, {
+ props: {
+ importStatus: makeStatus({
+ state: 'DONE',
+ statusCode: 'IMPORT_DONE',
+ processed: 1,
+ skipped: 1,
+ skippedFiles: [{ filename: 'odd.pdf', reason: 'SOME_FUTURE_CODE' }]
+ }),
+ ontrigger: () => {}
+ }
+ });
+
+ await expect.element(getByText('SOME_FUTURE_CODE', { exact: false })).toBeInTheDocument();
+ });
});
--
2.49.1
From 9443d8e668b418f98536a283ed01e54a246a0dfa Mon Sep 17 00:00:00 2001
From: Marcel
Date: Tue, 19 May 2026 07:44:12 +0200
Subject: [PATCH 09/10] fix(import): surface S3 failures + already-exists in
skippedFiles, a11y + max-height
- Change importSingleDocument return type from boolean to Optional
so callers in processRows receive the skip reason on every non-success path.
S3 upload failures now surface as "S3_UPLOAD_FAILED" and already-imported
documents as "ALREADY_EXISTS" in the skippedFiles list shown in the admin UI.
- Add two new tests: runImportAsync_addsS3UploadFailed_toSkippedFiles and
runImportAsync_addsAlreadyExists_toSkippedFiles; update
importSingleDocument_skips_whenDocumentAlreadyUploadedNotPlaceholder and
the S3-failure test to assert on the Optional return value.
- Add i18n keys for S3_UPLOAD_FAILED and ALREADY_EXISTS in de/en/es messages.
- Svelte ImportStatusCard: add aria-hidden="true" to SVG chevron, wrap
conditional warning section in aria-live="polite" div, add max-h-64
overflow-y-auto to skipped-files to cap height on large batches.
Co-Authored-By: Claude Sonnet 4.6
---
.../importing/MassImportService.java | 27 ++++++--
.../importing/MassImportServiceTest.java | 47 ++++++++++++-
frontend/messages/de.json | 2 +
frontend/messages/en.json | 2 +
frontend/messages/es.json | 2 +
.../admin/system/ImportStatusCard.svelte | 67 ++++++++++---------
6 files changed, 106 insertions(+), 41 deletions(-)
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java b/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java
index 58d6493a..e09aaa76 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/importing/MassImportService.java
@@ -69,9 +69,15 @@ public class MassImportService {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List skippedFiles,
LocalDateTime startedAt
) {
- @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
+ // Note: @Schema on a record accessor method is not picked up by SpringDoc; the
+ // "skipped" count is a computed convenience field derived from skippedFiles.size().
@JsonProperty("skipped")
public int skipped() { return skippedFiles.size(); }
+
+ /** Defensive-copy constructor — callers cannot mutate the stored list after construction. */
+ public ImportStatus {
+ skippedFiles = List.copyOf(skippedFiles);
+ }
}
record ProcessResult(int processed, List skippedFiles) {}
@@ -304,8 +310,10 @@ public class MassImportService {
}
}
- boolean imported = importSingleDocument(cells, fileOnDisk, filename, index);
- if (imported) {
+ Optional skipReason = importSingleDocument(cells, fileOnDisk, filename, index);
+ if (skipReason.isPresent()) {
+ skippedFiles.add(new SkippedFile(filename, skipReason.get()));
+ } else {
processed++;
}
}
@@ -328,12 +336,17 @@ public class MassImportService {
}
}
+ /**
+ * Imports a single document row.
+ *
+ * @return empty Optional on success; an Optional containing the skip reason on failure/skip.
+ */
@Transactional
- protected boolean importSingleDocument(List cells, Optional file, String originalFilename, String index) {
+ protected Optional importSingleDocument(List cells, Optional file, String originalFilename, String index) {
Optional existing = documentService.findByOriginalFilename(originalFilename);
if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) {
log.info("Dokument {} existiert bereits, überspringe.", originalFilename);
- return false;
+ return Optional.of("ALREADY_EXISTS");
}
String archiveBox = getCell(cells, colBox);
@@ -369,7 +382,7 @@ public class MassImportService {
status = DocumentStatus.UPLOADED;
} catch (Exception e) {
log.error("S3 Upload Fehler für {}", file.get().getName(), e);
- return false;
+ return Optional.of("S3_UPLOAD_FAILED");
}
}
@@ -411,7 +424,7 @@ public class MassImportService {
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
}
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
- return true;
+ return Optional.empty();
}
// --- Helpers ---
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java
index 31cd2842..26d38679 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/importing/MassImportServiceTest.java
@@ -154,9 +154,49 @@ class MassImportServiceTest {
.build();
when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing));
- service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
+ Optional result = service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
verify(documentService, never()).save(any());
+ assertThat(result).isPresent().contains("ALREADY_EXISTS");
+ }
+
+ // ─── importSingleDocument — S3 failure surfaced in skippedFiles ──────────
+
+ @Test
+ void runImportAsync_addsS3UploadFailed_toSkippedFiles_whenS3Throws(@TempDir Path tempDir) throws Exception {
+ byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
+ Files.write(tempDir.resolve("upload_fail.pdf"), pdfHeader);
+ buildMinimalImportXlsx(tempDir, "upload_fail.pdf");
+ ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
+ when(documentService.findByOriginalFilename("upload_fail.pdf")).thenReturn(Optional.empty());
+ doThrow(new RuntimeException("S3 unavailable"))
+ .when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
+
+ service.runImportAsync();
+
+ assertThat(service.getStatus().skipped()).isEqualTo(1);
+ assertThat(service.getStatus().skippedFiles())
+ .extracting(MassImportService.SkippedFile::filename, MassImportService.SkippedFile::reason)
+ .containsExactly(org.assertj.core.groups.Tuple.tuple("upload_fail.pdf", "S3_UPLOAD_FAILED"));
+ }
+
+ @Test
+ void runImportAsync_addsAlreadyExists_toSkippedFiles_whenDocumentAlreadyUploaded(@TempDir Path tempDir) throws Exception {
+ buildMinimalImportXlsx(tempDir, "existing.pdf");
+ ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
+ Document existing = Document.builder()
+ .id(UUID.randomUUID())
+ .originalFilename("existing.pdf")
+ .status(DocumentStatus.UPLOADED)
+ .build();
+ when(documentService.findByOriginalFilename("existing.pdf")).thenReturn(Optional.of(existing));
+
+ service.runImportAsync();
+
+ assertThat(service.getStatus().skipped()).isEqualTo(1);
+ assertThat(service.getStatus().skippedFiles())
+ .extracting(MassImportService.SkippedFile::reason)
+ .containsExactly("ALREADY_EXISTS");
}
// ─── importSingleDocument — create new document (metadata only) ───────────
@@ -208,7 +248,7 @@ class MassImportServiceTest {
}
@Test
- void importSingleDocument_returnsEarly_whenS3UploadFails(@TempDir Path tempDir) throws Exception {
+ void importSingleDocument_returnsS3UploadFailed_whenS3UploadFails(@TempDir Path tempDir) throws Exception {
Path tempFile = tempDir.resolve("fail.pdf");
Files.write(tempFile, "data".getBytes());
@@ -216,10 +256,11 @@ class MassImportServiceTest {
doThrow(new RuntimeException("S3 error"))
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
- service.importSingleDocument(
+ Optional result = service.importSingleDocument(
minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail");
verify(documentService, never()).save(any());
+ assertThat(result).isPresent().contains("S3_UPLOAD_FAILED");
}
// ─── importSingleDocument — sender handling ───────────────────────────────
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index ace72a7e..92b405b8 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -353,6 +353,8 @@
"admin_system_import_skipped_label": "übersprungen",
"import_reason_invalid_pdf_signature": "Keine gültige PDF-Signatur",
"import_reason_file_read_error": "Fehler beim Lesen der Datei",
+ "import_reason_s3_upload_failed": "Upload-Fehler (S3)",
+ "import_reason_already_exists": "Bereits importiert",
"admin_system_import_status_failed": "Import fehlgeschlagen",
"admin_system_import_failed_no_spreadsheet": "Keine Tabellendatei gefunden.",
"admin_system_import_failed_internal": "Interner Fehler beim Import.",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 4bcc3ca7..cf8b3f01 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -353,6 +353,8 @@
"admin_system_import_skipped_label": "skipped",
"import_reason_invalid_pdf_signature": "Invalid PDF signature",
"import_reason_file_read_error": "File read error",
+ "import_reason_s3_upload_failed": "Upload error (S3)",
+ "import_reason_already_exists": "Already imported",
"admin_system_import_status_failed": "Import failed",
"admin_system_import_failed_no_spreadsheet": "No spreadsheet file found.",
"admin_system_import_failed_internal": "Import failed due to an internal error.",
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index b52b17c3..d0423397 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -353,6 +353,8 @@
"admin_system_import_skipped_label": "omitidos",
"import_reason_invalid_pdf_signature": "Firma PDF no válida",
"import_reason_file_read_error": "Error al leer el archivo",
+ "import_reason_s3_upload_failed": "Error de carga (S3)",
+ "import_reason_already_exists": "Ya importado",
"admin_system_import_status_failed": "Importación fallida",
"admin_system_import_failed_no_spreadsheet": "No se encontró ninguna hoja de cálculo.",
"admin_system_import_failed_internal": "Error interno durante la importación.",
diff --git a/frontend/src/routes/admin/system/ImportStatusCard.svelte b/frontend/src/routes/admin/system/ImportStatusCard.svelte
index f6e9ab82..c18fcef3 100644
--- a/frontend/src/routes/admin/system/ImportStatusCard.svelte
+++ b/frontend/src/routes/admin/system/ImportStatusCard.svelte
@@ -19,6 +19,8 @@ const failureMessage = $derived(
function reasonLabel(code: string): string {
if (code === 'INVALID_PDF_SIGNATURE') return m.import_reason_invalid_pdf_signature();
if (code === 'FILE_READ_ERROR') return m.import_reason_file_read_error();
+ if (code === 'S3_UPLOAD_FAILED') return m.import_reason_s3_upload_failed();
+ if (code === 'ALREADY_EXISTS') return m.import_reason_already_exists();
return code;
}
@@ -54,38 +56,41 @@ function reasonLabel(code: string): string {
{m.admin_system_import_status_done()}
- {#if importStatus.skipped > 0}
-
-
-
-
- {importStatus.skipped}
+ {#if importStatus.skipped > 0}
+
+
+
-
-
- {#each importStatus.skippedFiles as skipped (skipped.filename)}
- -
- {skipped.filename} — {reasonLabel(skipped.reason)}
-
- {/each}
-
-
- {/if}
+
+
+
+ {importStatus.skipped}
+
+ {m.admin_system_import_skipped_label()}
+
+
+
+
+ {#each importStatus.skippedFiles as skipped (skipped.filename)}
+ -
+ {skipped.filename} — {reasonLabel(skipped.reason)}
+
+ {/each}
+
+
+ {/if}
+