diff --git a/.github/workflows/build-linux.yaml b/.github/workflows/build-linux.yaml index b4cbb024..11b21acc 100644 --- a/.github/workflows/build-linux.yaml +++ b/.github/workflows/build-linux.yaml @@ -1,9 +1,9 @@ name: Build Linux on: push: - branches: [ "main" ] + branches: [ "feature/automated-ui-test" ] pull_request: - branches: [ "main" ] + branches: [ "feature/automated-ui-test" ] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true @@ -19,8 +19,37 @@ jobs: java-version: '21' distribution: 'adopt' cache: maven + - name: Install and configure Tinyproxy + run: | + sudo apt-get update + sudo apt-get install -y tinyproxy + + sudo sed -i 's/^Allow 127.0.0.1/Allow 0.0.0.0\/0/' /etc/tinyproxy/tinyproxy.conf + + sudo systemctl start tinyproxy + + curl -x http://localhost:8888 https://www.github.com + - name: Install required packages + run: sudo apt-get update && sudo apt-get install -y xvfb libgtk-3-0 + - name: Start Xvfb + run: | + Xvfb :99 -screen 0 1024x768x24 & + echo "Xvfb started" + - name: Set DISPLAY environment variable + run: echo "DISPLAY=:99" >> $GITHUB_ENV - name: Build with Maven - run: mvn -B clean install -Plinux -Pci-build -Dno-native-profile + run: mvn -B clean install -Plinux -Pci-build -Dno-native-profile -Dmaven.test.skip=true + - name: Run headless test + run: mvn test -Plinux -Pci-build -Dno-native-profile + - name: Run headless test with proxy + run: mvn test -Plinux -Pci-build -Dno-native-profile -Dhttps.proxyHost=localhost -Dhttps.proxyPort=8888 + - name: Upload reference files and test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test resources + path: | + pdf-over-gui/src/test/resources/ - name: Describe current commit run: echo "commit_sha=${GITHUB_SHA::7}" >> $GITHUB_ENV - name: Remove Previous Build Artifacts @@ -38,9 +67,9 @@ jobs: - name: Upload Build Artifacts uses: actions/upload-artifact@v4 with: - name: pdf-over-${{ github.event.pull_request.number || github.ref_name }}-${{ env.commit_sha }}-linux-x86_64 + name: pdf-over-feature-automated-ui-test-${{ env.commit_sha }}-linux-x86_64 path: pdf-over.tar permissions: - contents: read - actions: write + contents: read + actions: write \ No newline at end of file diff --git a/.github/workflows/build-mac-aarch64.yaml b/.github/workflows/build-mac-aarch64.yaml index 9fe85b71..1278965b 100644 --- a/.github/workflows/build-mac-aarch64.yaml +++ b/.github/workflows/build-mac-aarch64.yaml @@ -1,9 +1,10 @@ name: Build MacOS (aarch64) on: push: - branches: [ "main" ] + branches: [ "feature/automated-ui-test#" ] pull_request: - branches: [ "main" ] + branches: [ "feature/automated-ui-test#" ] + concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true @@ -20,7 +21,9 @@ jobs: distribution: 'adopt' cache: maven - name: Build with Maven - run: mvn -B clean install -Pmac-aarch64 -Pci-build -Dno-native-profile + run: mvn -B clean install -Pmac-aarch64 -Pci-build -Dno-native-profile -Dmaven.test.skip=true + - name: Run tests + run: mvn test -DargLine="-XstartOnFirstThread" -Dmaven.test.skip=true - name: Describe current commit run: echo "commit_sha=${GITHUB_SHA::7}" >> $GITHUB_ENV - name: Remove Previous Build Artifacts @@ -38,9 +41,9 @@ jobs: - name: Upload Build Artifacts uses: actions/upload-artifact@v4 with: - name: pdf-over-${{ github.event.pull_request.number || github.ref_name }}-${{ env.commit_sha }}-macos-aarch64 + name: pdf-over-feature-automated-ui-test-${{ env.commit_sha }}-macos-aarch64 path: pdf-over.tar permissions: - contents: read - actions: write + contents: read + actions: write \ No newline at end of file diff --git a/.github/workflows/build-mac-x86_64.yaml b/.github/workflows/build-mac-x86_64.yaml index 50ea3167..eae47958 100644 --- a/.github/workflows/build-mac-x86_64.yaml +++ b/.github/workflows/build-mac-x86_64.yaml @@ -1,9 +1,9 @@ name: Build MacOS (x86_64) on: push: - branches: [ "main" ] + branches: [ "feature/automated-ui-test#" ] pull_request: - branches: [ "main" ] + branches: [ "feature/automated-ui-test#" ] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true @@ -20,7 +20,9 @@ jobs: distribution: 'adopt' cache: maven - name: Build with Maven - run: mvn -B clean install -Pmac -Pci-build -Dno-native-profile + run: mvn -B clean install -Pmac -Pci-build -Dno-native-profile -Dmaven.test.skip=true + - name: Run Tests + run: mvn test -DargLine="-XstartOnFirstThread" -Dmaven.test.skip=true - name: Describe current commit run: echo "commit_sha=${GITHUB_SHA::7}" >> $GITHUB_ENV - name: Remove Previous Build Artifacts @@ -38,9 +40,9 @@ jobs: - name: Upload Build Artifacts uses: actions/upload-artifact@v4 with: - name: pdf-over-${{ github.event.pull_request.number || github.ref_name }}-${{ env.commit_sha }}-macos-x86_64 + name: pdf-over-feature-automated-ui-test-${{ env.commit_sha }}-macos-x86_64 path: pdf-over.tar permissions: - contents: read - actions: write + contents: read + actions: write \ No newline at end of file diff --git a/.github/workflows/build-windows.yaml b/.github/workflows/build-windows.yaml index cc9305e6..d8983e8f 100644 --- a/.github/workflows/build-windows.yaml +++ b/.github/workflows/build-windows.yaml @@ -1,9 +1,10 @@ + name: Build Windows on: push: - branches: [ "main" ] + branches: [ "feature/automated-ui-test" ] pull_request: - branches: [ "main" ] + branches: [ "feature/automated-ui-test" ] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true @@ -20,7 +21,16 @@ jobs: distribution: 'adopt' cache: maven - name: Build with Maven - run: mvn -B clean install -Pwindows -Pci-build -Dno-native-profile + run: mvn -B clean install -Pwindows -Pci-build -Dno-native-profile -DskipTests + - name: Run tests + run: mvn -B test -Pwindows -Pci-build -Dno-native-profile + - name: Upload reference files and test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test resources + path: | + pdf-over-gui/src/test/resources/ - name: Describe current commit run: echo "commit_sha=$("${{ github.sha }}".SubString(0,7))" >> $env:GITHUB_ENV - name: Remove Previous Build Artifacts @@ -34,9 +44,9 @@ jobs: - name: Upload Build Artifacts uses: actions/upload-artifact@v4 with: - name: pdf-over-${{ github.event.pull_request.number || github.ref_name }}-${{ env.commit_sha }}-windows-x86_64 + name: pdf-over-feature-automated-ui-test-${{ env.commit_sha }}-windows-x86_64 path: pdf-over-build permissions: - contents: read - actions: write + contents: read + actions: write \ No newline at end of file diff --git a/pdf-over-gui/pom.xml b/pdf-over-gui/pom.xml index 63689f5b..06642c37 100644 --- a/pdf-over-gui/pom.xml +++ b/pdf-over-gui/pom.xml @@ -26,6 +26,11 @@ logback-classic 1.5.18 + + commons-cli + commons-cli + 1.10.0 + at.a-sit pdf-over-signer @@ -74,17 +79,39 @@ webauthn-client-java 0.0.1 + + org.junit.jupiter + junit-jupiter-params + test + org.junit.jupiter junit-jupiter-engine test + + org.junit.vintage + junit-vintage-engine + test + org.projectlombok lombok 1.18.36 provided + + org.eclipse.swtbot.swt + finder + 4.3.0.202509040840 + test + + + org.hamcrest + hamcrest + 3.0 + test + @@ -104,7 +131,6 @@ - org.eclipse.m2e lifecycle-mapping @@ -133,6 +159,11 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + maven-clean-plugin 3.2.0 @@ -846,4 +877,4 @@ file://${pdfover-build.root-dir}/repo - + \ No newline at end of file diff --git a/pdf-over-gui/src/main/resources/cfg/PDFASConfig.zip b/pdf-over-gui/src/main/resources/cfg/PDFASConfig.zip index 3297c51f..248faba8 100644 Binary files a/pdf-over-gui/src/main/resources/cfg/PDFASConfig.zip and b/pdf-over-gui/src/main/resources/cfg/PDFASConfig.zip differ diff --git a/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/AbstractSignatureUITest.java b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/AbstractSignatureUITest.java new file mode 100644 index 00000000..ddc4cd28 --- /dev/null +++ b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/AbstractSignatureUITest.java @@ -0,0 +1,254 @@ +package at.asit.pdfover.gui.tests; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.concurrent.BrokenBarrierException; +import java.util.concurrent.CyclicBarrier; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swtbot.swt.finder.SWTBot; +import org.eclipse.swtbot.swt.finder.exceptions.WidgetNotFoundException; +import org.eclipse.swtbot.swt.finder.waits.ICondition; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import at.asit.pdfover.commons.Constants; +import at.asit.pdfover.commons.Messages; +import at.asit.pdfover.commons.Profile; +import at.asit.pdfover.gui.Main; +import at.asit.pdfover.gui.workflow.StateMachine; +import at.asit.pdfover.gui.workflow.config.ConfigurationManager; +import lombok.NonNull; + +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.FilenameUtils; + +import static org.junit.jupiter.api.Assertions.*; + +public abstract class AbstractSignatureUITest { + + private static Thread uiThread; + private static Shell shell; + private static StateMachine sm; + private SWTBot bot; + + private static final File inputFile = new File("src/test/resources/TestFile.pdf"); + private static String outputDir = inputFile.getAbsoluteFile().getParent(); + private Profile currentProfile; + private final String postFix = "_superSigned"; + private static final List profiles = new ArrayList<>(); + + private static final Logger logger = LoggerFactory + .getLogger(AbstractSignatureUITest.class); + + protected String str(String k) { return Messages.getString(k); } + + @BeforeAll + public static void prepareTestEnvironment() throws IOException { + deleteTempDir(); + createTempDir(); + setSignatureProfiles(); + setupPdfBox(); + } + + private static void setupPdfBox() throws IOException{ + byte[] pdfBytes; + try (PDDocument doc = new PDDocument()) { + doc.addPage(new PDPage()); + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + doc.save(baos); + pdfBytes = baos.toByteArray(); + } + } + + // Load PDF to trigger initialization + try (PDDocument ignored = PDDocument.load(pdfBytes)) { + + } + } + + private static void deleteTempDir() throws IOException { + String root = inputFile.getAbsoluteFile().getParent(); + File dir = new File(root); + for (File f : Objects.requireNonNull(dir.listFiles())) { + if (f.getName().startsWith("output_")) { + FileUtils.deleteDirectory(f); + } + } + } + + private static void createTempDir() throws IOException { + Path tmpDir = Files.createTempDirectory(Paths.get(inputFile.getAbsoluteFile().getParent()), "output_"); + tmpDir.toFile().deleteOnExit(); + outputDir = FilenameUtils.separatorsToSystem(tmpDir.toString()); + } + + + @BeforeEach + public final void setupUITest() throws InterruptedException, BrokenBarrierException { + final CyclicBarrier swtBarrier = new CyclicBarrier(2); + + if (uiThread == null) { + uiThread = new Thread(new Runnable() { + @Override + public void run() { + + Display.getDefault().syncExec(() -> { + currentProfile = getCurrentProfile(); + setConfig(currentProfile); + sm = Main.setup(new String[]{inputFile.getAbsolutePath()}); + shell = sm.getMainShell(); + + try { + swtBarrier.await(); + } catch (InterruptedException | BrokenBarrierException e) { + throw new RuntimeException(e); + } + sm.start(); + }); + } + }); + uiThread.setDaemon(true); + uiThread.start(); + } + swtBarrier.await(); + bot = new SWTBot(shell); + } + + @AfterEach + public void reset() throws InterruptedException { + deleteOutputFile(); + closeShell(); + } + + public void closeShell() throws InterruptedException { + Display.getDefault().syncExec(new Runnable() { + public void run() { + shell.close(); + } + }); + uiThread.join(); + uiThread = null; + } + + + protected void setCredentials() { + try { + ICondition widgetExists = new WidgetExistsCondition(str("mobileBKU.number")); + bot.waitUntil(widgetExists, 80000); + bot.textWithLabel(str("mobileBKU.number")).setText("TestUser-1902503362"); + bot.textWithLabel(str("mobileBKU.password")).setText("123456789"); + bot.button(str("common.Ok")).click(); + } + catch (WidgetNotFoundException wnf) { + bot.button(str("common.Cancel")).click(); + fail(wnf.getMessage()); + } + + File output = new File(getPathOutputFile()); + ICondition outputExists = new FileExistsCondition(output); + bot.waitUntil(outputExists, 20000); + + if(!output.exists()) { + bot.button(str("common.Cancel")).click(); + } + assertTrue(output.exists(), "Received signed PDF"); + } + + private void deleteOutputFile() { + if (getPathOutputFile() != null) { + File outputFile = new File(getPathOutputFile()); + outputFile.delete(); + assertFalse(outputFile.exists()); + logger.info("Deleted output file"); + } + } + + protected void testSignature(boolean negative, boolean captureRefImage) throws IOException { + String outputFile = getPathOutputFile(); + assertNotNull(currentProfile); + assertNotNull(outputFile); + + try (SignaturePositionValidator provider = new SignaturePositionValidator(negative, captureRefImage, currentProfile, outputFile)) { + provider.verifySignaturePosition(); + } catch (Exception e) { + fail("Error verifiying signature position", e); + } + } + + private static void setProperty(@NonNull Properties props, @NonNull String key, @NonNull String value) { props.setProperty(key, value); } + + private void setConfig(Profile currentProfile) { + ConfigurationManager cm = new ConfigurationManager(); + Point size = cm.getMainWindowSize(); + + Map testParams = Map.ofEntries( + Map.entry(Constants.CFG_BKU, cm.getDefaultBKUPersistent().name()), + Map.entry(Constants.CFG_KEYSTORE_PASSSTORETYPE, "memory"), + Map.entry(Constants.CFG_LOCALE, cm.getInterfaceLocale().toString()), + Map.entry(Constants.CFG_LOGO_ONLY_SIZE, Double.toString(cm.getLogoOnlyTargetSize())), + Map.entry(Constants.CFG_MAINWINDOW_SIZE, size.x + "," + size.y), + Map.entry(Constants.CFG_OUTPUT_FOLDER, outputDir), + Map.entry(Constants.CFG_POSTFIX, postFix), + Map.entry(Constants.CFG_SIGNATURE_NOTE, currentProfile.getDefaultSignatureBlockNote(Locale.GERMANY)), + Map.entry(Constants.CFG_SIGNATURE_POSITION, "auto"), + Map.entry(Constants.SIGNATURE_PROFILE, currentProfile.toString()), + Map.entry(Constants.CFG_SIGNATURE_LOCALE, cm.getSignatureLocale().toString()) + ); + + File pdfOverConfig = new File(Constants.CONFIG_DIRECTORY + File.separator + Constants.DEFAULT_CONFIG_FILENAME); + Properties props = new Properties(); + testParams.forEach((k, v) -> setProperty(props, k, v)); + + try { + FileOutputStream outputStream = new FileOutputStream(pdfOverConfig, false); + props.store(outputStream, "TEST Configuration file was generated!"); + } catch (IOException e) { + logger.warn("Failed to create configuration file."); + } + } + + public static void setSignatureProfiles() { + Collections.addAll(profiles, Profile.values()); + assert(profiles.containsAll(EnumSet.allOf(Profile.class))); + } + + public Profile getCurrentProfile() { + currentProfile = profiles.get(0); + profiles.remove(0); + if (profiles.isEmpty()) { + setSignatureProfiles(); + } + return currentProfile; + } + + /** + * Returns path of the signed document. + */ + private String getPathOutputFile() { + String fileNameSigned = inputFile + .getName() + .substring(0, inputFile.getName().lastIndexOf('.')) + .concat(postFix) + .concat(".pdf"); + String pathOutputFile = FilenameUtils.separatorsToSystem(outputDir + .concat("/") + .concat(fileNameSigned)); + assertNotNull(pathOutputFile); + return pathOutputFile; + } + +} \ No newline at end of file diff --git a/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/FileExistsCondition.java b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/FileExistsCondition.java new file mode 100644 index 00000000..5bf92908 --- /dev/null +++ b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/FileExistsCondition.java @@ -0,0 +1,25 @@ +package at.asit.pdfover.gui.tests; + +import org.eclipse.swtbot.swt.finder.waits.DefaultCondition; + +import java.io.File; + +public class FileExistsCondition extends DefaultCondition { + + private final File file; + + public FileExistsCondition(File file) { + this.file = file; + } + + @Override + public boolean test() { + return file.exists(); + } + + @Override + public String getFailureMessage() { + return String.format("Could not create output file %s", file.getName()); + } + +} \ No newline at end of file diff --git a/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/ImageComparisonResult.java b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/ImageComparisonResult.java new file mode 100644 index 00000000..3da2eff2 --- /dev/null +++ b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/ImageComparisonResult.java @@ -0,0 +1,126 @@ +package at.asit.pdfover.gui.tests; + +import lombok.Getter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Holds the results of comparing two images, including the modified images + * and difference image. + */ +@Getter +public class ImageComparisonResult implements AutoCloseable { + private static final Logger logger = LoggerFactory.getLogger(ImageComparisonResult.class); + private static final Color BACKGROUND_COLOR = Color.WHITE; + private static final Color DIFFERENCE_COLOR = Color.RED; + + private final BufferedImage modifiedSigned; + private final BufferedImage modifiedReference; + private final BufferedImage differenceImage; + private final boolean equal; + + /** + * Creates a new comparison result by comparing two images. + * + * @param signedImage the modified signed image + * @param referenceImage the modified reference image + */ + public ImageComparisonResult(BufferedImage signedImage, BufferedImage referenceImage, BufferedImage differenceImage, boolean areEqual) { + this.modifiedSigned = Objects.requireNonNull(signedImage, "Signed image cannot be null"); + this.modifiedReference = Objects.requireNonNull(referenceImage, "Reference image cannot be null"); + this.differenceImage = Objects.requireNonNull(differenceImage, "Difference image cannot be null"); + this.equal = areEqual; + } + + /** + * Creates a new blank image for visualizing differences between compared images. + * The image is initialized with a white background ({@link #BACKGROUND_COLOR}) + * and will be used to mark pixel differences in red ({@link #DIFFERENCE_COLOR}). + * + * @param width The width of the image in pixels + * @param height The height of the image in pixels + * @return A new BufferedImage initialized with a white background + */ + private static BufferedImage createDifferenceImage(int width, int height) { + BufferedImage diff = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g2d = diff.createGraphics(); + try { + g2d.setColor(BACKGROUND_COLOR); + g2d.fillRect(0, 0, width, height); + return diff; + } finally { + g2d.dispose(); + } + } + + /** + * Performs pixel-by-pixel comparison of two images and marks differences. + * + * @param signed The signed image to compare + * @param reference The reference image to compare against + * @param difference The image where differences will be marked in {@link #DIFFERENCE_COLOR} + * @return true if all pixels match exactly, false if any differences are found + */ + private static boolean comparePixels(BufferedImage signed, BufferedImage reference, BufferedImage difference) { + boolean match = true; + for (int x = 0; x < signed.getWidth(); x++) { + for (int y = 0; y < signed.getHeight(); y++) { + if (signed.getRGB(x, y) != reference.getRGB(x, y)) { + match = false; + difference.setRGB(x, y, Color.RED.getRGB()); + } + } + } + return match; + } + + /** + * Validates that the dimensions of the signed image match the reference image exactly. + * Throws AssertionError if either width or height differs between the images. + * + * @param signed The signed image to validate + * @param reference The reference image to compare against + */ + private static void validateImageDimensions(BufferedImage signed, BufferedImage reference) { + Objects.requireNonNull(signed, "Signed image cannot be null"); + Objects.requireNonNull(reference, "Reference image cannot be null"); + + assertEquals(reference.getWidth(), signed.getWidth(), + "Width of image differs from reference"); + assertEquals(reference.getHeight(), signed.getHeight(), + "Height of image differs from reference"); + } + + /** + * Creates a new comparison result by comparing two images. + */ + public static ImageComparisonResult compare(BufferedImage signedImage, BufferedImage referenceImage) { + validateImageDimensions(signedImage, referenceImage); + + BufferedImage differenceImage = createDifferenceImage(signedImage.getWidth(), signedImage.getHeight()); + boolean areEqual = comparePixels(signedImage, referenceImage, differenceImage); + + return new ImageComparisonResult(signedImage, referenceImage, differenceImage, areEqual); + } + + @Override + public void close() { + try { + dispose(); + } catch (Exception e) { + logger.warn("Error disposing image resources", e); + } + } + + public void dispose() { + modifiedSigned.flush(); + modifiedReference.flush(); + differenceImage.flush(); + } +} \ No newline at end of file diff --git a/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/ReferenceFile.java b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/ReferenceFile.java new file mode 100644 index 00000000..241f9232 --- /dev/null +++ b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/ReferenceFile.java @@ -0,0 +1,60 @@ +package at.asit.pdfover.gui.tests; + + +import at.asit.pdfover.commons.Profile; +import lombok.Getter; + +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Enum representing reference files for signature profiles. + */ +@Getter +public enum ReferenceFile { + AMTSSIGNATUR("refFileAmtssignatur.png", Profile.AMTSSIGNATURBLOCK), + SIGNATURBLOCK_SMALL("refFileSignaturblockSmallNote.png", Profile.SIGNATURBLOCK_SMALL), + BASE_LOGO("refFileBaseLogo.png", Profile.BASE_LOGO), + INVISIBLE("refFileInvisible.png", Profile.INVISIBLE), + TEST_NEGATIVE("refFileTestNegative.png", null); + + private final String fileName; + private final Profile profile; + private static final Map REFERENCE_FILES; + + static { + REFERENCE_FILES = Arrays.stream(values()) + .filter(ref -> ref.profile != null) + .collect(Collectors.toMap( + ref -> ref.profile, + ref -> ref.fileName + )); + } + + ReferenceFile(String fileName, Profile profile) { + this.fileName = fileName; + this.profile = profile; + } + + /** + * Gets the reference file for a specific profile. + * @param profile the signature profile + * @return the corresponding reference file name + */ + public static String getFileNameForProfile(Profile profile) { + String fileName = REFERENCE_FILES.get(profile); + if (fileName == null) { + throw new IllegalArgumentException("No reference file defined for profile: " + profile); + } + return fileName; + } + + /** + * Gets the reference file name for negative test cases. + * @return the negative test reference file name + */ + public static String getNegativeTestFileName() { + return TEST_NEGATIVE.fileName; + } +} \ No newline at end of file diff --git a/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/SignaturePositionValidator.java b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/SignaturePositionValidator.java new file mode 100644 index 00000000..29cd85fa --- /dev/null +++ b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/SignaturePositionValidator.java @@ -0,0 +1,379 @@ +package at.asit.pdfover.gui.tests; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.List; + +import javax.imageio.ImageIO; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.PDFRenderer; + +import at.asit.pdfover.commons.Profile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Validates the position of signatures in PDF documents by comparing them with reference images. + * This class provides functionality to: + * * - Capture and save reference images for comparison + * * - Compare signature positions between signed documents and reference images + * * - Handle ignored areas in signature blocks (e.g., date fields) + * * + * * The class supports different signature profiles (Amtssignatur, Base Logo, etc.) + * * and can perform both positive and negative comparison tests. + * * @author resekk, mtappler + */ +public class SignaturePositionValidator implements AutoCloseable { + private static final Logger logger = LoggerFactory.getLogger(SignaturePositionValidator.class); + + private static int DEFAULT_PAGE = 1; + private static final float ZOOM = 4; + private static final String UNIX_SEPARATOR = "/"; + private static final String PNG_FORMAT = "png"; + + private static final String REF_IMAGE_IGNORED = "refImage_ignored." + PNG_FORMAT; + private static final String SIG_PAGE_IMAGE_IGNORED = "sigPageImage_ignored." + PNG_FORMAT; + private static final String DIFFERENCE_IMAGE = "difference." + PNG_FORMAT; + + private final boolean captureRefImage; + private final String refFileDir; + private final Profile currentProfile; + private final boolean isNegativeTest; + private final String pathOutputFile; + + /** + * Map of profiles and belonging coordinates for the black triangle + * which overwrites ignored areas such as the date. + */ + private static final Map IGNORED_AREAS = Map.of( + Profile.AMTSSIGNATURBLOCK, "215,679,142,9", + Profile.SIGNATURBLOCK_SMALL, "287,690,90,6" + ); + + public SignaturePositionValidator(boolean negative, boolean captureRefImage, + Profile currentProfile, String outputFile ) { + this.isNegativeTest = negative; + this.captureRefImage = captureRefImage; + this.currentProfile = currentProfile; + this.pathOutputFile = outputFile; + this.refFileDir = "src/test/resources"; + } + + /** + * Verifies the signature position by comparing the signed PDF against a reference image. + * Positive tests expect the images to match. + * Negative tests expect the images to differ. + */ + protected void verifySignaturePosition() throws IOException { + String refImagePath = getReferencePath(); + logger.debug("Starting signature position verification with reference: {}", refImagePath); + + if (shouldSkipComparison()) return; + + BufferedImage signedImage = captureImage(pathOutputFile); + BufferedImage referenceImage = loadReferenceImage(refImagePath); + + try { + applyIgnoredAreas(signedImage, referenceImage); + + try (ImageComparisonResult result = ImageComparisonResult.compare(signedImage, referenceImage)) { + saveComparisonResults(result, refImagePath); + validateComparisonResult(result); + } + } finally { + if (signedImage != null) signedImage.flush(); + if (referenceImage != null) referenceImage.flush(); + } + } + + /** + * Validates the result of an image comparison based on the test type. + * For positive tests, the images should match exactly. + * For negative tests, the images should have differences. + * + * @param result The ImageComparisonResult containing the comparison outcome + */ + private void validateComparisonResult(ImageComparisonResult result) { + String testType = isNegativeTest ? "negative" : "positive"; + assertTrue(isNegativeTest != result.isEqual(), + String.format("Unexpected comparison result for %s test " + currentProfile + ". Images %s match.", + testType, result.isEqual() ? "do" : "do not")); + } + + /** + * Applies ignored areas to signed image and reference image by clearing specified rectangular regions. + * For each image a Graphics context is created. + * Defined areas are cleared using the current profile's ignored area definitions. + * + * @param images One or more BufferedImages to apply ignored areas to + */ + private void applyIgnoredAreas(BufferedImage... images) { + List areas = parseIgnoredAreas(); + int height = images[0].getHeight(); + + for (BufferedImage image : images) { + Graphics g = image.getGraphics(); + try { + for (Rectangle area : areas) { + clearIgnoredArea(g, area, height); + } + } finally { + if (g != null) { + g.dispose(); + } + } + } + } + + /** + * Parses the ignored areas defined for the current profile into a list of Rectangle objects. + * Areas are defined in the {@link #IGNORED_AREAS} map as semicolon-separated strings, + * each representing a rectangle in the format "x,y,width,height". + * + * @return List of Rectangle objects representing areas to ignore during comparison, + * or an empty list if no areas are defined for the current profile + */ + private List parseIgnoredAreas() { + String areaDefinitions = IGNORED_AREAS.get(currentProfile); + if (areaDefinitions == null || areaDefinitions.trim().isEmpty()) { + return Collections.emptyList(); + } + + return Arrays.stream(areaDefinitions.split(";")) + .map(this::parseIgnoredArea) + .filter(Objects::nonNull) + .toList(); + } + + /** + * This parses one ignored area definition and returns a Rectangle-object + * representing it. The definitions have the exact format + * ",,,", with x and y specifying the coordinates of + * the upper left corner of the area and width and height specifying the + * size in pixels (width and height extends to the right and the bottom of + * the image). + * + * @param coordinates + * an ignored area definition + * @return a rectangle representing the area + */ + private Rectangle parseIgnoredArea(String coordinates) { + try { + String[] parts = coordinates.split(","); + if (parts.length != 4) { + logger.warn("Invalid coordinate format: {}", coordinates); + return null; + } + return new Rectangle( + Integer.parseInt(parts[0].trim()), + Integer.parseInt(parts[1]), + Integer.parseInt(parts[2]), + Integer.parseInt(parts[3]) + ); + } catch (NumberFormatException e) { + logger.warn("Failed to parse coordinates: {}", coordinates, e); + return null; + } + } + + /** + * Clears a rectangular area in the image using the provided Graphics context. + * Converts PDF coordinates to AWT coordinates and applies the zoom factor. + * In PDF coordinates, (0,0) is at the bottom-left, while in AWT it's at the top-left. + * + * @param g The Graphics context to draw on + * @param area The Rectangle defining the area to clear in PDF coordinates + * @param imageHeight The total height of the image for coordinate conversion + * @see #ZOOM The zoom factor applied to the coordinates + */ + private void clearIgnoredArea(Graphics g, Rectangle area, int imageHeight) { + int x = (int) (area.x * ZOOM); + int y = imageHeight - (int) (area.y * ZOOM); // Convert PDF to AWT coordinates + int width = (int) (area.width * ZOOM); + int height = (int) (area.height * ZOOM); + g.clearRect(x, y, width, height); + } + + /** + * Retrieves the full path to the reference image file based on the test type. + * + * @return The complete file path to the reference image + */ + private String getReferencePath() { + String refImageFileName = isNegativeTest ? + ReferenceFile.getNegativeTestFileName() : + ReferenceFile.getFileNameForProfile(currentProfile); + Objects.requireNonNull(refImageFileName, "Reference image filename cannot be null"); + return buildPath(refFileDir, refImageFileName); + } + + /** + * Loads a reference image from the filesystem. + * + * @param refImageFilePath Path to the reference image + * @return The loaded BufferedImage + * @throws IOException if image loading fails + * @throws AssertionError if the image couldn't be loaded + */ + private BufferedImage loadReferenceImage(String refImageFilePath) throws IOException { + BufferedImage refImage = ImageIO.read(new File(refImageFilePath)); + assertNotNull(refImage, "Could not load reference image"); + return refImage; + } + + private String buildPath(String... parts) { + return String.join(UNIX_SEPARATOR, parts); + } + + /** + * Determines whether the image comparison should be skipped based on test configuration. + * + * @return true if comparison should be skipped, false otherwise + * @throws IOException if there's an error while capturing the reference image + * @see #captureReferenceImage(String, String) + */ + private boolean shouldSkipComparison() throws IOException { + if (captureRefImage && isNegativeTest) { + logger.debug("Skipping comparison: Negative test in capture mode"); + return true; + } + + if (captureRefImage) { + String refPath = buildPath(refFileDir, ReferenceFile.getFileNameForProfile(currentProfile)); + captureReferenceImage(refPath, pathOutputFile); + logger.debug("Captured reference image: {}", refFileDir); + return true; + } + + return false; + } + + /** + * Saves a BufferedImage to the specified file path in PNG format. + * Creates parent directories if they don't exist. + * + * @param image The BufferedImage to save + * @param filePath The path where the image should be saved + */ + private void saveImage(BufferedImage image, String filePath) throws IOException { + Objects.requireNonNull(image, "Image cannot be null"); + Objects.requireNonNull(filePath, "File path cannot be null"); + + File outputFile = new File(filePath); + if (!outputFile.getParentFile().mkdirs() && !outputFile.getParentFile().exists()) { + throw new IOException("Failed to create directory " + outputFile.getParentFile()); + } + + if (!ImageIO.write(image, PNG_FORMAT, outputFile)) { + throw new IOException("Failed to write image to file: " + filePath); + } + image.flush(); + } + + /** + * Saves the comparison results between the reference and signed images to the filesystem. + * Creates a directory based on the reference image name and saves three images: + * - Modified reference image with ignored areas + * - Modified signed image with ignored areas + * - Difference image highlighting pixel differences in red + * + * @param result The ImageComparisonResult containing the modified and difference images + * @param refImagePath The path to the reference image, used to determine the output directory name + */ + private void saveComparisonResults(ImageComparisonResult result, String refImagePath) throws IOException { + String resultDir = createResultDirectory(refImagePath); + + Map imagesToSave = Map.of( + REF_IMAGE_IGNORED, result.getModifiedReference(), + SIG_PAGE_IMAGE_IGNORED, result.getModifiedSigned(), + DIFFERENCE_IMAGE, result.getDifferenceImage() + ); + + for (Map.Entry entry : imagesToSave.entrySet()) { + String outputPath = buildPath(resultDir, entry.getKey()); + try { + saveImage(entry.getValue(), outputPath); + logger.debug("Saved comparison result image: {}", outputPath); + } catch (IOException e) { + logger.error("Failed to save comparison result image: {}", outputPath, e); + throw new IOException("Failed to save comparison result: " + outputPath, e); + } + } + } + + /** + * Creates a directory for storing comparison result files. + * If the directory doesn't exist, it will be created along with any necessary parent directories. + * + * @param refImagePath The path to the reference image file used for comparison + * @return The absolute path to the created directory as a String + */ + private String createResultDirectory(String refImagePath) throws IOException { + String dirName = extractResultDirectoryName(refImagePath); + Path path = Paths.get(dirName); + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)){ + Files.createDirectories(path); + } + return dirName; + } + + /** + * Extracts the result directory name from the reference image path. + * + * @param refImagePath The full path to the reference image file + * @return The constructed directory name as a String + */ + private String extractResultDirectoryName(String refImagePath) { + Objects.requireNonNull(refImagePath, "Reference image path cannot be null"); + + String fileName = Paths.get(refImagePath).getFileName().toString(); + String baseName = fileName.substring(7, fileName.lastIndexOf('.')); + + return buildPath(refFileDir, + isNegativeTest ? baseName + currentProfile : baseName + ); + } + + /** + * Captures and saves a reference image from a signed PDF document for signature comparison. + * + * @param refPath the file path where the reference image will be saved + * @param signedPath the path to the signed PDF file from which to capture the image + */ + private void captureReferenceImage(String refPath, String signedPath) throws IOException { + BufferedImage image = captureImage(signedPath); + saveImage(image, refPath); + } + + /** + * Captures and renders a specific page from a PDF document as a BufferedImage. + * The page is rendered using the configured zoom factor ({@link #ZOOM}). + * Note: Page numbers are 1-based, but PDFRenderer uses 0-based indexing internally. + * + * @param pdfPath path to the PDF file to be rendered + * @return the captured page as a BufferedImage + */ + private BufferedImage captureImage(String pdfPath) throws IOException { + Objects.requireNonNull(pdfPath, "PDF path cannot be null"); + + try (PDDocument pdf = PDDocument.load(new File(pdfPath))) { + return new PDFRenderer(pdf).renderImage(DEFAULT_PAGE - 1, ZOOM); + } + } + + @Override + public void close() throws Exception { + + } +} \ No newline at end of file diff --git a/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/SignatureUITest.java b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/SignatureUITest.java new file mode 100644 index 00000000..ddca838f --- /dev/null +++ b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/SignatureUITest.java @@ -0,0 +1,24 @@ +package at.asit.pdfover.gui.tests; + +import java.io.IOException; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import at.asit.pdfover.commons.Profile; + +public class SignatureUITest extends AbstractSignatureUITest { + + @ParameterizedTest + @EnumSource(Profile.class) + public void testSignatureAutoPosition() throws IOException { + setCredentials(); + testSignature(false, false); + } + + @ParameterizedTest + @EnumSource(Profile.class) + public void testSignatureAutoPositionNegative() throws IOException { + setCredentials(); + testSignature(true, false); + } +} \ No newline at end of file diff --git a/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/WidgetExistsCondition.java b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/WidgetExistsCondition.java new file mode 100644 index 00000000..9433d79d --- /dev/null +++ b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/WidgetExistsCondition.java @@ -0,0 +1,23 @@ +package at.asit.pdfover.gui.tests; + +import org.eclipse.swtbot.swt.finder.waits.DefaultCondition; + +public class WidgetExistsCondition extends DefaultCondition { + + private final String widgetName; + + public WidgetExistsCondition(String widget) { + this.widgetName = widget; + } + + @Override + public boolean test() { + return bot.textWithLabel(widgetName).isVisible(); + } + + @Override + public String getFailureMessage() { + return String.format("Could not find widget %s", widgetName); + } + +} \ No newline at end of file diff --git a/pdf-over-gui/src/test/resources/TestFile.pdf b/pdf-over-gui/src/test/resources/TestFile.pdf new file mode 100644 index 00000000..867f68db Binary files /dev/null and b/pdf-over-gui/src/test/resources/TestFile.pdf differ diff --git a/pdf-over-gui/src/test/resources/refFileAmtssignatur.png b/pdf-over-gui/src/test/resources/refFileAmtssignatur.png new file mode 100644 index 00000000..6550abaf Binary files /dev/null and b/pdf-over-gui/src/test/resources/refFileAmtssignatur.png differ diff --git a/pdf-over-gui/src/test/resources/refFileBaseLogo.png b/pdf-over-gui/src/test/resources/refFileBaseLogo.png new file mode 100644 index 00000000..dc113df6 Binary files /dev/null and b/pdf-over-gui/src/test/resources/refFileBaseLogo.png differ diff --git a/pdf-over-gui/src/test/resources/refFileInvisible.png b/pdf-over-gui/src/test/resources/refFileInvisible.png new file mode 100644 index 00000000..28be15d2 Binary files /dev/null and b/pdf-over-gui/src/test/resources/refFileInvisible.png differ diff --git a/pdf-over-gui/src/test/resources/refFileSignaturblockSmallNote.png b/pdf-over-gui/src/test/resources/refFileSignaturblockSmallNote.png new file mode 100644 index 00000000..533c7b77 Binary files /dev/null and b/pdf-over-gui/src/test/resources/refFileSignaturblockSmallNote.png differ diff --git a/pdf-over-gui/src/test/resources/refFileTestNegative.png b/pdf-over-gui/src/test/resources/refFileTestNegative.png new file mode 100644 index 00000000..d9c4f3cd Binary files /dev/null and b/pdf-over-gui/src/test/resources/refFileTestNegative.png differ diff --git a/pdf-over-signer/pom.xml b/pdf-over-signer/pom.xml index c05c27d9..6cff1eb1 100644 --- a/pdf-over-signer/pom.xml +++ b/pdf-over-signer/pom.xml @@ -57,7 +57,6 @@ org.apache.pdfbox pdfbox - 2.0.24 org.projectlombok diff --git a/pom.xml b/pom.xml index 0aab062d..35c0ccf6 100644 --- a/pom.xml +++ b/pom.xml @@ -113,6 +113,7 @@ 21 ${project.basedir} 2.0.34 + 5.13.4 @@ -259,7 +260,19 @@ org.junit.jupiter junit-jupiter-engine - 5.9.0 + ${jupiter.version} + test + + + org.junit.jupiter + junit-jupiter-params + ${jupiter.version} + test + + + org.junit.vintage + junit-vintage-engine + ${jupiter.version} test diff --git a/repo/org/eclipse/swtbot/swt/finder/4.1.0.202306071420/finder-4.1.0.202306071420.jar b/repo/org/eclipse/swtbot/swt/finder/4.1.0.202306071420/finder-4.1.0.202306071420.jar new file mode 100644 index 00000000..f1c5188c Binary files /dev/null and b/repo/org/eclipse/swtbot/swt/finder/4.1.0.202306071420/finder-4.1.0.202306071420.jar differ diff --git a/repo/org/eclipse/swtbot/swt/finder/4.3.0.202509040840/finder-4.3.0.202509040840.jar b/repo/org/eclipse/swtbot/swt/finder/4.3.0.202509040840/finder-4.3.0.202509040840.jar new file mode 100644 index 00000000..b531d2f8 Binary files /dev/null and b/repo/org/eclipse/swtbot/swt/finder/4.3.0.202509040840/finder-4.3.0.202509040840.jar differ