#include "nocal/sync/microsoft_graph.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { using namespace std::chrono; using nocal::storage::CachedAccount; using nocal::storage::CachedCalendar; using nocal::storage::SyncCache; using nocal::sync::HttpMethod; using nocal::sync::HttpRequest; using nocal::sync::HttpResponse; using nocal::sync::HttpTransport; using nocal::sync::MicrosoftGraphCredentials; using nocal::sync::MicrosoftGraphError; using nocal::sync::MicrosoftGraphSync; using nocal::sync::MicrosoftGraphWindow; void require(bool condition, std::string_view message) { if (!condition) { throw std::runtime_error(std::string(message)); } } class TemporaryDirectory { public: TemporaryDirectory() { path_ = std::filesystem::temp_directory_path() / ("nocal-graph-tests-" + std::to_string(::getpid()) + "-XXXXXX"); std::string pattern = path_.string(); std::vector writable(pattern.begin(), pattern.end()); writable.push_back('\0'); char* created = ::mkdtemp(writable.data()); require(created != nullptr, "unable to create temporary Graph test directory"); path_ = created; } ~TemporaryDirectory() { std::error_code ignored; std::filesystem::remove_all(path_, ignored); } [[nodiscard]] std::filesystem::path database(std::string_view name) const { return path_ / std::string(name); } private: std::filesystem::path path_; }; struct TransportStep { HttpResponse response; bool fail{false}; }; class ScriptedTransport final : public HttpTransport { public: HttpResponse send(const HttpRequest& request) override { requests.push_back(request); if (next >= steps.size()) { throw std::runtime_error("UNEXPECTED_REQUEST_SECRET"); } const TransportStep& step = steps[next++]; if (step.fail) { throw std::runtime_error("SENSITIVE_TRANSPORT_DETAIL"); } return step.response; } std::vector steps; std::vector requests; std::size_t next{0}; }; [[nodiscard]] HttpResponse json_response(std::string body, int status = 200) { return {status, {{"Content-Type", "application/json"}}, std::move(body)}; } [[nodiscard]] MicrosoftGraphCredentials credentials() { return {"account-local", "ACCESS_TOKEN_SECRET"}; } [[nodiscard]] std::string hash(std::string_view input) { std::array digest{}; unsigned int size = 0; require(EVP_Digest(input.data(), input.size(), digest.data(), &size, EVP_sha256(), nullptr) == 1 && size == digest.size(), "test SHA-256 failed"); static constexpr char hexadecimal[] = "0123456789abcdef"; std::string output; for (const unsigned char byte : digest) { output.push_back(hexadecimal[byte >> 4U]); output.push_back(hexadecimal[byte & 0x0fU]); } return output; } [[nodiscard]] std::string expected_id(std::string_view prefix, std::string_view type, std::string_view parent, std::string_view remote = {}) { std::string input(type); input.push_back('\0'); input += parent; if (!remote.empty()) { input.push_back('\0'); input += remote; } return std::string(prefix) + hash(input); } [[nodiscard]] std::string calendar_id(std::string_view remote) { return expected_id("graph-calendar-", "calendar", "account-local", remote); } [[nodiscard]] std::int64_t epoch_microseconds( year_month_day date, hours hour = 0h, minutes minute = 0min, seconds second = 0s, microseconds fractional = 0us) { return duration_cast( (sys_days{date} + hour + minute + second + fractional).time_since_epoch()) .count(); } void require_graph_headers(const HttpRequest& request) { require(request.method == HttpMethod::get && request.body.empty(), "Graph request was not a bodyless GET"); require(request.headers == std::vector{{"Authorization", "Bearer ACCESS_TOKEN_SECRET"}, {"Accept", "application/json"}, {"Content-Type", "application/json"}, {"Prefer", "outlook.timezone=\"UTC\", IdType=\"ImmutableId\""}}, "Graph request headers were not exact"); } template std::string expect_graph_error(Action&& action) { try { action(); } catch (const MicrosoftGraphError& error) { return error.what(); } throw std::runtime_error("expected MicrosoftGraphError was not thrown"); } void seed_account(SyncCache& cache) { cache.upsert_account({"account-local", "microsoft-graph", "remote-user", "User"}); } void seed_primary(SyncCache& cache, std::string remote = "primary-remote") { seed_account(cache); const CachedCalendar calendar{calendar_id(remote), "account-local", std::move(remote), "Primary", "#112233", false, true, "{\"id\":\"primary-remote\",\"isDefaultCalendar\":true}"}; cache.replace_calendars_after_complete_listing("account-local", {&calendar, 1}); } void test_profile_mapping_headers_and_atomic_validation(const TemporaryDirectory& temporary) { SyncCache cache(temporary.database("profile.db")); ScriptedTransport transport; transport.steps.push_back({json_response( "{\"id\":\"remote-subject\",\"displayName\":\"\"," "\"userPrincipalName\":\"user@example.test\"}")}); MicrosoftGraphSync graph(transport, cache); const CachedAccount account = graph.refresh_account(credentials()); require(account == CachedAccount{"account-local", "microsoft-graph", "remote-subject", "user@example.test"}, "Graph profile was not mapped exactly"); require(cache.snapshot().accounts == std::vector{account}, "Graph profile was not persisted"); require(transport.requests.size() == 1 && transport.requests[0].url == "https://graph.microsoft.com/v1.0/me", "Graph profile used the wrong route"); require_graph_headers(transport.requests[0]); const auto baseline = cache.snapshot(); ScriptedTransport invalid_transport; invalid_transport.steps.push_back( {json_response("{\"id\":\"SENSITIVE_REMOTE\",\"id\":\"duplicate\"," "\"displayName\":\"Secret\"}")}); MicrosoftGraphSync invalid_graph(invalid_transport, cache); const std::string message = expect_graph_error( [&] { (void)invalid_graph.refresh_account(credentials()); }); require(cache.snapshot() == baseline, "invalid profile changed cached account"); require(message.find("SENSITIVE") == std::string::npos && message.find("ACCESS_TOKEN_SECRET") == std::string::npos, "profile error leaked provider data"); } void test_paged_listing_atomicity_primary_and_stable_ids( const TemporaryDirectory& temporary) { SyncCache cache(temporary.database("listing.db")); seed_account(cache); const CachedCalendar old{"old-local", "account-local", "old-remote", "Old", "", false, true, "{\"isDefaultCalendar\":false}"}; cache.replace_calendars_after_complete_listing("account-local", {&old, 1}); const auto baseline = cache.snapshot(); const std::string next = "https://graph.microsoft.com/v1.0/me/calendars?$skiptoken=opaque-one"; ScriptedTransport partial; partial.steps = {{json_response( "{\"value\":[{\"id\":\"A\",\"name\":\"First\"," "\"hexColor\":\"#abcdef\",\"color\":\"lightBlue\"," "\"canEdit\":true,\"isDefaultCalendar\":true}]," "\"@odata.nextLink\":\"" + next + "\"}")}, {json_response("{\"value\":\"SENSITIVE_BAD_PAGE\"}")}}; MicrosoftGraphSync partial_graph(partial, cache); const std::string partial_message = expect_graph_error([&] { partial_graph.refresh_calendars(credentials()); }); require(cache.snapshot() == baseline, "partial calendar listing changed the cache"); require(partial_message.find("SENSITIVE") == std::string::npos, "calendar parse error leaked response body"); ScriptedTransport success; success.steps = {{partial.steps[0].response}, {json_response( "{\"value\":[{\"id\":\"a\",\"name\":\"Second\"," "\"hexColor\":null,\"color\":\"darkRed\",\"canEdit\":false," "\"isDefaultCalendar\":false}]}")}}; MicrosoftGraphSync success_graph(success, cache); success_graph.refresh_calendars(credentials()); const auto snapshot = cache.snapshot(); require(snapshot.calendars.size() == 3, "completed listing did not retain omitted calendar"); const auto find_remote = [&](std::string_view remote) -> const CachedCalendar& { const auto found = std::find_if(snapshot.calendars.begin(), snapshot.calendars.end(), [&](const CachedCalendar& value) { return value.remote_id == remote; }); require(found != snapshot.calendars.end(), "listed calendar is missing"); return *found; }; require(!find_remote("old-remote").active, "omitted calendar was not deactivated"); require(find_remote("A").id == calendar_id("A") && find_remote("A").color == "#abcdef" && !find_remote("A").read_only, "primary calendar mapping or ID is wrong"); require(find_remote("a").id == calendar_id("a") && find_remote("a").color == "darkRed" && find_remote("a").read_only && calendar_id("A") != calendar_id("a"), "case-sensitive calendar identity or fallback mapping is wrong"); require(success.requests.size() == 2 && success.requests[1].url == next, "calendar pagination did not use the opaque nextLink"); for (const HttpRequest& request : success.requests) { require_graph_headers(request); } const auto completed = cache.snapshot(); ScriptedTransport multiple; multiple.steps.push_back({json_response( "{\"value\":[{\"id\":\"one\",\"name\":\"One\",\"canEdit\":true," "\"isDefaultCalendar\":true},{\"id\":\"two\",\"name\":\"Two\"," "\"canEdit\":true,\"isDefaultCalendar\":true}]}")}); MicrosoftGraphSync multiple_graph(multiple, cache); expect_graph_error([&] { multiple_graph.refresh_calendars(credentials()); }); require(cache.snapshot() == completed, "multiple-primary listing changed cache"); } void test_delta_mapping_incremental_tombstone_and_routes( const TemporaryDirectory& temporary) { SyncCache cache(temporary.database("delta.db")); seed_primary(cache); const std::string next = "https://graph.microsoft.com/v1.0/me/calendarView/delta?$skiptoken=NEXT_OPAQUE"; const std::string delta = "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=DELTA_OPAQUE"; ScriptedTransport transport; transport.steps = {{json_response( "{\"value\":[{\"id\":\"occurrence-1\"," "\"iCalUId\":\"series-uid\",\"@odata.etag\":\"etag-1\"," "\"changeKey\":\"change-1\",\"subject\":\"Occurrence\"," "\"location\":{\"displayName\":\"Room\"}," "\"bodyPreview\":\"Preview\",\"isAllDay\":false," "\"type\":\"occurrence\"," "\"start\":{\"dateTime\":\"2026-07-18T10:00:00.1234567+01:00\"," "\"timeZone\":\"Ignored/Zone\"}," "\"end\":{\"dateTime\":\"2026-07-18T10:00:01.1234567\"," "\"timeZone\":\"UTC\"}}],\"@odata.nextLink\":\"" + next + "\"}")}, {json_response("{\"value\":[],\"@odata.deltaLink\":\"" + delta + "\"}")}}; MicrosoftGraphSync graph(transport, cache); const MicrosoftGraphWindow window{ epoch_microseconds(2026y / July / 1), epoch_microseconds(2026y / August / 1)}; graph.sync_primary_calendar_window(credentials(), window); auto snapshot = cache.snapshot(); require(transport.requests.size() == 2 && transport.requests[0].url == "https://graph.microsoft.com/v1.0/me/calendarView/delta?" "startDateTime=2026-07-01T00%3A00%3A00.000000Z&" "endDateTime=2026-08-01T00%3A00%3A00.000000Z" && transport.requests[1].url == next, "initial delta used the wrong documented route or canonical window"); for (const HttpRequest& request : transport.requests) { require_graph_headers(request); require(request.url.find("/beta/") == std::string::npos && request.url.find("/calendars/") == std::string::npos, "delta used beta or a per-calendar route"); } const std::string local_calendar = calendar_id("primary-remote"); const std::string local_event = expected_id("graph-event-", "event", local_calendar, "occurrence-1"); require(snapshot.events.size() == 1 && snapshot.events[0] == nocal::storage::CachedEvent{local_event, local_calendar, "occurrence-1", "series-uid", "etag-1", "change-1", snapshot.events[0].raw_payload, false}, "delta event mapping or deterministic identity is wrong"); require(snapshot.instances.size() == 1 && snapshot.instances[0].id == expected_id("graph-instance-", "instance", local_event) && snapshot.instances[0].start_epoch_microseconds == epoch_microseconds(2026y / July / 18, 9h, 0min, 0s, 123456us) && snapshot.instances[0].end_epoch_microseconds == epoch_microseconds(2026y / July / 18, 10h, 0min, 1s, 123456us) && snapshot.instances[0].location == "Room" && snapshot.instances[0].time_zone == "UTC", "delta occurrence timestamp or instance mapping is wrong"); require(snapshot.checkpoints.size() == 1 && snapshot.checkpoints[0].complete && snapshot.checkpoints[0].cursor == delta, "delta checkpoint was not committed complete"); ScriptedTransport incremental; const std::string delta_two = "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=DELTA_TWO"; incremental.steps.push_back({json_response( "{\"value\":[{\"id\":\"occurrence-1\",\"@removed\":{\"reason\":\"deleted\"}}]," "\"@odata.deltaLink\":\"" + delta_two + "\"}")}); MicrosoftGraphSync incremental_graph(incremental, cache); incremental_graph.sync_primary_calendar_window(credentials(), window); snapshot = cache.snapshot(); require(incremental.requests.size() == 1 && incremental.requests[0].url == delta, "incremental sync did not resume the exact delta cursor"); require(snapshot.events.size() == 1 && snapshot.events[0].deleted && !snapshot.events[0].raw_payload.empty() && snapshot.instances.empty(), "tombstone did not retain raw payload and remove instances"); } void test_page_resume_and_primary_only_failure(const TemporaryDirectory& temporary) { SyncCache cache(temporary.database("resume.db")); seed_primary(cache); const MicrosoftGraphWindow window{ epoch_microseconds(2026y / January / 1), epoch_microseconds(2026y / February / 1)}; const std::string next = "https://graph.microsoft.com/v1.0/me/calendarView/delta?$skiptoken=resume-here"; ScriptedTransport first; first.steps = {{json_response( "{\"value\":[{\"id\":\"event-one\",\"isAllDay\":false," "\"start\":{\"dateTime\":\"2026-01-02T00:00:00Z\"}," "\"end\":{\"dateTime\":\"2026-01-02T01:00:00Z\"}}]," "\"@odata.nextLink\":\"" + next + "\"}")}, {{}, true}}; MicrosoftGraphSync first_graph(first, cache); const std::string message = expect_graph_error( [&] { first_graph.sync_primary_calendar_window(credentials(), window); }); auto snapshot = cache.snapshot(); require(message.find("SENSITIVE") == std::string::npos && message.find("ACCESS_TOKEN_SECRET") == std::string::npos && message.find("resume-here") == std::string::npos, "later-page error leaked sensitive state"); require(snapshot.events.size() == 1 && snapshot.checkpoints.size() == 1 && snapshot.checkpoints[0].cursor == next && !snapshot.checkpoints[0].complete, "first page and resume cursor were not committed before later failure"); const std::string final = "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=finished"; ScriptedTransport resumed; resumed.steps.push_back( {json_response("{\"value\":[],\"@odata.deltaLink\":\"" + final + "\"}")}); MicrosoftGraphSync resumed_graph(resumed, cache); resumed_graph.sync_primary_calendar_window(credentials(), window); require(resumed.requests.size() == 1 && resumed.requests[0].url == next, "sync did not resume from the committed next cursor"); SyncCache no_primary_cache(temporary.database("no-primary.db")); seed_account(no_primary_cache); const CachedCalendar secondary{calendar_id("secondary"), "account-local", "secondary", "Secondary", "", false, true, "{\"isDefaultCalendar\":false}"}; no_primary_cache.replace_calendars_after_complete_listing( "account-local", {&secondary, 1}); ScriptedTransport no_network; MicrosoftGraphSync no_primary_graph(no_network, no_primary_cache); expect_graph_error( [&] { no_primary_graph.sync_primary_calendar_window(credentials(), window); }); require(no_network.requests.empty(), "missing primary calendar caused a network request"); } void test_invalid_inputs_continuations_and_timestamps(const TemporaryDirectory& temporary) { SyncCache cache(temporary.database("invalid.db")); seed_primary(cache); const auto baseline = cache.snapshot(); ScriptedTransport transport; MicrosoftGraphSync graph(transport, cache); const MicrosoftGraphWindow valid{ epoch_microseconds(2026y / January / 1), epoch_microseconds(2026y / January / 2)}; const std::vector invalid_credentials = { {"", "token"}, {"account", "bad\r\ntoken"}, {"account", " "}}; for (const MicrosoftGraphCredentials& invalid : invalid_credentials) { expect_graph_error([&] { graph.sync_primary_calendar_window(invalid, valid); }); } expect_graph_error([&] { graph.sync_primary_calendar_window(credentials(), {valid.start_epoch_microseconds, valid.start_epoch_microseconds}); }); require(transport.requests.empty() && cache.snapshot() == baseline, "invalid credentials/window caused side effects"); ScriptedTransport bad_timestamp; bad_timestamp.steps.push_back({json_response( "{\"value\":[{\"id\":\"SENSITIVE_EVENT_ID\",\"isAllDay\":false," "\"start\":{\"dateTime\":\"2026-02-30T10:00:00\",\"timeZone\":\"UTC\"}," "\"end\":{\"dateTime\":\"2026-03-01T10:00:00\",\"timeZone\":\"UTC\"}}]," "\"@odata.deltaLink\":" "\"https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=x\"}")}); MicrosoftGraphSync bad_timestamp_graph(bad_timestamp, cache); const std::string timestamp_message = expect_graph_error( [&] { bad_timestamp_graph.sync_primary_calendar_window(credentials(), valid); }); require(timestamp_message.find("SENSITIVE") == std::string::npos && cache.snapshot() == baseline, "invalid timestamp leaked or committed event data"); } } // namespace int main() { try { TemporaryDirectory temporary; test_profile_mapping_headers_and_atomic_validation(temporary); test_paged_listing_atomicity_primary_and_stable_ids(temporary); test_delta_mapping_incremental_tombstone_and_routes(temporary); test_page_resume_and_primary_only_failure(temporary); test_invalid_inputs_continuations_and_timestamps(temporary); } catch (const std::exception& error) { std::cerr << "Microsoft Graph tests failed: " << error.what() << '\n'; return 1; } std::cout << "Microsoft Graph tests passed\n"; return 0; }