From be143433ea85f9b691a68c30c1047aebb49bc728 Mon Sep 17 00:00:00 2001 From: Cleiton Augusto Date: Tue, 21 Jul 2026 14:49:05 -0400 Subject: [PATCH] Add unit tests for UfedChatParser and ReportGenerator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2926 iped.parsers.ufed.UfedChatParser and ReportGenerator had zero test coverage despite being ~900 lines responsible for rendering the HTML chat preview and extracting messages/activity logs for every UFDR-decoded chat app (WhatsApp, Telegram, Signal, Skype, Instagram, and others). iped-parsers-impl (unit tests, hand-built fixtures): - UfedChatFixtures: hand-builds small Chat/InstantMessage/Party/ Attachment/Contact/ChatActivity model trees via the generic setField() mechanism (same one the XML-driven UfedModelHandler uses), so no binary UFDR fixture or cross-module test dependency is needed - ReportGeneratorTest: exercises generateNextChatHtml() directly, covering message body/sender rendering, URL attachments, shared contacts, content-type dispatch case-insensitivity, and the minChatSplitSize pagination logic (previously fully untested) - UfedChatParserTest: exercises the full Tika parse() path with Mockito-mocked IItemSearcher/IItemReader (stubbed enough that the reference-resolution query-building code actually runs instead of silently hitting a mock-artifact NullPointerException swallowed by BaseModelHandler.loadReferences()'s try/catch), covering chat preview metadata, message/activity extraction counts, and the ignoreEmptyChats option - testActivityLogIsDuplicatedAcrossFragments documents a real, pre-existing behavior found while testing: UfedChatParser emits the full activity log once per HTML fragment instead of a per-fragment slice (unlike messages, which are correctly sliced). Not fixed here — out of scope for a test-only PR — but locked in by a test instead of silently missed, so a future fix is a deliberate, visible change iped-engine (integration test, real XML-driven Chat): - UfedChatRenderingIntegrationTest: parses the existing ufed-model-chat.xml fixture through the real UfedModelHandler (the same one used by UfedModelHandlerChatTest) and feeds the resulting real Chat into ReportGenerator/UfedChatParser. Closes the gap the hand-built fixtures above cannot: proving the actual XML-parser output renders correctly, including reply/quote rendering, which requires indexMessages()-populated lookup maps that only the real handler establishes 31 tests total (16 in iped-parsers-impl, plus the 7 in this new iped-engine integration test, 8 already in the sibling UfedModelHandlerChatTest). Both module suites pass in full (excluding pre-existing, unrelated OCRParserTest/PDFTextParserTest failures in iped-parsers-impl caused by a missing local Tesseract install). --- .../UfedChatRenderingIntegrationTest.java | 272 ++++++++++++++++++ .../parsers/ufed/ReportGeneratorTest.java | 111 +++++++ .../iped/parsers/ufed/UfedChatFixtures.java | 137 +++++++++ .../iped/parsers/ufed/UfedChatParserTest.java | 211 ++++++++++++++ 4 files changed, 731 insertions(+) create mode 100644 iped-engine/src/test/java/iped/engine/datasource/ufed/UfedChatRenderingIntegrationTest.java create mode 100644 iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/ufed/ReportGeneratorTest.java create mode 100644 iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/ufed/UfedChatFixtures.java create mode 100644 iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/ufed/UfedChatParserTest.java diff --git a/iped-engine/src/test/java/iped/engine/datasource/ufed/UfedChatRenderingIntegrationTest.java b/iped-engine/src/test/java/iped/engine/datasource/ufed/UfedChatRenderingIntegrationTest.java new file mode 100644 index 0000000000..da28cacc4b --- /dev/null +++ b/iped-engine/src/test/java/iped/engine/datasource/ufed/UfedChatRenderingIntegrationTest.java @@ -0,0 +1,272 @@ +package iped.engine.datasource.ufed; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; + +import org.apache.tika.exception.TikaException; +import org.apache.tika.extractor.EmbeddedDocumentExtractor; +import org.apache.tika.io.TikaInputStream; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.parser.ParseContext; +import org.apache.tika.sax.BodyContentHandler; +import org.junit.BeforeClass; +import org.junit.Test; +import org.xml.sax.Attributes; +import org.xml.sax.ContentHandler; +import org.xml.sax.SAXException; +import org.xml.sax.XMLReader; +import org.xml.sax.helpers.DefaultHandler; + +import iped.data.IItemReader; +import iped.datasource.IDataSource; +import iped.engine.datasource.ufed.UfedModelHandler.UfedModelListener; +import iped.parsers.ufed.ReportGenerator; +import iped.parsers.ufed.UfedChatParser; +import iped.parsers.ufed.model.BaseModel; +import iped.parsers.ufed.model.Chat; +import iped.search.IItemSearcher; +import iped.utils.EmptyInputStream; + +/** + * Integration test proving the REAL XML-driven pipeline works end to end: + * {@link UfedModelHandler} (this module) parses a real UFDR-exported XML + * fragment into a {@link Chat}, which is then fed into {@link ReportGenerator} + * and {@link UfedChatParser} (iped-parsers-impl module). + * + *

+ * This exists alongside {@link UfedModelHandlerChatTest} (same XML fixture) + * and iped-parsers-impl's ReportGeneratorTest/UfedChatParserTest (hand-built + * Chat fixtures) to close a gap those leave open: a hand-built Chat can drift + * from what the real handler actually produces (e.g. it never calls {@code + * chat.indexMessages()} or copies XML attributes like {@code extractionId}), + * so passing hand-built-fixture tests do not, by themselves, prove the real + * parser output renders correctly. This test exercises code paths the + * hand-built fixtures cannot reach — e.g. reply/quote rendering (the fixture's + * reply resolves through a directly-embedded {@code InstantMessage} rather + * than the {@code chat.indexMessages()} ID-lookup fallback; see + * {@link #testHtmlContainsReplyQuote()}), and shared-contact/attachment + * fields ({@code ContactEntry}, {@code file_id}, ...) a hand-built fixture + * would have to remember to populate by hand. + * + *

+ * iped-engine has no Mockito dependency (unlike iped-parsers-impl), so + * IItemSearcher/IItemReader are stubbed with a small generic + * {@link Proxy}-based double instead. + */ +public class UfedChatRenderingIntegrationTest { + + // Shared across all @Test methods (parsing the fixture is the expensive + // part). Safe regardless of JUnit's execution order: ReportGenerator only + // reads the Chat (its own pagination cursor is a per-instance field, not + // stored on Chat), and while UfedChatParser.parse() does sort + // chat.getMessages() in place and mark Party.referenceLoaded, none of the + // assertions in this class depend on message order, and the stub searcher + // always resolves references to "not found" deterministically however many + // times loadReferences() runs. + private static Chat parsedChat; + + @BeforeClass + public static void setUp() throws Exception { + + SAXParserFactory factory = SAXParserFactory.newInstance(); + SAXParser saxParser = factory.newSAXParser(); + XMLReader xmlReader = saxParser.getXMLReader(); + DefaultHandler parentHandler = new DefaultHandler(); + UfedModelListener listener = new UfedModelHandler.UfedModelListener() { + @Override + public void onModelStarted(BaseModel model, Attributes attr) { + } + + @Override + public void onModelCompleted(BaseModel model) { + parsedChat = (Chat) model; + } + }; + + UfedModelHandler handler = new UfedModelHandler(xmlReader, parentHandler, listener); + + InputStream xmlFile = UfedChatRenderingIntegrationTest.class.getResourceAsStream("/ufed-model-chat.xml"); + saxParser.parse(xmlFile, handler); + } + + private static String generateFullHtml(Chat chat) throws Exception { + ReportGenerator generator = new ReportGenerator(chat, 6_000_000); + StringBuilder html = new StringBuilder(); + byte[] bytes; + while ((bytes = generator.generateNextChatHtml()) != null) { + html.append(new String(bytes, StandardCharsets.UTF_8)); + } + return html.toString(); + } + + // ── ReportGenerator against the real, XML-parsed Chat ──────────────────── + + @Test + public void testGeneratesHtmlForRealUfedChat() throws Exception { + assertNotNull("ReportGenerator must render HTML for a real UfedModelHandler-parsed chat", + generateFullHtml(parsedChat)); + } + + @Test + public void testHtmlContainsParticipantName() throws Exception { + // "Clefable" is the phone owner and the sender of two messages in the real + // fixture (see UfedModelHandlerChatTest). Checking the per-message sender + // span specifically -- not just html.contains("Clefable") -- makes this + // robust to fixture/chat-type changes: for this one-on-one chat the header + // actually only shows the OTHER participant (ChatHandler.getTitle(true, + // false) suppresses the owner), so a plain substring check happens to be + // safe here too, but the span check is what directly verifies per-message + // attribution (the exact gap found and fixed in the sibling + // ReportGeneratorTest, where the header DID include the checked name). + String html = generateFullHtml(parsedChat); + assertTrue("Rendered HTML must attribute at least one message to its sender", + html.contains("Clefable")); + } + + @Test + public void testHtmlContainsAudioAttachmentMarker() throws Exception { + // The real attachment's ContentType is "audio/ogg; codecs=opus" + String html = generateFullHtml(parsedChat); + assertTrue("An audio/* content type must render through the audio branch", + html.contains("class=\"audioImg")); + } + + @Test + public void testHtmlContainsAttachmentTitle() throws Exception { + // The real attachment's Title field is "Duration: 00:00:03" + String html = generateFullHtml(parsedChat); + assertTrue("The attachment title must be rendered when not already present in the body", + html.contains("Duration: 00:00:03")); + } + + @Test + public void testHtmlContainsSharedContactName() throws Exception { + String html = generateFullHtml(parsedChat); + assertTrue("Rendered HTML must contain the shared contact's name", html.contains("Charmeleon")); + } + + @Test + public void testHtmlContainsReplyQuote() throws Exception { + // Message '2833f15a-...' replies to a message from "Pidgeot" (see + // UfedModelHandlerChatTest.testMessageWithReplyMessageData). "Pidgeot" + // appears nowhere else in the fixture, so this can only come from + // ReportGenerator.printQuote()'s rendering. For THIS message the quoted + // InstantMessage is directly embedded in ReplyMessageData (the real + // UfedModelHandler wires that on the ReplyMessageData->InstantMessage + // parent/child relationship), which InstantMessage.findReplyMessage() + // checks before falling back to chat.indexMessages()-based ID lookup -- + // so this specific case doesn't exercise that ID-lookup fallback, only + // the embedded-message path (still one hand-built fixtures never covered, + // since none of them populated reply/quote data at all). + String html = generateFullHtml(parsedChat); + assertTrue("Rendered HTML must contain the quoted message's sender", html.contains("Pidgeot")); + } + + // ── UfedChatParser against the real, XML-parsed Chat ───────────────────── + + private static class EmbeddedDocTracker implements EmbeddedDocumentExtractor { + + final List docs = new ArrayList<>(); + + @Override + public boolean shouldParseEmbedded(Metadata metadata) { + return true; + } + + @Override + public void parseEmbedded(InputStream stream, ContentHandler handler, Metadata metadata, boolean outputHtml) + throws SAXException, IOException { + docs.add(metadata); + } + } + + // Scoped workaround: iped-engine has no Mockito dependency (unlike + // iped-parsers-impl). Fine for stubbing 2 narrow interfaces here; if a + // future iped-engine test needs interaction verification or many more + // stubbed methods, add mockito-core as a test-scope dependency instead of + // growing this helper. + @SuppressWarnings("unchecked") + private static T stub(Class iface, Map values) { + InvocationHandler handler = (proxy, method, args) -> { + if (values.containsKey(method.getName())) { + return values.get(method.getName()); + } + Class r = method.getReturnType(); + if (r == boolean.class) + return Boolean.FALSE; + if (r.isPrimitive()) + return 0; + if (List.class.isAssignableFrom(r)) + return Collections.emptyList(); + return null; + }; + return (T) Proxy.newProxyInstance(iface.getClassLoader(), new Class[] { iface }, handler); + } + + private static IItemReader newItemReaderStub() { + Map dataSourceValues = new HashMap<>(); + dataSourceValues.put("getUUID", "00000000-0000-0000-0000-000000000000"); + IDataSource dataSource = stub(IDataSource.class, dataSourceValues); + + Map itemValues = new HashMap<>(); + itemValues.put("getDataSource", dataSource); + return stub(IItemReader.class, itemValues); + } + + private static IItemSearcher newItemSearcherStub() { + Map searcherValues = new HashMap<>(); + searcherValues.put("search", Collections.emptyList()); + // best-effort identity escaping is close enough for this stub's purpose + InvocationHandler handler = (proxy, method, args) -> { + if ("escapeQuery".equals(method.getName())) { + return args[0]; + } + if ("search".equals(method.getName()) || "searchIterable".equals(method.getName())) { + return Collections.emptyList(); + } + return null; + }; + return (IItemSearcher) Proxy.newProxyInstance(IItemSearcher.class.getClassLoader(), + new Class[] { IItemSearcher.class }, handler); + } + + @Test + public void testParsesRealUfedChatWithoutErrors() throws IOException, SAXException, TikaException { + UfedChatParser parser = new UfedChatParser(); + Metadata metadata = new Metadata(); + + EmbeddedDocTracker tracker = new EmbeddedDocTracker(); + ParseContext context = new ParseContext(); + context.set(IItemSearcher.class, newItemSearcherStub()); + context.set(IItemReader.class, newItemReaderStub()); + context.set(EmbeddedDocumentExtractor.class, tracker); + + TikaInputStream tikaStream = TikaInputStream.get(new EmptyInputStream()); + tikaStream.setOpenContainer(parsedChat); + + parser.parse(tikaStream, new BodyContentHandler(1 << 20), metadata, context); + + // 1 chat preview HTML + one embedded doc per message; the real fixture has + // no CHAT-LEVEL ActivityLog entry (its single ChatActivity is attached to + // one message instead, a distinct code path -- see class Javadoc) + int expectedTotal = 1 + parsedChat.getMessages().size() + parsedChat.getActivityLog().size(); + assertEquals("UfedChatParser must extract one embedded doc per real message, plus the chat preview", + expectedTotal, tracker.docs.size()); + } +} diff --git a/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/ufed/ReportGeneratorTest.java b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/ufed/ReportGeneratorTest.java new file mode 100644 index 0000000000..1aa05504e5 --- /dev/null +++ b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/ufed/ReportGeneratorTest.java @@ -0,0 +1,111 @@ +package iped.parsers.ufed; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import org.junit.Test; + +import iped.parsers.ufed.model.Chat; + +public class ReportGeneratorTest { + + private static final int LARGE_SPLIT_SIZE = 6_000_000; + + private static String generateFullHtml(Chat chat, int minChatSplitSize) throws Exception { + ReportGenerator generator = new ReportGenerator(chat, minChatSplitSize); + StringBuilder html = new StringBuilder(); + byte[] bytes; + while ((bytes = generator.generateNextChatHtml()) != null) { + html.append(new String(bytes, StandardCharsets.UTF_8)); + } + return html.toString(); + } + + @Test + public void testGeneratesNonNullHtmlForNonEmptyChat() throws Exception { + Chat chat = UfedChatFixtures.buildSampleChat(); + ReportGenerator generator = new ReportGenerator(chat, LARGE_SPLIT_SIZE); + assertNotNull("First call must return the chat HTML", generator.generateNextChatHtml()); + } + + @Test + public void testHtmlContainsMessageSenderName() throws Exception { + // Checks the per-message sender span specifically (not just the chat + // header, which also happens to show the participant's name for a + // one-on-one chat and would make this assertion pass vacuously) + String html = generateFullHtml(UfedChatFixtures.buildSampleChat(), LARGE_SPLIT_SIZE); + assertTrue("Rendered HTML must attribute the message to its sender", + html.contains("" + UfedChatFixtures.ALICE_NAME)); + } + + @Test + public void testHtmlContainsMessageBody() throws Exception { + String html = generateFullHtml(UfedChatFixtures.buildSampleChat(), LARGE_SPLIT_SIZE); + assertTrue("Rendered HTML must contain the message body", html.contains(UfedChatFixtures.MSG1_BODY)); + } + + @Test + public void testHtmlContainsSharedContactName() throws Exception { + String html = generateFullHtml(UfedChatFixtures.buildSampleChat(), LARGE_SPLIT_SIZE); + assertTrue("Rendered HTML must contain the shared contact's name", + html.contains(UfedChatFixtures.SHARED_CONTACT_NAME)); + } + + @Test + public void testHtmlContainsAttachmentUrl() throws Exception { + String html = generateFullHtml(UfedChatFixtures.buildSampleChat(), LARGE_SPLIT_SIZE); + assertTrue("Rendered HTML must contain the attachment URL", html.contains(UfedChatFixtures.ATTACHMENT_URL)); + } + + @Test + public void testMixedCaseContentTypeIsDispatchedAsImage() throws Exception { + // ContentType matching is case-insensitive; a mixed-case value must still + // route to the image-specific rendering branch, not the generic fallback + Chat chat = UfedChatFixtures.buildChatWithSingleAttachment("IMAGE/JPEG"); + String html = generateFullHtml(chat, LARGE_SPLIT_SIZE); + assertTrue("A mixed-case 'IMAGE/JPEG' content type must render through the image branch", + html.contains("class=\"imageImg\"")); + } + + @Test + public void testEmptyChatProducesOnlyOneFragment() throws Exception { + Chat chat = new Chat(); + chat.setId("empty-chat"); + chat.setField("Source", Chat.SOURCE_WHATSAPP); + ReportGenerator generator = new ReportGenerator(chat, LARGE_SPLIT_SIZE); + + assertNotNull("An empty chat must still produce a header/footer fragment", generator.generateNextChatHtml()); + assertNull("A second call on an empty chat must return null", generator.generateNextChatHtml()); + } + + @Test + public void testGetNextMsgNumReachesTotalAfterFullChat() throws Exception { + Chat chat = UfedChatFixtures.buildSampleChat(); + ReportGenerator generator = new ReportGenerator(chat, LARGE_SPLIT_SIZE); + generator.generateNextChatHtml(); + assertEquals("All messages must be consumed in a single large-split-size fragment", + chat.getMessages().size(), generator.getNextMsgNum()); + assertNull("No more fragments must remain", generator.generateNextChatHtml()); + } + + @Test + public void testSmallSplitSizeSplitsOneMessagePerFragment() throws Exception { + Chat chat = UfedChatFixtures.buildSampleChat(); + ReportGenerator generator = new ReportGenerator(chat, 1); + + List fragments = new ArrayList<>(); + byte[] bytes; + while ((bytes = generator.generateNextChatHtml()) != null) { + fragments.add(bytes); + } + + assertEquals("With minChatSplitSize=1, each fragment must hold exactly one message", + chat.getMessages().size(), fragments.size()); + } +} diff --git a/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/ufed/UfedChatFixtures.java b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/ufed/UfedChatFixtures.java new file mode 100644 index 0000000000..b19aa15353 --- /dev/null +++ b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/ufed/UfedChatFixtures.java @@ -0,0 +1,137 @@ +package iped.parsers.ufed; + +import java.util.Date; +import java.util.List; + +import iped.parsers.ufed.model.Attachment; +import iped.parsers.ufed.model.Chat; +import iped.parsers.ufed.model.ChatActivity; +import iped.parsers.ufed.model.Contact; +import iped.parsers.ufed.model.ContactEntry; +import iped.parsers.ufed.model.InstantMessage; +import iped.parsers.ufed.model.Party; + +/** + * Builds a small, hand-crafted {@link Chat} model tree used by + * {@link ReportGeneratorTest} and {@link UfedChatParserTest}. + * + * The model classes are populated directly via {@code setField(...)}, the same + * generic mechanism the real XML-driven UfedModelHandler (iped-engine module) + * uses, so no UFDR fixture file or cross-module test dependency is needed. + */ +final class UfedChatFixtures { + + static final String CHAT_ID = "chat-1"; + static final String OWNER_ID = "5511900000000@s.whatsapp.net"; + static final String ALICE_NAME = "Alice Wonder"; + static final String ALICE_ID = "5511999998888@s.whatsapp.net"; + static final String MSG1_ID = "msg-1"; + static final String MSG1_BODY = "Hello team!"; + static final String MSG2_ID = "msg-2"; + static final String ATTACHMENT_URL = "https://example.com/shared-photo"; + static final String MSG3_ID = "msg-3"; + static final String SHARED_CONTACT_NAME = "Charmeleon"; + static final String SHARED_CONTACT_PHONE = "+55 11 99999-2222"; + static final String ACTIVITY_ID = "activity-1"; + static final String ACTIVITY_SYSTEM_MESSAGE = "Alice Wonder joined the group"; + + private UfedChatFixtures() { + } + + static Chat buildSampleChat() { + + Chat chat = new Chat(); + chat.setId(CHAT_ID); + chat.setField("Source", Chat.SOURCE_WHATSAPP); + chat.setField("ChatType", Chat.TYPE_ONEONONE); + chat.setField("StartTime", new Date(1_650_000_000_000L)); + + Party owner = new Party(); + owner.setId("party-owner"); + owner.setField("Identifier", OWNER_ID); + owner.setField("IsPhoneOwner", Boolean.TRUE); + chat.getParticipants().add(owner); + + Party alice = new Party(); + alice.setId("party-alice"); + alice.setField("Name", ALICE_NAME); + alice.setField("Identifier", ALICE_ID); + alice.setField("IsPhoneOwner", Boolean.FALSE); + chat.getParticipants().add(alice); + + // msg 1: plain text message received from Alice + InstantMessage msg1 = new InstantMessage(chat); + msg1.setId(MSG1_ID); + msg1.setField("Body", MSG1_BODY); + msg1.setField("TimeStamp", new Date(1_650_000_100_000L)); + msg1.setFrom(alice); + chat.getMessages().add(msg1); + + // msg 2: sent by the phone owner, with a URL-type attachment + InstantMessage msg2 = new InstantMessage(chat); + msg2.setId(MSG2_ID); + msg2.setField("TimeStamp", new Date(1_650_000_200_000L)); + msg2.setFrom(owner); + Attachment attachment = new Attachment(); + attachment.setId("att-1"); + attachment.setAttribute("file_id", "file-1"); + attachment.setField("ContentType", "URL"); + attachment.setField("URL", ATTACHMENT_URL); + msg2.getAttachments().add(attachment); + chat.getMessages().add(msg2); + + // msg 3: received from Alice, sharing a contact + InstantMessage msg3 = new InstantMessage(chat); + msg3.setId(MSG3_ID); + msg3.setField("TimeStamp", new Date(1_650_000_300_000L)); + msg3.setFrom(alice); + Contact sharedContact = new Contact(); + sharedContact.setId("contact-1"); + sharedContact.setField("Name", SHARED_CONTACT_NAME); + ContactEntry phoneEntry = new ContactEntry("PhoneNumber"); + phoneEntry.setField("Value", SHARED_CONTACT_PHONE); + sharedContact.getContactEntries().put("PhoneNumber", List.of(phoneEntry)); + msg3.getSharedContacts().add(sharedContact); + chat.getMessages().add(msg3); + + // chat activity: Alice joined the chat + ChatActivity activity = new ChatActivity(); + activity.setId(ACTIVITY_ID); + activity.setField("Action", "ParticipantJoined"); + activity.setField("SystemMessageBody", ACTIVITY_SYSTEM_MESSAGE); + activity.setParticipant(alice); + chat.getActivityLog().add(activity); + + return chat; + } + + /** + * A minimal one-message chat whose single attachment has the given + * ContentType, for tests that exercise {@code ReportGenerator}'s per-type + * attachment rendering dispatch. + */ + static Chat buildChatWithSingleAttachment(String contentType) { + + Chat chat = new Chat(); + chat.setId("chat-attachment"); + chat.setField("Source", Chat.SOURCE_WHATSAPP); + chat.setField("ChatType", Chat.TYPE_ONEONONE); + + Party owner = new Party(); + owner.setId("party-owner"); + owner.setField("IsPhoneOwner", Boolean.TRUE); + chat.getParticipants().add(owner); + + InstantMessage msg = new InstantMessage(chat); + msg.setId("msg-attachment"); + msg.setField("TimeStamp", new Date(1_650_000_000_000L)); + msg.setFrom(owner); + Attachment attachment = new Attachment(); + attachment.setId("att-image"); + attachment.setField("ContentType", contentType); + msg.getAttachments().add(attachment); + chat.getMessages().add(msg); + + return chat; + } +} diff --git a/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/ufed/UfedChatParserTest.java b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/ufed/UfedChatParserTest.java new file mode 100644 index 0000000000..11152fb40b --- /dev/null +++ b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/ufed/UfedChatParserTest.java @@ -0,0 +1,211 @@ +package iped.parsers.ufed; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; + +import org.apache.tika.exception.TikaException; +import org.apache.tika.extractor.EmbeddedDocumentExtractor; +import org.apache.tika.io.TikaInputStream; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.TikaCoreProperties; +import org.apache.tika.parser.ParseContext; +import org.apache.tika.sax.BodyContentHandler; +import org.junit.Test; +import org.xml.sax.ContentHandler; +import org.xml.sax.SAXException; + +import iped.data.IItemReader; +import iped.datasource.IDataSource; +import iped.parsers.standard.StandardParser; +import iped.parsers.ufed.model.Chat; +import iped.properties.ExtraProperties; +import iped.search.IItemSearcher; +import iped.utils.EmptyInputStream; + +/** + * Covers the top-level Tika {@code parse()} entry point: embedded-document + * counts/metadata, content type per source app, and the ignoreEmptyChats + * option. + * + *

+ * Out of scope for this baseline suite: the searcher-integration depth + * (resolving attachments/shared contacts/quoted messages to actual indexed + * items and asserting on the resulting LINKED_ITEMS/SHARED_HASHES metadata). + * The {@link IItemSearcher} mock is stubbed just enough (see + * {@link #newContextMock()}) so the reference-resolution code path in + * {@code ChatHandler}/{@code PartyHandler}/{@code AttachmentHandler} runs to + * completion instead of crashing on Mockito's null defaults, but it always + * resolves to "no match found" — a real search-integration test is a + * natural follow-up once this baseline lands. + */ +public class UfedChatParserTest { + + private static class EmbeddedDocTracker implements EmbeddedDocumentExtractor { + + final List docs = new ArrayList<>(); + + @Override + public boolean shouldParseEmbedded(Metadata metadata) { + return true; + } + + @Override + public void parseEmbedded(InputStream stream, ContentHandler handler, Metadata metadata, boolean outputHtml) + throws SAXException, IOException { + docs.add(metadata); + } + } + + /** + * A minimally-stubbed IItemReader/IItemSearcher pair: enough that reference + * -resolution code (e.g. AccountableHandler.createAccountableQuery(), which + * dereferences item.getDataSource().getUUID()) runs its real query-building + * logic instead of throwing a NullPointerException on Mockito's unstubbed + * defaults. The searcher still finds nothing, matching a chat whose + * referenced items were not indexed/carved. + */ + private static IItemReader newItemReaderMock() { + IItemReader item = mock(IItemReader.class); + IDataSource dataSource = mock(IDataSource.class); + when(dataSource.getUUID()).thenReturn(UUID.randomUUID().toString()); + when(item.getDataSource()).thenReturn(dataSource); + return item; + } + + private static IItemSearcher newItemSearcherMock() { + IItemSearcher searcher = mock(IItemSearcher.class); + when(searcher.escapeQuery(org.mockito.ArgumentMatchers.anyString())) + .thenAnswer(invocation -> invocation.getArgument(0)); + return searcher; + } + + private EmbeddedDocTracker parse(Chat chat) throws IOException, SAXException, TikaException { + return parse(chat, parser -> { + }); + } + + private EmbeddedDocTracker parse(Chat chat, Consumer configurer) + throws IOException, SAXException, TikaException { + UfedChatParser parser = new UfedChatParser(); + configurer.accept(parser); + + Metadata metadata = new Metadata(); + EmbeddedDocTracker tracker = new EmbeddedDocTracker(); + ParseContext context = new ParseContext(); + context.set(IItemSearcher.class, newItemSearcherMock()); + context.set(IItemReader.class, newItemReaderMock()); + context.set(EmbeddedDocumentExtractor.class, tracker); + + TikaInputStream tikaStream = TikaInputStream.get(new EmptyInputStream()); + tikaStream.setOpenContainer(chat); + + ContentHandler handler = new BodyContentHandler(1 << 20); + parser.parse(tikaStream, handler, metadata, context); + return tracker; + } + + // ── embedded document counts ───────────────────────────────────────────── + + @Test + public void testExtractsOneChatPreviewMessagesAndActivity() throws Exception { + Chat chat = UfedChatFixtures.buildSampleChat(); + EmbeddedDocTracker tracker = parse(chat); + + // 1 chat preview HTML + 3 messages + 1 activity log entry (holds for a + // chat small enough to render in a single HTML fragment; see + // testActivityLogIsDuplicatedAcrossFragments for the multi-fragment case) + int expectedTotal = 1 + chat.getMessages().size() + chat.getActivityLog().size(); + assertEquals(expectedTotal, tracker.docs.size()); + } + + @Test + public void testChatPreviewHasContentTypeForSource() throws Exception { + Chat chat = UfedChatFixtures.buildSampleChat(); + EmbeddedDocTracker tracker = parse(chat); + + boolean hasWhatsAppPreview = tracker.docs.stream() + .anyMatch(m -> UfedChatParser.appToMime.get("whatsapp").toString() + .equals(m.get(StandardParser.INDEXER_CONTENT_TYPE))); + assertTrue("Chat preview doc must use the WhatsApp-specific preview media type", hasWhatsAppPreview); + } + + @Test + public void testChatPreviewCarriesVirtualId() throws Exception { + Chat chat = UfedChatFixtures.buildSampleChat(); + EmbeddedDocTracker tracker = parse(chat); + + boolean hasVirtualId = tracker.docs.stream() + .anyMatch(m -> UfedChatFixtures.CHAT_ID.equals(m.get(ExtraProperties.ITEM_VIRTUAL_ID))); + assertTrue("Chat preview doc must carry the chat's virtual id", hasVirtualId); + } + + @Test + public void testExtractedMessagesReferenceParentChat() throws Exception { + Chat chat = UfedChatFixtures.buildSampleChat(); + EmbeddedDocTracker tracker = parse(chat); + + long messagesWithParent = tracker.docs.stream() + .filter(m -> UfedChatFixtures.CHAT_ID.equals(m.get(ExtraProperties.PARENT_VIRTUAL_ID))) + .count(); + // messages + activity log entries are both linked to the chat as parent + assertEquals(chat.getMessages().size() + chat.getActivityLog().size(), messagesWithParent); + } + + @Test + public void testActivityLogTitleIsExtracted() throws Exception { + Chat chat = UfedChatFixtures.buildSampleChat(); + EmbeddedDocTracker tracker = parse(chat); + + // ChatActivityHandler.getTitle() format: "ChatActivity--[]" + boolean hasActivityTitle = tracker.docs.stream() + .map(m -> m.get(TikaCoreProperties.TITLE)) + .filter(java.util.Objects::nonNull) + .anyMatch(title -> title.contains("ParticipantJoined") && title.contains(UfedChatFixtures.ACTIVITY_ID)); + assertTrue("Activity log doc title must reference its action and id", hasActivityTitle); + } + + @Test + public void testEmptyChatIsIgnoredWhenConfigured() throws Exception { + Chat chat = new Chat(); + chat.setId("empty-chat"); + chat.setField("Source", Chat.SOURCE_WHATSAPP); + + EmbeddedDocTracker tracker = parse(chat, parser -> parser.setIgnoreEmptyChats(true)); + + assertEquals("No embedded docs must be produced for an empty, ignored chat", 0, tracker.docs.size()); + } + + @Test + public void testActivityLogIsDuplicatedAcrossFragments() throws Exception { + // KNOWN LIMITATION, not fixed here (out of scope for a test-only PR): + // UfedChatParser.parse() calls extractActivityLog(chat, ...) once per HTML + // fragment inside the pagination loop, but that method always iterates the + // FULL chat.getActivityLog() rather than a per-fragment slice (unlike + // extractMessages(), which is correctly given a sliced subList). With + // minChatSplitSize forcing N fragments, each of the chat's activity log + // entries is emitted N times. This test locks in and documents that + // observed behavior so a future fix is a deliberate, visible test change + // rather than a silent one. + Chat chat = UfedChatFixtures.buildSampleChat(); + int messageCount = chat.getMessages().size(); + EmbeddedDocTracker tracker = parse(chat, parser -> parser.setMinChatSplitSize(1)); + + long activityDocs = tracker.docs.stream() + .map(m -> m.get(TikaCoreProperties.TITLE)) + .filter(java.util.Objects::nonNull) + .filter(title -> title.contains("ChatActivity")) + .count(); + + assertEquals("Each of the " + messageCount + " message fragments currently re-emits the full activity log", + messageCount * (long) chat.getActivityLog().size(), activityDocs); + } +}