449 lines
19 KiB
C++
449 lines
19 KiB
C++
#include "nocal/storage/ics_store.hpp"
|
|
|
|
#include <chrono>
|
|
#include <cstdlib>
|
|
#include <ctime>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <iterator>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#if !defined(_WIN32)
|
|
#include <sys/stat.h>
|
|
#endif
|
|
|
|
namespace {
|
|
|
|
using namespace std::chrono;
|
|
|
|
void require(bool condition, const char* message) {
|
|
if (!condition) {
|
|
throw std::runtime_error(message);
|
|
}
|
|
}
|
|
|
|
void write_fixture(const std::filesystem::path& path, const std::string& contents) {
|
|
std::ofstream output(path, std::ios::binary);
|
|
output << contents;
|
|
require(static_cast<bool>(output), "failed to write test fixture");
|
|
}
|
|
|
|
std::string read_fixture(const std::filesystem::path& path) {
|
|
std::ifstream input(path, std::ios::binary);
|
|
require(static_cast<bool>(input), "failed to open test fixture");
|
|
return {std::istreambuf_iterator<char>{input}, {}};
|
|
}
|
|
|
|
void require_no_temporary_files(const std::filesystem::path& path) {
|
|
const std::string prefix = "." + path.filename().string() + ".tmp.";
|
|
for (const auto& entry : std::filesystem::directory_iterator(path.parent_path())) {
|
|
require(!entry.path().filename().string().starts_with(prefix),
|
|
"atomic save left a temporary file behind");
|
|
}
|
|
}
|
|
|
|
nocal::Event event_named(std::string uid, std::string title, int day_offset = 0) {
|
|
nocal::Event event;
|
|
event.uid = std::move(uid);
|
|
event.title = std::move(title);
|
|
event.start = sys_days{year{2026}/July/17} + days{day_offset} + 9h;
|
|
event.end = event.start + 1h;
|
|
return event;
|
|
}
|
|
|
|
void test_round_trip_and_folding(const std::filesystem::path& path) {
|
|
nocal::Event timed;
|
|
timed.uid = "meeting-1@example.test";
|
|
timed.title = "Planning, review; and \\ decisions";
|
|
timed.start = sys_days{year{2026}/July/17} + 9h + 30min + 15s;
|
|
timed.end = timed.start + 45min;
|
|
timed.location = "Nomarchy HQ";
|
|
timed.description = "First line\nA long UTF-8 line: café café café café café café café café café café café café";
|
|
timed.calendar_id = "work";
|
|
timed.color = "#89b4fa";
|
|
|
|
nocal::Event all_day;
|
|
all_day.uid = "holiday-1@example.test";
|
|
all_day.title = "Release day";
|
|
all_day.start = sys_days{year{2026}/July/20};
|
|
all_day.end = sys_days{year{2026}/July/22};
|
|
all_day.all_day = true;
|
|
|
|
const std::vector<nocal::Event> source{timed, all_day};
|
|
const auto saved_revision = nocal::storage::IcsStore::save(path, source);
|
|
const auto loaded = nocal::storage::IcsStore::load(path);
|
|
|
|
require(saved_revision.existed() && saved_revision.size() > 0,
|
|
"save did not return an existing non-empty revision");
|
|
require(loaded.revision.existed() && loaded.revision.size() == saved_revision.size(),
|
|
"load did not retain the exact saved revision");
|
|
require(loaded.warnings.empty(), "saved calendar produced load warnings");
|
|
require(loaded.safe_to_rewrite, "nocal-generated calendar was not safe to rewrite");
|
|
require(loaded.events.size() == 2, "round-trip event count differs");
|
|
const auto& loaded_timed = loaded.events[0];
|
|
require(loaded_timed.uid == timed.uid, "UID did not round-trip");
|
|
require(loaded_timed.title == timed.title, "escaped SUMMARY did not round-trip");
|
|
require(loaded_timed.start == timed.start && loaded_timed.end == timed.end,
|
|
"timed interval did not round-trip");
|
|
require(loaded_timed.description == timed.description, "DESCRIPTION did not round-trip");
|
|
require(loaded_timed.calendar_id == timed.calendar_id && loaded_timed.color == timed.color,
|
|
"nocal extension properties did not round-trip");
|
|
require(loaded.events[1].all_day, "all-day flag did not round-trip");
|
|
require(loaded.events[1].start == all_day.start && loaded.events[1].end == all_day.end,
|
|
"all-day interval did not round-trip");
|
|
|
|
std::ifstream input(path, std::ios::binary);
|
|
const std::string bytes{std::istreambuf_iterator<char>{input}, {}};
|
|
require(bytes.find("\r\n ") != std::string::npos, "long property was not folded");
|
|
require(bytes.find("Planning\\, review\\; and \\\\ decisions") != std::string::npos,
|
|
"TEXT escaping was not written");
|
|
std::size_t offset = 0;
|
|
while (offset < bytes.size()) {
|
|
const std::size_t end = bytes.find("\r\n", offset);
|
|
require(end != std::string::npos, "physical line is not CRLF terminated");
|
|
require(end - offset <= 75, "folded physical line exceeds 75 octets");
|
|
offset = end + 2;
|
|
}
|
|
}
|
|
|
|
void test_local_utc_and_recovery(const std::filesystem::path& path) {
|
|
write_fixture(path,
|
|
"BEGIN:VCALENDAR\r\n"
|
|
"VERSION:2.0\r\n"
|
|
"END:VEVENT\r\n"
|
|
"BEGIN:VEVENT\r\n"
|
|
"UID:local\r\n"
|
|
"DTSTART:20260717T101500\r\n"
|
|
"DTEND:20260717T111500\r\n"
|
|
"SUMMARY:Local time\r\n"
|
|
"END:VEVENT\r\n"
|
|
"BEGIN:VEVENT\r\n"
|
|
"UID:utc\r\n"
|
|
"DTSTART:20260717T101500Z\r\n"
|
|
"DTEND:20260717T111500Z\r\n"
|
|
"DESCRIPTION:folded text begins here and continues over a deliberately long value \r\n"
|
|
" with the rest\r\n"
|
|
"END:VEVENT\r\n"
|
|
"BEGIN:VEVENT\r\n"
|
|
"UID:one-day\r\n"
|
|
"DTSTART;VALUE=DATE:20260718\r\n"
|
|
"END:VEVENT\r\n"
|
|
"BEGIN:VEVENT\r\n"
|
|
"UID:broken\r\n"
|
|
"DTSTART:not-a-date\r\n"
|
|
"END:VEVENT\r\n"
|
|
"END:VCALENDAR\r\n");
|
|
|
|
const auto loaded = nocal::storage::IcsStore::load(path);
|
|
require(loaded.events.size() == 3, "malformed VEVENT was not isolated");
|
|
require(!loaded.warnings.empty(), "malformed VEVENT was not diagnosed");
|
|
require(!loaded.safe_to_rewrite, "malformed source was considered safe to rewrite");
|
|
require(loaded.events[0].start == loaded.events[1].start,
|
|
"local and UTC DATE-TIME should agree while tests run in UTC");
|
|
require(loaded.events[2].all_day, "DATE DTSTART was not recognized");
|
|
require(loaded.events[2].end - loaded.events[2].start == 24h,
|
|
"implicit all-day DTEND should be the following day in UTC");
|
|
require(loaded.events[1].description.find("value with the rest") != std::string::npos,
|
|
"folded content line was not unfolded");
|
|
}
|
|
|
|
void test_missing_file(const std::filesystem::path& path) {
|
|
std::error_code ignored;
|
|
std::filesystem::remove(path, ignored);
|
|
const auto loaded = nocal::storage::IcsStore::load(path);
|
|
require(loaded.events.empty() && loaded.warnings.empty(),
|
|
"missing calendar should load as empty without warnings");
|
|
require(loaded.safe_to_rewrite, "missing calendar should be safe to create");
|
|
require(!loaded.revision.existed() && loaded.revision.size() == 0,
|
|
"missing calendar produced an existing revision");
|
|
}
|
|
|
|
void test_nested_creation_and_permissions(const std::filesystem::path& root) {
|
|
const auto path = root / "new" / "nested" / "calendar.ics";
|
|
nocal::storage::IcsStore::save(path, {});
|
|
require(std::filesystem::is_regular_file(path), "save did not create a nested calendar path");
|
|
require(read_fixture(path).starts_with("BEGIN:VCALENDAR\r\n"),
|
|
"new calendar does not contain a VCALENDAR");
|
|
require_no_temporary_files(path);
|
|
|
|
#if !defined(_WIN32)
|
|
struct stat metadata {};
|
|
require(::stat(path.c_str(), &metadata) == 0, "could not stat saved calendar");
|
|
require((metadata.st_mode & S_IRUSR) != 0 && (metadata.st_mode & S_IWUSR) != 0,
|
|
"saved calendar is not readable and writable by its owner");
|
|
require((metadata.st_mode & (S_IRWXG | S_IRWXO)) == 0,
|
|
"saved calendar grants group or other permissions");
|
|
#endif
|
|
}
|
|
|
|
void test_atomic_replacement(const std::filesystem::path& root) {
|
|
const auto path = root / "replace.ics";
|
|
write_fixture(path, "old calendar bytes");
|
|
|
|
nocal::Event replacement;
|
|
replacement.uid = "replacement@example.test";
|
|
replacement.title = "Replacement";
|
|
replacement.start = sys_days{year{2026}/July/23} + 10h;
|
|
replacement.end = replacement.start + 30min;
|
|
const std::vector<nocal::Event> events{replacement};
|
|
nocal::storage::IcsStore::save(path, events);
|
|
|
|
const std::string bytes = read_fixture(path);
|
|
require(bytes.find("old calendar bytes") == std::string::npos,
|
|
"save did not replace the existing calendar");
|
|
require(bytes.find("UID:replacement@example.test") != std::string::npos,
|
|
"replacement calendar bytes are incomplete");
|
|
require_no_temporary_files(path);
|
|
}
|
|
|
|
void test_revisions_reject_external_changes(const std::filesystem::path& root) {
|
|
const auto path = root / "revisions.ics";
|
|
const auto backup = nocal::storage::IcsStore::backup_path(path);
|
|
const std::vector<nocal::Event> original_events{
|
|
event_named("revision-1@example.test", "Original")};
|
|
const auto missing = nocal::storage::IcsStore::load(path);
|
|
const auto created = nocal::storage::IcsStore::save(path, original_events, missing.revision);
|
|
require(created.existed(), "created revision says the calendar does not exist");
|
|
require(!std::filesystem::exists(backup), "initial creation unexpectedly made a backup");
|
|
|
|
const auto loaded = nocal::storage::IcsStore::load(path);
|
|
const std::string original = read_fixture(path);
|
|
std::string externally_changed = original;
|
|
const auto title = externally_changed.find("SUMMARY:Original");
|
|
require(title != std::string::npos, "test fixture lacks the expected title");
|
|
externally_changed[title + std::string("SUMMARY:").size()] = 'X';
|
|
require(externally_changed.size() == original.size(), "external edit changed fixture size");
|
|
write_fixture(path, externally_changed);
|
|
write_fixture(backup, "existing backup must remain");
|
|
|
|
bool failed = false;
|
|
std::string message;
|
|
try {
|
|
const std::vector<nocal::Event> replacement{
|
|
event_named("revision-2@example.test", "Replacement")};
|
|
(void)nocal::storage::IcsStore::save(path, replacement, loaded.revision);
|
|
} catch (const std::runtime_error& error) {
|
|
failed = true;
|
|
message = error.what();
|
|
}
|
|
require(failed, "same-size external change was not rejected");
|
|
require(message.find("reload") != std::string::npos,
|
|
"external-change error did not tell the caller to reload");
|
|
require(read_fixture(path) == externally_changed,
|
|
"conflicting save changed the externally modified calendar");
|
|
require(read_fixture(backup) == "existing backup must remain",
|
|
"conflicting save changed the existing backup");
|
|
require_no_temporary_files(path);
|
|
}
|
|
|
|
void test_backup_replacement_failure_preserves_calendar(const std::filesystem::path& root) {
|
|
#if !defined(_WIN32)
|
|
const auto path = root / "backup-replacement-failure.ics";
|
|
const std::vector<nocal::Event> original_events{
|
|
event_named("survivor@example.test", "Must survive")};
|
|
const auto revision = nocal::storage::IcsStore::save(path, original_events);
|
|
const std::string before = read_fixture(path);
|
|
const auto backup = nocal::storage::IcsStore::backup_path(path);
|
|
std::filesystem::create_directory(backup);
|
|
write_fixture(backup / "sentinel", "backup destination must survive");
|
|
|
|
bool failed = false;
|
|
try {
|
|
const std::vector<nocal::Event> replacement{
|
|
event_named("replacement@example.test", "Replacement")};
|
|
(void)nocal::storage::IcsStore::save(path, replacement, revision);
|
|
} catch (const std::runtime_error&) {
|
|
failed = true;
|
|
}
|
|
require(failed, "save unexpectedly replaced a non-empty backup directory");
|
|
require(read_fixture(path) == before, "backup failure changed the calendar");
|
|
require(read_fixture(backup / "sentinel") == "backup destination must survive",
|
|
"backup failure damaged the existing backup destination");
|
|
require_no_temporary_files(path);
|
|
require_no_temporary_files(backup);
|
|
#else
|
|
(void)root;
|
|
#endif
|
|
}
|
|
|
|
void test_backup_rotation_permissions_and_restore(const std::filesystem::path& root) {
|
|
const auto path = root / "backup.ics";
|
|
const auto backup = nocal::storage::IcsStore::backup_path(path);
|
|
const std::vector<nocal::Event> first_events{event_named("first@example.test", "First")};
|
|
const auto first_revision = nocal::storage::IcsStore::save(path, first_events);
|
|
const std::string first_bytes = read_fixture(path);
|
|
require(!std::filesystem::exists(backup), "initial save created a backup");
|
|
|
|
const std::vector<nocal::Event> second_events{event_named("second@example.test", "Second", 1)};
|
|
const auto second_revision = nocal::storage::IcsStore::save(path, second_events, first_revision);
|
|
const std::string second_bytes = read_fixture(path);
|
|
require(read_fixture(backup) == first_bytes, "backup does not contain exact previous bytes");
|
|
|
|
#if !defined(_WIN32)
|
|
struct stat metadata {};
|
|
require(::stat(backup.c_str(), &metadata) == 0, "could not stat calendar backup");
|
|
require((metadata.st_mode & (S_IRWXG | S_IRWXO)) == 0,
|
|
"calendar backup grants group or other permissions");
|
|
require((metadata.st_mode & S_IRUSR) != 0 && (metadata.st_mode & S_IWUSR) != 0,
|
|
"calendar backup is not owner-readable and owner-writable");
|
|
#endif
|
|
|
|
const std::vector<nocal::Event> third_events{event_named("third@example.test", "Third", 2)};
|
|
const auto third_revision = nocal::storage::IcsStore::save(path, third_events, second_revision);
|
|
require(read_fixture(backup) == second_bytes, "backup did not rotate to the previous revision");
|
|
|
|
const std::string backup_before_restore = read_fixture(backup);
|
|
const auto restored = nocal::storage::IcsStore::restore_backup(path, third_revision);
|
|
require(restored.existed() && restored.size() == backup_before_restore.size(),
|
|
"restore returned an incorrect revision");
|
|
require(read_fixture(path) == second_bytes, "restore did not install exact backup bytes");
|
|
require(read_fixture(backup) == backup_before_restore, "restore changed the backup");
|
|
|
|
std::string external = read_fixture(path);
|
|
external[0] = external[0] == 'B' ? 'X' : 'B';
|
|
write_fixture(path, external);
|
|
bool conflict = false;
|
|
try {
|
|
(void)nocal::storage::IcsStore::restore_backup(path, restored);
|
|
} catch (const std::runtime_error&) {
|
|
conflict = true;
|
|
}
|
|
require(conflict, "restore ignored an external calendar change");
|
|
require(read_fixture(path) == external, "conflicting restore changed the calendar");
|
|
require(read_fixture(backup) == backup_before_restore,
|
|
"conflicting restore changed the backup");
|
|
require_no_temporary_files(path);
|
|
}
|
|
|
|
void test_restore_requires_backup(const std::filesystem::path& root) {
|
|
const auto path = root / "missing-backup.ics";
|
|
const std::vector<nocal::Event> events{event_named("only@example.test", "Only")};
|
|
const auto revision = nocal::storage::IcsStore::save(path, events);
|
|
const std::string before = read_fixture(path);
|
|
bool failed = false;
|
|
try {
|
|
(void)nocal::storage::IcsStore::restore_backup(path, revision);
|
|
} catch (const std::runtime_error&) {
|
|
failed = true;
|
|
}
|
|
require(failed, "restore unexpectedly succeeded without a backup");
|
|
require(read_fixture(path) == before, "failed restore changed the calendar");
|
|
require(!std::filesystem::exists(nocal::storage::IcsStore::backup_path(path)),
|
|
"failed restore created a backup");
|
|
require_no_temporary_files(path);
|
|
}
|
|
|
|
void test_lock_failure_preserves_existing_bytes(const std::filesystem::path& root) {
|
|
#if !defined(_WIN32)
|
|
const auto path = root / "must-survive.ics";
|
|
const std::string original = "existing bytes must survive";
|
|
write_fixture(path, original);
|
|
const auto backup = nocal::storage::IcsStore::backup_path(path);
|
|
write_fixture(backup, "existing backup must survive");
|
|
|
|
std::filesystem::path lock_path = path;
|
|
lock_path += ".lock";
|
|
std::filesystem::create_directory(lock_path);
|
|
bool failed = false;
|
|
try {
|
|
nocal::storage::IcsStore::save(path, {});
|
|
} catch (const std::runtime_error&) {
|
|
failed = true;
|
|
}
|
|
require(failed, "save unexpectedly succeeded without acquiring its destination lock");
|
|
require(read_fixture(path) == original, "failed save modified the existing calendar");
|
|
require(read_fixture(backup) == "existing backup must survive",
|
|
"failed save modified the existing backup");
|
|
require_no_temporary_files(path);
|
|
std::filesystem::remove(lock_path);
|
|
#else
|
|
(void)root;
|
|
#endif
|
|
}
|
|
|
|
void test_rename_failure_cleans_temporary_file(const std::filesystem::path& root) {
|
|
#if !defined(_WIN32)
|
|
const auto path = root / "destination-is-a-directory.ics";
|
|
std::filesystem::create_directory(path);
|
|
const auto sentinel = path / "existing-data";
|
|
write_fixture(sentinel, "must remain");
|
|
|
|
bool failed = false;
|
|
try {
|
|
nocal::storage::IcsStore::save(path, {});
|
|
} catch (const std::runtime_error&) {
|
|
failed = true;
|
|
}
|
|
require(failed, "save unexpectedly replaced a non-empty directory");
|
|
require(read_fixture(sentinel) == "must remain", "failed replacement damaged existing data");
|
|
require_no_temporary_files(path);
|
|
#else
|
|
(void)root;
|
|
#endif
|
|
}
|
|
|
|
void test_unsupported_data_is_not_rewrite_safe(const std::filesystem::path& root) {
|
|
const auto path = root / "unsupported.ics";
|
|
write_fixture(path,
|
|
"BEGIN:VCALENDAR\r\n"
|
|
"VERSION:2.0\r\n"
|
|
"BEGIN:VEVENT\r\n"
|
|
"UID:recurring\r\n"
|
|
"DTSTART;TZID=Europe/London:20260717T101500\r\n"
|
|
"DTEND;TZID=Europe/London:20260717T111500\r\n"
|
|
"SUMMARY;LANGUAGE=en:Recurring appointment\r\n"
|
|
"RRULE:FREQ=WEEKLY\r\n"
|
|
"ATTENDEE:mailto:person@example.test\r\n"
|
|
"BEGIN:VALARM\r\n"
|
|
"ACTION:DISPLAY\r\n"
|
|
"TRIGGER:-PT10M\r\n"
|
|
"END:VALARM\r\n"
|
|
"END:VEVENT\r\n"
|
|
"X-UNKNOWN-CALENDAR-PROPERTY:keep me\r\n"
|
|
"END:VCALENDAR\r\n");
|
|
|
|
const auto loaded = nocal::storage::IcsStore::load(path);
|
|
require(!loaded.safe_to_rewrite,
|
|
"unsupported recurrence, alarm, attendee, parameters, or properties were considered safe");
|
|
require(!loaded.warnings.empty(), "unsafe calendar did not explain why editing is disabled");
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
#if !defined(_WIN32)
|
|
setenv("TZ", "UTC", 1);
|
|
tzset();
|
|
#endif
|
|
const auto root = std::filesystem::temp_directory_path() / "nocal-ics-tests";
|
|
const auto path = root / "calendar.ics";
|
|
try {
|
|
std::error_code ignored;
|
|
std::filesystem::remove_all(root, ignored);
|
|
std::filesystem::create_directories(root);
|
|
test_round_trip_and_folding(path);
|
|
test_local_utc_and_recovery(path);
|
|
test_missing_file(path);
|
|
test_nested_creation_and_permissions(root);
|
|
test_atomic_replacement(root);
|
|
test_revisions_reject_external_changes(root);
|
|
test_backup_replacement_failure_preserves_calendar(root);
|
|
test_backup_rotation_permissions_and_restore(root);
|
|
test_restore_requires_backup(root);
|
|
test_lock_failure_preserves_existing_bytes(root);
|
|
test_rename_failure_cleans_temporary_file(root);
|
|
test_unsupported_data_is_not_rewrite_safe(root);
|
|
std::filesystem::remove_all(root, ignored);
|
|
std::cout << "ics_tests: ok\n";
|
|
return 0;
|
|
} catch (const std::exception& error) {
|
|
std::error_code ignored;
|
|
std::filesystem::remove_all(root, ignored);
|
|
std::cerr << "ics_tests: " << error.what() << '\n';
|
|
return 1;
|
|
}
|
|
}
|