yams/tests/unit/manifest/manifest_catch2_test.cpp
trvon b31de9ce99
fix(storage): harden manifest and graph cleanup
Decode canonical and legacy binary manifests strictly, including protobuf builds. Add indexed, transactional document graph cleanup with typed forced-deletion errors, and resolve the associated arithmetic, narrowing, pagination, backoff, and nullable-coordinator findings.
2026-07-29 02:35:04 -04:00

618 lines
23 KiB
C++

// Catch2 tests for ManifestManager
// Migrated from GTest: manifest_test.cpp
#include <catch2/catch_test_macros.hpp>
#include <yams/chunking/chunker.h>
#include <yams/crypto/hasher.h>
#include <yams/manifest/manifest_manager.h>
#include <algorithm>
#include <array>
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <random>
#include <span>
#include <string_view>
#include <thread>
#include <type_traits>
using namespace yams;
using namespace yams::manifest;
using namespace yams::chunking;
namespace {
std::filesystem::path make_temp_dir(std::string_view prefix = "yams_manifest_test_") {
namespace fs = std::filesystem;
const auto base = fs::temp_directory_path();
std::uniform_int_distribution<int> dist(0, 9999);
thread_local std::mt19937_64 rng{std::random_device{}()};
for (int attempt = 0; attempt < 512; ++attempt) {
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
auto candidate =
base / (std::string(prefix) + std::to_string(stamp) + "_" + std::to_string(dist(rng)));
std::error_code ec;
if (fs::create_directories(candidate, ec)) {
return candidate;
}
}
return base;
}
std::vector<std::byte> generateRandomBytes(std::size_t size) {
static thread_local std::mt19937_64 engine{std::random_device{}()};
std::vector<std::byte> data(size);
std::uniform_int_distribution<int> dist(0, 255);
std::generate(data.begin(), data.end(), [&] { return std::byte(dist(engine)); });
return data;
}
template <typename T> void appendFixturePod(std::vector<std::byte>& output, T value) {
static_assert(std::is_trivially_copyable_v<T>);
const auto bytes = std::as_bytes(std::span<const T, 1>{&value, 1});
output.insert(output.end(), bytes.begin(), bytes.end());
}
void appendFixtureString(std::vector<std::byte>& output, std::string_view value) {
appendFixturePod(output, static_cast<std::uint32_t>(value.size()));
const auto bytes = std::as_bytes(std::span{value.data(), value.size()});
output.insert(output.end(), bytes.begin(), bytes.end());
}
std::vector<std::byte> makeBinaryManifestFixture(bool legacy64BitFlags,
std::uint64_t firstChunkFlags = 1) {
const std::string kFileHash(64, 'f');
const std::string kFirstChunkHash(64, 'a');
const std::string kSecondChunkHash(64, 'b');
const std::string kThirdChunkHash(64, 'c');
std::vector<std::byte> output;
const std::array magic{std::byte{'Y'}, std::byte{'A'}, std::byte{'M'}, std::byte{'S'}};
output.insert(output.end(), magic.begin(), magic.end());
appendFixturePod(output, Manifest::CURRENT_VERSION);
appendFixtureString(output, kFileHash);
appendFixturePod(output, std::uint64_t{18});
appendFixtureString(output, "legacy.bin");
appendFixtureString(output, "application/octet-stream");
appendFixturePod(output, std::uint32_t{3});
const auto appendChunk = [&](std::string_view hash, std::uint64_t offset, std::uint32_t size,
std::uint64_t flags) {
appendFixtureString(output, hash);
appendFixturePod(output, offset);
appendFixturePod(output, size);
if (legacy64BitFlags) {
appendFixturePod(output, flags);
} else {
appendFixturePod(output, static_cast<std::uint32_t>(flags));
}
};
appendChunk(kFirstChunkHash, 0, 5, firstChunkFlags);
appendChunk(kSecondChunkHash, 5, 7, 2);
appendChunk(kThirdChunkHash, 12, 6, 3);
return output;
}
struct ManifestTestFixture {
ManifestTestFixture() {
testDir = make_temp_dir();
ManifestManager::Config config{.enableCompression = false, // Disable for predictable tests
.enableChecksums = true,
.enableCaching = true,
.cacheSize = 100};
manager = std::make_unique<ManifestManager>(config);
}
~ManifestTestFixture() {
manager.reset();
std::error_code ec;
std::filesystem::remove_all(testDir, ec);
}
// Helper to create test chunks
std::vector<Chunk> createTestChunks(size_t count, size_t chunkSize = 1024) {
std::vector<Chunk> chunks;
chunks.reserve(count);
uint64_t offset = 0;
auto hasher = crypto::createSHA256Hasher();
for (size_t i = 0; i < count; ++i) {
auto data = generateRandomBytes(chunkSize);
auto hash = hasher->hash(data);
chunks.emplace_back(
Chunk{.data = data, .hash = hash, .offset = offset, .size = data.size()});
offset += data.size();
}
return chunks;
}
// Helper to create test file info
FileInfo createTestFileInfo(const std::vector<Chunk>& chunks) {
uint64_t totalSize = 0;
std::vector<std::byte> allData;
for (const auto& chunk : chunks) {
totalSize += chunk.size;
allData.insert(allData.end(), chunk.data.begin(), chunk.data.end());
}
auto hasher = crypto::createSHA256Hasher();
auto fileHash = hasher->hash(allData);
return FileInfo{.hash = fileHash,
.size = totalSize,
.mimeType = "text/plain",
.createdAt = std::chrono::system_clock::now(),
.originalName = "test.txt"};
}
std::filesystem::path testDir;
std::unique_ptr<ManifestManager> manager;
};
} // namespace
TEST_CASE_METHOD(ManifestTestFixture, "ManifestManager create simple manifest",
"[manifest][catch2]") {
auto chunks = createTestChunks(5, 512);
auto fileInfo = createTestFileInfo(chunks);
auto result = manager->createManifest(fileInfo, chunks);
REQUIRE(result.has_value());
const auto& manifest = result.value();
CHECK((manifest.version == Manifest::CURRENT_VERSION));
CHECK((manifest.fileHash == fileInfo.hash));
CHECK((manifest.fileSize == fileInfo.size));
CHECK((manifest.originalName == fileInfo.originalName));
CHECK((manifest.mimeType == fileInfo.mimeType));
CHECK((manifest.chunks.size() == chunks.size()));
// Verify chunk mapping
for (size_t i = 0; i < chunks.size(); ++i) {
CHECK((manifest.chunks[i].hash == chunks[i].hash));
CHECK((manifest.chunks[i].offset == chunks[i].offset));
CHECK((manifest.chunks[i].size == chunks[i].size));
}
CHECK(manifest.isValid());
}
TEST_CASE_METHOD(ManifestTestFixture, "ManifestManager create empty manifest fails",
"[manifest][catch2]") {
std::vector<Chunk> emptyChunks;
FileInfo fileInfo{.hash = "empty",
.size = 0,
.mimeType = "text/plain",
.createdAt = std::chrono::system_clock::now(),
.originalName = "empty.txt"};
auto result = manager->createManifest(fileInfo, emptyChunks);
REQUIRE_FALSE(result.has_value());
CHECK((result.error() == ErrorCode::InvalidArgument));
}
TEST_CASE_METHOD(ManifestTestFixture, "ManifestManager create large manifest",
"[manifest][catch2]") {
// Test with many chunks
auto chunks = createTestChunks(1000, 64);
auto fileInfo = createTestFileInfo(chunks);
auto result = manager->createManifest(fileInfo, chunks);
REQUIRE(result.has_value());
const auto& manifest = result.value();
CHECK((manifest.chunks.size() == 1000u));
CHECK(manifest.isValid());
// Verify total size calculation
CHECK((manifest.calculateTotalSize() == manifest.fileSize));
}
TEST_CASE_METHOD(ManifestTestFixture, "ManifestManager serialize deserialize roundtrip",
"[manifest][catch2]") {
auto chunks = createTestChunks(10, 256);
auto fileInfo = createTestFileInfo(chunks);
// Create manifest
auto createResult = manager->createManifest(fileInfo, chunks);
REQUIRE(createResult.has_value());
const auto& originalManifest = createResult.value();
// Serialize
auto serializeResult = manager->serialize(originalManifest);
REQUIRE(serializeResult.has_value());
const auto& serializedData = serializeResult.value();
// Verify serialized size is reasonable
CHECK((serializedData.size() > 100u)); // Should have substantial content
CHECK((serializedData.size() <
originalManifest.fileSize)); // Should be less than original file size
// Deserialize
auto deserializeResult = manager->deserialize(serializedData);
REQUIRE(deserializeResult.has_value());
const auto& deserializedManifest = deserializeResult.value();
// Verify roundtrip accuracy
CHECK((deserializedManifest.version == originalManifest.version));
CHECK((deserializedManifest.fileHash == originalManifest.fileHash));
CHECK((deserializedManifest.fileSize == originalManifest.fileSize));
CHECK((deserializedManifest.originalName == originalManifest.originalName));
CHECK((deserializedManifest.mimeType == originalManifest.mimeType));
CHECK((deserializedManifest.chunks.size() == originalManifest.chunks.size()));
// Verify all chunks match
for (size_t i = 0; i < originalManifest.chunks.size(); ++i) {
CHECK((deserializedManifest.chunks[i].hash == originalManifest.chunks[i].hash));
CHECK((deserializedManifest.chunks[i].offset == originalManifest.chunks[i].offset));
CHECK((deserializedManifest.chunks[i].size == originalManifest.chunks[i].size));
}
}
TEST_CASE_METHOD(ManifestTestFixture, "ManifestManager reads canonical 32-bit chunk flags",
"[manifest][catch2][compatibility]") {
const auto bytes = makeBinaryManifestFixture(false);
auto result = manager->deserialize(bytes);
REQUIRE(result.has_value());
REQUIRE((result.value().chunks.size() == 3));
CHECK((result.value().chunks[0].flags == 1));
CHECK((result.value().chunks[1].flags == 2));
CHECK((result.value().chunks[2].flags == 3));
}
TEST_CASE_METHOD(ManifestTestFixture, "ManifestManager reads legacy 64-bit chunk flags",
"[manifest][catch2][compatibility]") {
const auto bytes = makeBinaryManifestFixture(true);
auto result = manager->deserialize(bytes);
REQUIRE(result.has_value());
REQUIRE((result.value().chunks.size() == 3));
CHECK((result.value().chunks[0].flags == 1));
CHECK((result.value().chunks[1].flags == 2));
CHECK((result.value().chunks[2].flags == 3));
CHECK((result.value().calculateTotalSize() == result.value().fileSize));
}
TEST_CASE_METHOD(ManifestTestFixture, "ManifestManager rejects oversized legacy chunk flags",
"[manifest][catch2][compatibility]") {
const auto bytes = makeBinaryManifestFixture(
true, static_cast<std::uint64_t>(std::numeric_limits<std::uint32_t>::max()) + 1);
auto result = manager->deserialize(bytes);
REQUIRE_FALSE(result.has_value());
CHECK((result.error() == ErrorCode::CorruptedData));
}
TEST_CASE_METHOD(ManifestTestFixture, "ManifestManager rejects trailing binary manifest bytes",
"[manifest][catch2][compatibility]") {
auto bytes = makeBinaryManifestFixture(false);
appendFixturePod(bytes, std::uint32_t{0});
auto result = manager->deserialize(bytes);
REQUIRE_FALSE(result.has_value());
CHECK((result.error() == ErrorCode::CorruptedData));
}
TEST_CASE_METHOD(ManifestTestFixture, "ManifestManager deserialize corrupted data fails",
"[manifest][catch2]") {
std::vector<std::byte> corruptedData{std::byte{0xFF}, std::byte{0xFE}, std::byte{0xFD}};
auto result = manager->deserialize(corruptedData);
REQUIRE_FALSE(result.has_value());
CHECK((result.error() == ErrorCode::CorruptedData));
}
TEST_CASE_METHOD(ManifestTestFixture, "ManifestManager validate manifest", "[manifest][catch2]") {
auto chunks = createTestChunks(5, 512);
auto fileInfo = createTestFileInfo(chunks);
auto createResult = manager->createManifest(fileInfo, chunks);
REQUIRE(createResult.has_value());
auto manifest = createResult.value();
// Valid manifest
auto validationResult = manager->validateManifest(manifest);
REQUIRE(validationResult.has_value());
CHECK(validationResult.value());
// Test invalid manifest - wrong file size
manifest.fileSize = 12345;
validationResult = manager->validateManifest(manifest);
REQUIRE(validationResult.has_value());
CHECK_FALSE(validationResult.value());
}
TEST_CASE_METHOD(ManifestTestFixture, "ManifestManager validate manifest with gaps",
"[manifest][catch2]") {
auto chunks = createTestChunks(3, 512);
auto fileInfo = createTestFileInfo(chunks);
auto createResult = manager->createManifest(fileInfo, chunks);
REQUIRE(createResult.has_value());
auto manifest = createResult.value();
// Create gap in chunk offsets
manifest.chunks[1].offset = manifest.chunks[0].offset + manifest.chunks[0].size + 100;
auto validationResult = manager->validateManifest(manifest);
REQUIRE(validationResult.has_value());
CHECK_FALSE(validationResult.value());
}
TEST_CASE_METHOD(ManifestTestFixture, "ManifestManager file reconstruction", "[manifest][catch2]") {
auto chunks = createTestChunks(5, 1024);
auto fileInfo = createTestFileInfo(chunks);
// Create manifest
auto createResult = manager->createManifest(fileInfo, chunks);
REQUIRE(createResult.has_value());
const auto& manifest = createResult.value();
// Mock chunk provider
struct MockChunkProvider : public IChunkProvider {
const std::vector<Chunk>& chunks;
explicit MockChunkProvider(const std::vector<Chunk>& c) : chunks(c) {}
Result<std::vector<std::byte>> getChunk(const std::string& hash) const override {
for (const auto& chunk : chunks) {
if (chunk.hash == hash) {
return Result<std::vector<std::byte>>(chunk.data);
}
}
return Result<std::vector<std::byte>>(ErrorCode::ChunkNotFound);
}
bool hasChunk(const std::string& hash) const {
return std::ranges::any_of(chunks,
[&hash](const auto& chunk) { return chunk.hash == hash; });
}
};
MockChunkProvider provider(chunks);
auto outputPath = testDir / "reconstructed.txt";
// Reconstruct file
auto result = manager->reconstructFile(manifest, outputPath, provider);
REQUIRE(result.has_value());
// Verify reconstructed file
REQUIRE(std::filesystem::exists(outputPath));
CHECK((std::filesystem::file_size(outputPath) == manifest.fileSize));
// Verify file content matches original
std::ifstream file(outputPath, std::ios::binary);
REQUIRE(file.is_open());
for (const auto& chunk : chunks) {
std::vector<char> buffer(chunk.size);
file.read(buffer.data(), static_cast<std::streamsize>(chunk.size));
REQUIRE(file.good());
// Compare chunk data
auto chunkBytes = reinterpret_cast<const std::byte*>(buffer.data());
CHECK(std::equal(chunk.data.begin(), chunk.data.end(), chunkBytes));
}
}
TEST_CASE_METHOD(ManifestTestFixture, "ManifestManager file reconstruction missing chunk",
"[manifest][catch2]") {
auto chunks = createTestChunks(3, 512);
auto fileInfo = createTestFileInfo(chunks);
auto createResult = manager->createManifest(fileInfo, chunks);
REQUIRE(createResult.has_value());
const auto& manifest = createResult.value();
// Mock provider missing the second chunk
struct IncompleteChunkProvider : public IChunkProvider {
const std::vector<Chunk>& chunks;
explicit IncompleteChunkProvider(const std::vector<Chunk>& c) : chunks(c) {}
Result<std::vector<std::byte>> getChunk(const std::string& hash) const override {
// Skip the second chunk
for (size_t i = 0; i < chunks.size(); ++i) {
if (i == 1)
continue; // Skip second chunk
if (chunks[i].hash == hash) {
return Result<std::vector<std::byte>>(chunks[i].data);
}
}
return Result<std::vector<std::byte>>(ErrorCode::ChunkNotFound);
}
bool hasChunk(const std::string& hash) const {
for (size_t i = 0; i < chunks.size(); ++i) {
if (i == 1)
continue;
if (chunks[i].hash == hash)
return true;
}
return false;
}
};
IncompleteChunkProvider provider(chunks);
auto outputPath = testDir / "failed_reconstruction.txt";
auto result = manager->reconstructFile(manifest, outputPath, provider);
REQUIRE_FALSE(result.has_value());
CHECK((result.error() == ErrorCode::ChunkNotFound));
}
TEST_CASE_METHOD(ManifestTestFixture, "ManifestManager async file reconstruction",
"[manifest][catch2]") {
auto chunks = createTestChunks(3, 256);
auto fileInfo = createTestFileInfo(chunks);
auto createResult = manager->createManifest(fileInfo, chunks);
REQUIRE(createResult.has_value());
const auto& manifest = createResult.value();
// Mock chunk provider with delays
struct SlowChunkProvider : public IChunkProvider {
const std::vector<Chunk>& chunks;
explicit SlowChunkProvider(const std::vector<Chunk>& c) : chunks(c) {}
Result<std::vector<std::byte>> getChunk(const std::string& hash) const override {
// Simulate network delay
std::this_thread::sleep_for(std::chrono::milliseconds(10));
for (const auto& chunk : chunks) {
if (chunk.hash == hash) {
return Result<std::vector<std::byte>>(chunk.data);
}
}
return Result<std::vector<std::byte>>(ErrorCode::ChunkNotFound);
}
bool hasChunk(const std::string& hash) const {
return std::ranges::any_of(chunks,
[&hash](const auto& chunk) { return chunk.hash == hash; });
}
};
SlowChunkProvider provider(chunks);
auto outputPath = testDir / "async_reconstructed.txt";
// Start async reconstruction
auto future = manager->reconstructFileAsync(manifest, outputPath, provider);
// Should complete within reasonable time
auto status = future.wait_for(std::chrono::seconds(5));
REQUIRE((status == std::future_status::ready));
auto result = future.get();
REQUIRE(result.has_value());
// Verify file was created
REQUIRE(std::filesystem::exists(outputPath));
CHECK((std::filesystem::file_size(outputPath) == manifest.fileSize));
}
TEST_CASE("ManifestManager mime type detection", "[manifest][catch2]") {
// Test various file extensions
CHECK((ManifestManager::detectMimeType("test.txt") == "text/plain"));
CHECK((ManifestManager::detectMimeType("config.json") == "application/json"));
CHECK((ManifestManager::detectMimeType("image.jpg") == "image/jpeg"));
CHECK((ManifestManager::detectMimeType("image.JPEG") == "image/jpeg")); // Case insensitive
CHECK((ManifestManager::detectMimeType("document.pdf") == "application/pdf"));
CHECK((ManifestManager::detectMimeType("unknown.xyz") == "application/octet-stream"));
}
TEST_CASE_METHOD(ManifestTestFixture, "ManifestManager statistics", "[manifest][catch2]") {
auto chunks = createTestChunks(5, 512);
auto fileInfo = createTestFileInfo(chunks);
// Create and serialize multiple manifests
for (int i = 0; i < 3; ++i) {
auto createResult = manager->createManifest(fileInfo, chunks);
REQUIRE(createResult.has_value());
auto serializeResult = manager->serialize(createResult.value());
REQUIRE(serializeResult.has_value());
auto deserializeResult = manager->deserialize(serializeResult.value());
REQUIRE(deserializeResult.has_value());
}
auto stats = manager->getStats();
CHECK((stats.totalManifests >= 3u)); // At least 3 manifests
CHECK((stats.avgSerializationTime.count() > 0));
CHECK((stats.avgDeserializationTime.count() > 0));
}
TEST_CASE_METHOD(ManifestTestFixture, "ManifestManager concurrent operations",
"[manifest][catch2]") {
const int numThreads = 10;
const int operationsPerThread = 5;
std::vector<std::thread> threads;
std::atomic<int> successCount{0};
auto chunks = createTestChunks(3, 256);
auto fileInfo = createTestFileInfo(chunks);
for (int i = 0; i < numThreads; ++i) {
threads.emplace_back([this, &chunks, &fileInfo, &successCount]() {
for (int j = 0; j < operationsPerThread; ++j) {
// Create manifest
auto createResult = manager->createManifest(fileInfo, chunks);
if (!createResult.has_value())
continue;
// Serialize
auto serializeResult = manager->serialize(createResult.value());
if (!serializeResult.has_value())
continue;
// Deserialize
auto deserializeResult = manager->deserialize(serializeResult.value());
if (!deserializeResult.has_value())
continue;
// Validate
auto validateResult = manager->validateManifest(deserializeResult.value());
if (!validateResult.has_value() || !validateResult.value())
continue;
successCount++;
}
});
}
for (auto& thread : threads) {
thread.join();
}
CHECK((successCount.load() == numThreads * operationsPerThread));
}
TEST_CASE_METHOD(ManifestTestFixture, "ManifestManager performance benchmark",
"[manifest][catch2][!benchmark]") {
// Test serialization performance
auto chunks = createTestChunks(100, 1024); // 100KB file
auto fileInfo = createTestFileInfo(chunks);
auto createResult = manager->createManifest(fileInfo, chunks);
REQUIRE(createResult.has_value());
const auto& manifest = createResult.value();
// Benchmark serialization
auto start = std::chrono::steady_clock::now();
for (int i = 0; i < 10; ++i) {
auto result = manager->serialize(manifest);
REQUIRE(result.has_value());
}
auto end = std::chrono::steady_clock::now();
auto avgSerializationTime =
std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count() / 10;
// Should serialize 100KB file manifest in < 50ms
CHECK((avgSerializationTime < 50));
// Verify manifest size is < 10% of file size
auto serializeResult = manager->serialize(manifest);
REQUIRE(serializeResult.has_value());
double manifestRatio = static_cast<double>(serializeResult.value().size()) / manifest.fileSize;
CHECK((manifestRatio < 0.1)); // < 10%
}