diff --git a/UnitTest/UnitTest.vcxproj b/UnitTest/UnitTest.vcxproj index e0db92731..db3b2fc5b 100644 --- a/UnitTest/UnitTest.vcxproj +++ b/UnitTest/UnitTest.vcxproj @@ -795,6 +795,7 @@ + diff --git a/UnitTest/UnitTest.vcxproj.filters b/UnitTest/UnitTest.vcxproj.filters index 5a2b4b128..a81f9d748 100644 --- a/UnitTest/UnitTest.vcxproj.filters +++ b/UnitTest/UnitTest.vcxproj.filters @@ -32,6 +32,9 @@ Source Files + + Source Files + Source Files diff --git a/UnitTest/tests/mapioutputtest.cpp b/UnitTest/tests/mapioutputtest.cpp new file mode 100644 index 000000000..31dbc13b3 --- /dev/null +++ b/UnitTest/tests/mapioutputtest.cpp @@ -0,0 +1,517 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Resolve only the exporter's memory APIs through this DLL, not an installed MAPI provider. +#ifdef _M_IX86 +#pragma comment(linker, "/export:MAPIAllocateBuffer@8=_TestMAPIAllocateBuffer@8") +#pragma comment(linker, "/export:MAPIFreeBuffer@4=_TestMAPIFreeBuffer@4") +#pragma comment(linker, "/export:FreeProws@4=_TestFreeProws@4") +#else +#pragma comment(linker, "/export:MAPIAllocateBuffer=TestMAPIAllocateBuffer") +#pragma comment(linker, "/export:MAPIFreeBuffer=TestMAPIFreeBuffer") +#pragma comment(linker, "/export:FreeProws=TestFreeProws") +#endif + +namespace +{ + union Buffer { + Buffer* next; + std::max_align_t alignment; + }; + + Buffer* buffers{}; + ULONG invalidFrees{}; + ULONG freedRowSets{}; + std::vector freedPropertyTags; +} // namespace + +extern "C" SCODE STDAPICALLTYPE TestMAPIAllocateBuffer(ULONG size, LPVOID* result) +{ + if (!result) return MAPI_E_INVALID_PARAMETER; + *result = nullptr; + if (static_cast(size) > (std::numeric_limits::max)() - sizeof(Buffer)) + return MAPI_E_NOT_ENOUGH_MEMORY; + const auto buffer = static_cast(HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(Buffer) + size)); + if (!buffer) return MAPI_E_NOT_ENOUGH_MEMORY; + buffer->next = buffers; + buffers = buffer; + *result = buffer + 1; + return S_OK; +} + +extern "C" ULONG STDAPICALLTYPE TestMAPIFreeBuffer(LPVOID buffer) +{ + if (!buffer) return 0; + auto link = &buffers; + while (*link && *link + 1 != buffer) + link = &(*link)->next; + if (!*link) + { + ++invalidFrees; + return static_cast(MAPI_E_INVALID_PARAMETER); + } + + const auto allocation = *link; + *link = allocation->next; + HeapFree(GetProcessHeap(), 0, allocation); + return 0; +} + +extern "C" void STDAPICALLTYPE TestFreeProws(LPSRowSet rows) +{ + if (!rows) return; + ++freedRowSets; + for (ULONG row = 0; row < rows->cRows; ++row) + { + for (ULONG prop = 0; prop < rows->aRow[row].cValues; ++prop) + freedPropertyTags.push_back(rows->aRow[row].lpProps[prop].ulPropTag); + TestMAPIFreeBuffer(rows->aRow[row].lpProps); + } + + TestMAPIFreeBuffer(rows); +} + +namespace mapioutputtest +{ + constexpr ULONG numberTag = 0x60010003; + constexpr ULONG stringTag = 0x6002001F; + + class TestTable final : public IMAPITable + { + public: + TestTable(ULONG rowCount, ULONG batchSize, bool withProperties = false) + : m_rowCount(rowCount), m_batchSize(batchSize), m_withProperties(withProperties) + { + } + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID, void** object) override + { + *object = nullptr; + return E_NOINTERFACE; + } + ULONG STDMETHODCALLTYPE AddRef() override { return ++m_refs; } + ULONG STDMETHODCALLTYPE Release() override { return --m_refs; } + HRESULT STDMETHODCALLTYPE GetLastError(HRESULT, ULONG, LPMAPIERROR*) override { return MAPI_E_NO_SUPPORT; } + HRESULT STDMETHODCALLTYPE Advise(ULONG, LPMAPIADVISESINK, ULONG_PTR*) override { return MAPI_E_NO_SUPPORT; } + HRESULT STDMETHODCALLTYPE Unadvise(ULONG_PTR) override { return MAPI_E_NO_SUPPORT; } + HRESULT STDMETHODCALLTYPE GetStatus(ULONG*, ULONG*) override { return MAPI_E_NO_SUPPORT; } + HRESULT STDMETHODCALLTYPE SetColumns(LPSPropTagArray, ULONG) override { return MAPI_E_NO_SUPPORT; } + HRESULT STDMETHODCALLTYPE QueryColumns(ULONG, LPSPropTagArray*) override { return MAPI_E_NO_SUPPORT; } + HRESULT STDMETHODCALLTYPE GetRowCount(ULONG, ULONG*) override { return MAPI_E_NO_SUPPORT; } + HRESULT STDMETHODCALLTYPE SeekRow(BOOKMARK origin, LONG count, LONG* sought) override + { + Assert::AreEqual(static_cast(BOOKMARK_BEGINNING), origin); + Assert::AreEqual(0L, count); + ++seekCount; + if (FAILED(seekResult)) return seekResult; + position = 0; + if (sought) *sought = 0; + return S_OK; + } + HRESULT STDMETHODCALLTYPE SeekRowApprox(ULONG, ULONG) override { return MAPI_E_NO_SUPPORT; } + HRESULT STDMETHODCALLTYPE QueryPosition(ULONG*, ULONG*, ULONG*) override { return MAPI_E_NO_SUPPORT; } + HRESULT STDMETHODCALLTYPE FindRow(LPSRestriction, BOOKMARK, ULONG) override { return MAPI_E_NO_SUPPORT; } + HRESULT STDMETHODCALLTYPE Restrict(LPSRestriction, ULONG) override { return MAPI_E_NO_SUPPORT; } + HRESULT STDMETHODCALLTYPE CreateBookmark(BOOKMARK*) override { return MAPI_E_NO_SUPPORT; } + HRESULT STDMETHODCALLTYPE FreeBookmark(BOOKMARK) override { return MAPI_E_NO_SUPPORT; } + HRESULT STDMETHODCALLTYPE SortTable(LPSSortOrderSet, ULONG) override { return MAPI_E_NO_SUPPORT; } + HRESULT STDMETHODCALLTYPE QuerySortOrder(LPSSortOrderSet*) override { return MAPI_E_NO_SUPPORT; } + HRESULT STDMETHODCALLTYPE QueryRows(LONG requested, ULONG flags, LPSRowSet* rows) override + { + Assert::AreEqual(20L, requested); + Assert::AreEqual(0UL, flags); + ++queryCount; + *rows = nullptr; + if (queryCount == failOnQuery && !returnRowsOnFailure) return MAPI_E_CALL_FAILED; + if (position == m_rowCount && nullAtEnd) return S_OK; + const auto count = (std::min)({static_cast(requested), m_batchSize, m_rowCount - position}); + *rows = mapi::allocate(CbNewSRowSet(count)); + if (!*rows) return MAPI_E_NOT_ENOUGH_MEMORY; + (*rows)->cRows = count; + if (m_withProperties) + { + for (ULONG row = 0; row < count; ++row) + { + auto& outputRow = (*rows)->aRow[row]; + outputRow.lpProps = mapi::allocate(2 * sizeof(SPropValue)); + if (!outputRow.lpProps) return MAPI_E_NOT_ENOUGH_MEMORY; + outputRow.cValues = 2; + outputRow.lpProps[0].ulPropTag = stringTag; + outputRow.lpProps[0].Value.lpszW = m_text.data(); + outputRow.lpProps[1].ulPropTag = numberTag; + outputRow.lpProps[1].Value.l = static_cast(position + row); + } + } + + position += count; + return queryCount == failOnQuery ? MAPI_E_CALL_FAILED : S_OK; + } + HRESULT STDMETHODCALLTYPE Abort() override { return MAPI_E_NO_SUPPORT; } + HRESULT STDMETHODCALLTYPE ExpandRow(ULONG, LPBYTE, ULONG, ULONG, LPSRowSet*, ULONG*) override + { + return MAPI_E_NO_SUPPORT; + } + HRESULT STDMETHODCALLTYPE CollapseRow(ULONG, LPBYTE, ULONG, ULONG*) override { return MAPI_E_NO_SUPPORT; } + HRESULT STDMETHODCALLTYPE WaitForCompletion(ULONG, ULONG, ULONG*) override { return MAPI_E_NO_SUPPORT; } + HRESULT STDMETHODCALLTYPE GetCollapseState(ULONG, ULONG, LPBYTE, ULONG*, LPBYTE*) override + { + return MAPI_E_NO_SUPPORT; + } + HRESULT STDMETHODCALLTYPE SetCollapseState(ULONG, ULONG, LPBYTE, BOOKMARK*) override + { + return MAPI_E_NO_SUPPORT; + } + + ULONG queryCount{}; + ULONG seekCount{}; + ULONG position{}; + ULONG failOnQuery{}; + HRESULT seekResult{S_OK}; + bool returnRowsOnFailure{}; + bool nullAtEnd{}; + + private: + ULONG m_refs{1}; + ULONG m_rowCount{}; + ULONG m_batchSize{}; + bool m_withProperties{}; + std::wstring m_text{L"\u03A9<&"}; + }; + + class OutputFile + { + public: + OutputFile() + { + FILE* file{}; + Assert::AreEqual(0, tmpfile_s(&file)); + m_file.reset(file); + Assert::IsTrue(_setmode(_fileno(file), _O_U16TEXT) != -1); + } + + FILE* get() const noexcept { return m_file.get(); } + + std::wstring read() const + { + Assert::AreEqual(0, fflush(get())); + Assert::AreEqual(0, fseek(get(), 0, SEEK_SET)); + auto actual = std::wstring{}; + for (auto ch = fgetwc(get()); ch != WEOF; ch = fgetwc(get())) + actual.push_back(static_cast(ch)); + Assert::AreEqual(0, ferror(get())); + return actual; + } + + private: + std::unique_ptr m_file{nullptr, fclose}; + }; + + std::wstring ExpectedNumber(ULONG tag, ULONG value) + { + const auto names = + tag == numberTag ? L"\t\tPR_DOTSTUFF_STATE\n" + L"\t\tPR_NICK_NAME, PR_NICK_NAME_A, PR_NICK_NAME_W\n" + : tag == 0x60030003 + ? L"\t\tPR_DROPDOWN_DISPLAY_NAME\n" + L"\t\tPR_DROPDOWN_DISPLAY_NAME_A, PR_DROPDOWN_DISPLAY_NAME_W\n" + : L""; + return strings::format( + L"\t\n" + L"%ws" + L"\t\t%u\n" + L"\t\t0x%X\n" + L"\t\n", + tag, + names, + value, + value); + } + + std::wstring ExpectedProperties(ULONG value) + { + return ExpectedNumber(numberTag, value) + L"\t\n" + L"\t\t\n" + L"\t\tA9033C002600\n" + L"\t\n"; + } + + std::wstring ExpectedTable(ULONG rowCount, bool withProperties = false, ULONG firstValue = 0) + { + auto expected = std::wstring(L"\n\n"); + for (ULONG row = 0; row < rowCount; ++row) + { + expected += strings::format(L"\n", row); + if (withProperties) expected += ExpectedProperties(firstValue + row); + + expected += L"\n"; + } + + return expected + L"
\n"; + } + + TEST_CLASS(mapioutputtest) + { + public: + TEST_CLASS_INITIALIZE(initialize) { unittest::init(); } + + TEST_METHOD_INITIALIZE(initializeMemory) + { + Assert::IsTrue(buffers == nullptr); + invalidFrees = 0; + freedRowSets = 0; + freedPropertyTags.clear(); + m_previousFreedRowSets = 0; + m_errors.clear(); + HMODULE testModule{}; + Assert::IsTrue(GetModuleHandleExW(0, L"UnitTest.dll", &testModule) != FALSE); + const auto original = mapistub::GetMAPIHandle(); + if (original) + { + if (!GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, reinterpret_cast(original), &m_originalMAPI)) + { + FreeLibrary(testModule); + Assert::Fail(L"Could not retain the original MAPI module."); + } + } + + m_debugTag = registry::debugTag; + registry::debugTag = 0; + mapistub::SetMAPIHandle(nullptr); + mapistub::SetMAPIHandle(testModule); + m_installed = true; + m_originalDisplayError = std::move(error::displayError); + error::displayError = [this](const std::wstring& text) { m_errors.push_back(text); }; + } + + TEST_METHOD_CLEANUP(uninitializeMemory) + { + ULONG leakedBuffers{}; + while (buffers) + { + ++leakedBuffers; + TestMAPIFreeBuffer(buffers + 1); + } + + if (m_installed) + { + error::displayError = std::move(m_originalDisplayError); + mapistub::SetMAPIHandle(nullptr); + if (m_originalMAPI) mapistub::SetMAPIHandle(m_originalMAPI); + registry::debugTag = m_debugTag; + m_originalMAPI = nullptr; + m_installed = false; + } + + Assert::AreEqual(0UL, leakedBuffers, L"The exporter leaked MAPI allocations."); + Assert::AreEqual(0UL, invalidFrees, L"The exporter freed an unowned MAPI allocation."); + } + + TEST_METHOD(Test_TableRowIndicesAcrossBatches) { CheckTable(41, 20); } + TEST_METHOD(Test_TableRowIndicesShortBatches) { CheckTable(41, 7); } + TEST_METHOD(Test_TableRowIndicesSingleRowBatches) { CheckTable(21, 1); } + + TEST_METHOD(Test_TableRowIndicesBoundaries) + { + for (const auto count : {0UL, 1UL, 19UL, 20UL, 21UL, 40UL}) + CheckTable(count, 20); + } + + TEST_METHOD(Test_TablePropertiesStayWithRowsAcrossBatches) + { + CheckTable(41, 7, true); + Assert::AreEqual(size_t{82}, freedPropertyTags.size()); + for (size_t prop = 0; prop < freedPropertyTags.size(); prop += 2) + { + Assert::AreEqual(stringTag, freedPropertyTags[prop]); + Assert::AreEqual(numberTag, freedPropertyTags[prop + 1]); + } + } + + TEST_METHOD(Test_TableSeeksToBeginning) + { + auto table = TestTable(3, 2, true); + table.position = 2; + auto file = OutputFile{}; + output::outputTable(output::dbgLevel::NoDebug, file.get(), &table); + unittest::AreEqualEx(ExpectedTable(3, true), file.read()); + Assert::AreEqual(1UL, table.seekCount); + Assert::IsTrue(m_errors.empty()); + } + + TEST_METHOD(Test_TableAcceptsNullTerminalRowSet) + { + auto table = TestTable(21, 20, true); + table.nullAtEnd = true; + auto file = OutputFile{}; + output::outputTable(output::dbgLevel::NoDebug, file.get(), &table); + unittest::AreEqualEx(ExpectedTable(21, true), file.read()); + Assert::AreEqual(3UL, table.queryCount); + Assert::AreEqual(2UL, freedRowSets); + Assert::IsTrue(m_errors.empty()); + } + + TEST_METHOD(Test_TableQueryFailureBeforeFirstRow) { CheckQueryFailure(1, false); } + TEST_METHOD(Test_TableQueryFailureAfterBatch) { CheckQueryFailure(2, false); } + TEST_METHOD(Test_TableQueryFailureFreesReturnedRows) { CheckQueryFailure(2, true); } + + TEST_METHOD(Test_TableSeekFailureIsReportedAndExportContinues) + { + auto table = TestTable(3, 20, true); + table.position = 1; + table.seekResult = MAPI_E_CALL_FAILED; + auto file = OutputFile{}; + output::outputTable(output::dbgLevel::NoDebug, file.get(), &table); + unittest::AreEqualEx(ExpectedTable(2, true, 1), file.read()); + CheckError(L"SeekRow"); + } + + TEST_METHOD(Test_NullTableProducesNoOutput) + { + auto file = OutputFile{}; + output::outputTable(output::dbgLevel::NoDebug, file.get(), nullptr); + Assert::IsTrue(file.read().empty()); + Assert::IsTrue(m_errors.empty()); + } + + TEST_METHOD(Test_DisabledDebugDoesNotReadTable) + { + auto table = TestTable(3, 20); + output::outputTable(output::dbgLevel::Generic, nullptr, &table); + Assert::AreEqual(0UL, table.queryCount); + Assert::AreEqual(0UL, table.seekCount); + Assert::IsTrue(m_errors.empty()); + } + + TEST_METHOD(Test_PropertiesAreSortedWithoutChangingInput) + { + SPropValue props[3]{}; + props[0].ulPropTag = 0x60030003; + props[0].Value.l = 3; + props[1].ulPropTag = numberTag; + props[1].Value.l = 1; + props[2].ulPropTag = 0x60020003; + props[2].Value.l = 2; + const auto original = std::vector(std::begin(props), std::end(props)); + auto file = OutputFile{}; + output::outputProperties(output::dbgLevel::NoDebug, file.get(), 3, props, nullptr, false); + unittest::AreEqualEx( + ExpectedNumber(numberTag, 1) + ExpectedNumber(0x60020003, 2) + ExpectedNumber(0x60030003, 3), + file.read()); + Assert::IsTrue(memcmp(original.data(), props, sizeof(props)) == 0); + Assert::IsTrue(m_errors.empty()); + } + + TEST_METHOD(Test_RowSetOutputsEveryRowsProperties) + { + auto table = TestTable(2, 20, true); + LPSRowSet rows{}; + Assert::AreEqual(S_OK, table.QueryRows(20, 0, &rows)); + auto file = OutputFile{}; + output::outputSRowSet(output::dbgLevel::NoDebug, file.get(), rows, nullptr); + FreeProws(rows); + unittest::AreEqualEx(ExpectedProperties(0) + ExpectedProperties(1), file.read()); + Assert::IsTrue(m_errors.empty()); + } + + TEST_METHOD(Test_EmptyRowSetProducesNoOutput) + { + SRowSet rows{}; + auto file = OutputFile{}; + output::outputSRowSet(output::dbgLevel::NoDebug, file.get(), &rows, nullptr); + Assert::IsTrue(file.read().empty()); + Assert::IsTrue(m_errors.empty()); + } + + TEST_METHOD(Test_InvalidRowInputsProduceDiagnostics) + { + auto file = OutputFile{}; + output::outputSRowSet(output::dbgLevel::NoDebug, file.get(), nullptr, nullptr); + output::outputSRow(output::dbgLevel::NoDebug, file.get(), nullptr, nullptr); + SRow row{}; + row.cValues = 1; + output::outputSRow(output::dbgLevel::NoDebug, file.get(), &row, nullptr); + output::outputProperties(output::dbgLevel::NoDebug, file.get(), 1, nullptr, nullptr, false); + const auto actual = file.read(); + for (const auto message : + {L"OutputSRowSet called with NULL lpRowSet!", + L"OutputSRow called with NULL lpSRow!", + L"OutputSRow called with NULL lpSRow->lpProps!", + L"OutputProperties called with NULL lpProps!"}) + Assert::IsTrue(actual.find(message) != std::wstring::npos); + Assert::IsTrue(m_errors.empty()); + } + + TEST_METHOD(Test_DisabledDebugSkipsRowAndPropertyOutput) + { + output::outputSRowSet(output::dbgLevel::Generic, nullptr, nullptr, nullptr); + output::outputSRow(output::dbgLevel::Generic, nullptr, nullptr, nullptr); + output::outputProperties(output::dbgLevel::Generic, nullptr, 1, nullptr, nullptr, false); + output::outputProperty(output::dbgLevel::Generic, nullptr, nullptr, nullptr, false); + Assert::IsTrue(m_errors.empty()); + } + + TEST_METHOD(Test_NullPropertyProducesNoOutput) + { + auto file = OutputFile{}; + output::outputProperty(output::dbgLevel::NoDebug, file.get(), nullptr, nullptr, false); + Assert::IsTrue(file.read().empty()); + Assert::IsTrue(m_errors.empty()); + } + + private: + void CheckTable(ULONG rowCount, ULONG batchSize, bool withProperties = false) + { + auto table = TestTable(rowCount, batchSize, withProperties); + auto file = OutputFile{}; + output::outputTable(output::dbgLevel::NoDebug, file.get(), &table); + unittest::AreEqualEx( + ExpectedTable(rowCount, withProperties), + file.read(), + strings::format(L"Rows: %u, batch size: %u", rowCount, batchSize).c_str()); + Assert::AreEqual((rowCount + batchSize - 1) / batchSize + 1, table.queryCount); + Assert::AreEqual(table.queryCount, freedRowSets - m_previousFreedRowSets); + m_previousFreedRowSets = freedRowSets; + Assert::AreEqual(1UL, table.seekCount); + Assert::IsTrue(m_errors.empty()); + } + + void CheckQueryFailure(ULONG failOnQuery, bool returnRows) + { + auto table = TestTable(25, 20, true); + table.failOnQuery = failOnQuery; + table.returnRowsOnFailure = returnRows; + auto file = OutputFile{}; + output::outputTable(output::dbgLevel::NoDebug, file.get(), &table); + unittest::AreEqualEx(ExpectedTable((failOnQuery - 1) * 20, true), file.read()); + Assert::AreEqual(failOnQuery, table.queryCount); + Assert::AreEqual(failOnQuery - (returnRows ? 0UL : 1UL), freedRowSets); + CheckError(L"QueryRows"); + } + + void CheckError(const std::wstring& function) + { + Assert::AreEqual(size_t{1}, m_errors.size()); + Assert::IsTrue(m_errors[0].find(function) != std::wstring::npos); + Assert::IsTrue(m_errors[0].find(L"0x80004005") != std::wstring::npos); + } + + HMODULE m_originalMAPI{}; + bool m_installed{}; + ULONG m_debugTag{}; + ULONG m_previousFreedRowSets{}; + std::function m_originalDisplayError; + std::vector m_errors; + }; +} // namespace mapioutputtest diff --git a/core/mapi/mapiOutput.cpp b/core/mapi/mapiOutput.cpp index 256bb9698..2b79e4053 100644 --- a/core/mapi/mapiOutput.cpp +++ b/core/mapi/mapiOutput.cpp @@ -252,6 +252,7 @@ namespace output Output(ulDbgLvl, fFile, false, g_szXMLHeader); Output(ulDbgLvl, fFile, false, L"\n"); + ULONG iOutputRow = 0; for (;;) { FreeProws(lpRows); @@ -261,7 +262,7 @@ namespace output for (ULONG iCurRow = 0; iCurRow < lpRows->cRows; iCurRow++) { - Outputf(ulDbgLvl, fFile, false, L"\n", iCurRow); + Outputf(ulDbgLvl, fFile, false, L"\n", iOutputRow++); outputSRow(ulDbgLvl, fFile, &lpRows->aRow[iCurRow], nullptr); Output(ulDbgLvl, fFile, false, L"\n"); }