diff --git a/README.md b/README.md index 8e8afb6..fcd811b 100644 --- a/README.md +++ b/README.md @@ -123,10 +123,11 @@ conflict records are reserved for later write synchronization. The provider boundary models Microsoft sign-in as a public desktop client using the system browser, the OAuth authorization-code flow, PKCE with `S256`, and a loopback redirect. Nocal's application registration must be multitenant, -with the intended organizational/personal Microsoft account audience, and the -first read-only synchronization will request only delegated `Calendars.Read`. -This permission normally supports user consent, but an organization's tenant -policy can still require administrator consent or block user consent. An +with the intended organizational/personal Microsoft account audience. The +read-only adapter requests delegated `User.Read` for `/me` plus +`Calendars.Read` for calendar and event reads. Neither delegated permission +normally requires administrator consent, but an organization's tenant policy +can still require approval or block user consent. An open-source desktop executable cannot keep a client secret, so none is embedded or expected. @@ -160,8 +161,25 @@ cancelled, and storage failures remain distinct errors and never fall back to another storage channel. Nocal does not mark an account connected until the complete secret has been stored successfully. -This remains foundation, not working Office 365 integration. There is still no -Microsoft Graph call, account UI, or usable account setup. The next slice is a -Microsoft adapter that calls `/me`, discovers -calendars, and performs read-only delta synchronization with delegated -`Calendars.Read`. +The Microsoft Graph read phase calls `/me` and discovers every calendar through +the complete paged listing, replacing the +cached calendar set atomically only after every page succeeds. Provider/account +identity produces stable deterministic local IDs. Event requests prefer +immutable Graph IDs, parse strictly within fixed bounds, and keep credentials +out of errors. Access tokens are supplied per operation; Secret Service remains +the only persistent token store. + +All Graph requests and opaque next/delta links must use HTTPS on +`graph.microsoft.com`. Primary-calendar event windows are half-open UTC ranges. +Each page, its tombstones, and its next or delta cursor commit atomically, so an +interrupted round resumes without advancing past durable data. + +Stable Graph v1.0 documents `calendarView` delta only for the primary calendar. +Nocal therefore discovers all calendars but delta-syncs only the primary one; +it deliberately avoids beta and undocumented per-calendar routes. Secondary +calendars require a product decision between v1 full-window reconciliation with +explicit deletion semantics and a beta-specific delta implementation before +Outlook-equivalent coverage can be claimed. There is still no account UI/CLI +orchestration or live Microsoft credential integration, and remote writes +remain out of scope. The next phase is account setup and orchestration plus that +secondary-calendar decision. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 470f097..e04d091 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -240,7 +240,48 @@ read/write/delete failure remains an explicit failure; none triggers plaintext fallback or partial connected state. An account becomes connected only after the complete versioned payload has been stored successfully. -Graph requests, account UI, and usable account setup remain absent. After this -boundary passes verification, the Microsoft adapter will call `/me`, discover -calendars, and apply read-only delta pages through the durable cache using only -delegated `Calendars.Read`. +Graph requests, account UI, and usable account setup remain absent at this +boundary. The read adapter uses delegated `User.Read` for `/me`, then delegated +`Calendars.Read` for calendars and events. Neither permission normally requires +administrator consent, although tenant policy can require approval or prevent +user consent. + +### Microsoft Graph read boundary + +The adapter remains a public desktop, multitenant client for organizational and +personal Microsoft accounts. It takes +an access token for each operation without retaining it; persistent access and +refresh tokens remain solely in the versioned Secret Service payload. + +Identity uses `/me`. Calendar discovery consumes every page before atomically +replacing the account's cached calendar set, so a malformed or failed later page +leaves the prior complete listing intact. Local calendar IDs are deterministic +from stable provider, account, and remote identities. Event requests send the +Graph `Prefer: IdType="ImmutableId"` header so the provider identity remains +stable when an item moves within its mailbox. Nocal's calendar-scoped local +event identity remains stable for the lifetime of that cached calendar mapping. + +JSON parsing is strict and bounded for identity, calendars, events, and paging +metadata. Error construction excludes authorization headers, access tokens, and +credential-bearing response material. Requests use only HTTPS +`graph.microsoft.com` URLs. Returned `@odata.nextLink` and `@odata.deltaLink` +values are opaque continuation capabilities: Nocal validates their scheme and +host but does not reconstruct or reinterpret their query parameters. + +Event synchronization uses half-open UTC windows. Each page's normalized +events, deletion tombstones, and opaque next/delta cursor commit in one cache +transaction. A partial round is therefore durable and resumable, while a cursor +can never advance beyond its corresponding event changes. + +Stable Microsoft Graph v1.0 documents `calendarView` delta only for the primary +calendar. The adapter discovers all calendars but delta-syncs events only from +the primary calendar. It does not call beta or an undocumented per-calendar +delta route. Secondary calendars remain an explicit architecture decision: +either reconcile complete v1 calendar-view windows with well-defined deletion +semantics, or accept a beta-specific delta dependency. That choice must be made +and tested before Nocal claims Outlook-equivalent calendar coverage. + +There is no account UI or CLI orchestration and no live Microsoft credential +integration yet; tests use injected transports and credentials. Remote writes +remain out of scope. Account setup and orchestration plus the secondary-calendar +reconciliation decision are next. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index aef65b8..f2b8be9 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -169,11 +169,35 @@ parsing, and storage failures are reported distinctly, with no plaintext fallback and no partially connected account. Connection state is published only after the complete secret store succeeds. -This phase still does not make Office 365 usable. It contains no Microsoft Graph -call, account UI, or usable account setup. After verification, the next product -slice calls `/me`, discovers calendars, and performs read-only Graph delta -synchronization using delegated `Calendars.Read`. Remote writes remain gated on -the durability and conflict paths being exercised end to end. +The Microsoft Graph read phase keeps Nocal a public desktop multitenant +application for organizational and personal +Microsoft accounts. It requests delegated `User.Read` for `/me` and delegated +`Calendars.Read` for calendars and events. Neither normally requires +administrator consent, although tenant policy may require approval or block +user consent. Access tokens are passed only to the operation that needs them; +Secret Service retains the persistent token set. + +Calendar discovery reads every page and publishes an atomic replacement only +after the full listing succeeds. Stable provider/account identity gives each +calendar a deterministic local ID. Event reads request immutable Graph IDs and +use strict bounded parsing with credential-safe failures. Requests and opaque +next/delta links are restricted to HTTPS `graph.microsoft.com` URLs. + +Event windows are half-open UTC intervals. Each event page, its deletions, and +its continuation cursor cross the cache boundary in one transaction. A partial +round can resume from its durable cursor, and deletion tombstones remain +explicit instead of silently dropping remote state. + +The stable Graph v1.0 `calendarView` delta API is documented only for the +primary calendar. This slice discovers all calendars but delta-syncs only the +primary; it uses neither beta nor undocumented per-calendar routes. Secondary +calendar support requires a deliberate choice between v1 full-window +reconciliation with defined deletion semantics and beta-specific delta. Nocal +does not yet claim Outlook-equivalent coverage. + +There is still no account UI/CLI orchestration or live Microsoft credential +integration, and remote writes remain out of scope. Account setup/orchestration +and the secondary-calendar reconciliation decision are the next product phase. ## Local file interchange diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f0d9c7c..7e9011f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -70,11 +70,26 @@ Completed token/secret boundary: - OAuth secrets only in Secret Service, never SQLite, logs, CLI arguments, or environment variables +Completed Microsoft Graph read phase: + +- Public desktop multitenant organizational/personal account support +- Delegated `User.Read` for `/me` and `Calendars.Read` for calendars/events; + tenant policy may still require approval or block user consent +- Per-operation access tokens with persistent tokens retained only by Secret + Service +- Complete paged all-calendar discovery with atomic cache replacement and + deterministic local IDs +- Immutable Graph event IDs, strict bounded parsing, and credential-safe errors +- HTTPS `graph.microsoft.com`-only opaque continuation/delta links +- Half-open UTC event windows and per-page event/tombstone/cursor transactions +- Resumable primary-calendar v1 delta synchronization + Next provider-boundary slices: -- Microsoft `/me` identity and read-only calendar discovery -- Read-only Graph delta synchronization using delegated `Calendars.Read` -- Generic `SyncProvider` contract and observable sync status +- Account setup and CLI/TUI orchestration with live credential integration +- Choose secondary-calendar behavior: v1 full-window reconciliation with + deletion semantics, or an explicit beta-specific delta dependency +- Generic `SyncProvider` integration and observable sync status ## 0.3 — CalDAV @@ -89,11 +104,12 @@ Next provider-boundary slices: ETag-aware remote writes after the conflict boundary is proven - Account/calendar management UI and per-calendar ANSI identity -The cache, generic HTTP/OAuth boundary, and concrete Linux adapters are -prerequisites, not usable Microsoft 365 integration. The token/Secret -Service phase still provides no Graph call, account UI, or usable account setup. -After it passes verification, the immediate sequence is Microsoft `/me`, -read-only calendar discovery, and Graph delta synchronization. +The Graph read phase discovers all calendars but uses stable v1 delta only for +the primary calendar; it deliberately avoids beta and undocumented +per-calendar routes. No account UI/CLI orchestration or live Microsoft +credential integration exists yet, and remote writes remain out of scope. Do +not claim Outlook-equivalent coverage until the secondary-calendar strategy is +chosen and verified. ## 1.0 — distribution quality diff --git a/include/nocal/sync/microsoft_graph.hpp b/include/nocal/sync/microsoft_graph.hpp new file mode 100644 index 0000000..ce1435f --- /dev/null +++ b/include/nocal/sync/microsoft_graph.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include "nocal/storage/sync_cache.hpp" +#include "nocal/sync/http.hpp" + +#include +#include +#include + +namespace nocal::sync { + +// Per-operation credentials for Microsoft Graph. The adapter never retains the +// access token after the public operation returns. +struct MicrosoftGraphCredentials { + std::string account_id; + std::string access_token; +}; + +// A half-open UTC calendar window. Values are Unix epoch microseconds and are +// serialized canonically before they become part of a durable checkpoint. +struct MicrosoftGraphWindow { + std::int64_t start_epoch_microseconds{0}; + std::int64_t end_epoch_microseconds{0}; +}; + +class MicrosoftGraphError : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +// Read-only Microsoft Graph v1.0 synchronization. Calendar discovery covers +// every calendar returned by /me/calendars. Stable v1.0 event delta is exposed +// only for the signed-in user's primary calendar; this class deliberately does +// not call beta or undocumented per-calendar delta routes. +class MicrosoftGraphSync { +public: + MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache); + + // Requires delegated User.Read. A complete and validated /me response is + // persisted atomically; failures leave the cached account unchanged. + storage::CachedAccount refresh_account(const MicrosoftGraphCredentials& credentials); + + // Requires delegated Calendars.Read. Pagination is completed before the + // cache listing is replaced, so a partial listing never deactivates data. + void refresh_calendars(const MicrosoftGraphCredentials& credentials); + + // Requires delegated Calendars.Read. Each validated page and its opaque + // next/delta URL are committed in one cache transaction. A later-page + // failure therefore resumes from the last committed cursor. Only HTTPS + // continuations on graph.microsoft.com are accepted. + void sync_primary_calendar_window(const MicrosoftGraphCredentials& credentials, + const MicrosoftGraphWindow& window); + +private: + HttpTransport& transport_; + storage::SyncCache& cache_; +}; + +} // namespace nocal::sync diff --git a/meson.build b/meson.build index c4f7ce9..6e24d35 100644 --- a/meson.build +++ b/meson.build @@ -28,6 +28,7 @@ nocal_sources = files( 'src/storage/sync_cache.cpp', 'src/sync/curl_http.cpp', 'src/sync/desktop_oauth.cpp', + 'src/sync/microsoft_graph.cpp', 'src/sync/oauth.cpp', 'src/sync/oauth_tokens.cpp', 'src/sync/secret_store.cpp', @@ -75,6 +76,12 @@ secret_store_tests = executable('secret-store-tests', 'tests/secret_store_tests.cpp', dependencies: nocal_dep) test('secret-store', shell, args: [files('tests/run_secret_store_test.sh'), secret_store_tests]) +microsoft_graph_tests = executable('microsoft-graph-tests', + 'tests/microsoft_graph_tests.cpp', dependencies: nocal_dep) +test('microsoft-graph', microsoft_graph_tests) +microsoft_graph_security_tests = executable('microsoft-graph-security-tests', + 'tests/microsoft_graph_security_tests.cpp', dependencies: nocal_dep) +test('microsoft-graph-security', microsoft_graph_security_tests) tui_tests = executable('tui-tests', 'tests/tui_tests.cpp', dependencies: nocal_dep) test('tui', tui_tests) diff --git a/src/sync/microsoft_graph.cpp b/src/sync/microsoft_graph.cpp new file mode 100644 index 0000000..bfef6c7 --- /dev/null +++ b/src/sync/microsoft_graph.cpp @@ -0,0 +1,618 @@ +#include "nocal/sync/microsoft_graph.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace nocal::sync { +namespace { + +using namespace std::chrono; +using nocal::storage::CachedAccount; +using nocal::storage::CachedCalendar; +using nocal::storage::CachedEvent; +using nocal::storage::CachedEventInstance; +using nocal::storage::PullPage; +using nocal::storage::SyncCheckpoint; + +constexpr std::size_t maximum_response_bytes = 8U * 1024U * 1024U; +constexpr std::size_t maximum_continuation_bytes = 16U * 1024U; +constexpr std::size_t maximum_pages = 10'000U; +constexpr std::size_t maximum_values_per_page = 10'000U; +constexpr std::size_t maximum_accumulated_calendars = 100'000U; +constexpr std::size_t maximum_mapped_string_bytes = 1024U * 1024U; +constexpr std::string_view graph_v1 = "https://graph.microsoft.com/v1.0"; + +class ParseFailure final : public std::exception {}; +class DuplicateKey final : public std::exception {}; + +[[noreturn]] void fail() { throw ParseFailure{}; } + +[[nodiscard]] nlohmann::json parse_json_object(std::string_view body) { + if (body.empty() || body.size() > maximum_response_bytes) { + fail(); + } + std::vector> object_keys; + const nlohmann::json::parser_callback_t callback = + [&object_keys](int, nlohmann::json::parse_event_t event, nlohmann::json& value) { + if (event == nlohmann::json::parse_event_t::object_start) { + object_keys.emplace_back(); + } else if (event == nlohmann::json::parse_event_t::key) { + if (object_keys.empty() + || !object_keys.back().insert(value.get()).second) { + throw DuplicateKey{}; + } + } else if (event == nlohmann::json::parse_event_t::object_end) { + if (object_keys.empty()) { + throw DuplicateKey{}; + } + object_keys.pop_back(); + } + return true; + }; + try { + nlohmann::json parsed = + nlohmann::json::parse(body.begin(), body.end(), callback, true, false); + if (!object_keys.empty() || !parsed.is_object()) { + fail(); + } + return parsed; + } catch (const DuplicateKey&) { + fail(); + } catch (const nlohmann::json::exception&) { + fail(); + } +} + +[[nodiscard]] std::string required_string( + const nlohmann::json& object, std::string_view field, bool nonempty = true) { + const auto found = object.find(std::string(field)); + if (found == object.end() || !found->is_string()) { + fail(); + } + const std::string value = found->get(); + if ((nonempty && value.empty()) || value.size() > maximum_mapped_string_bytes) { + fail(); + } + return value; +} + +[[nodiscard]] std::string optional_string( + const nlohmann::json& object, std::string_view field) { + const auto found = object.find(std::string(field)); + if (found == object.end() || found->is_null()) { + return {}; + } + if (!found->is_string()) { + fail(); + } + const std::string value = found->get(); + if (value.size() > maximum_mapped_string_bytes) { + fail(); + } + return value; +} + +[[nodiscard]] bool required_bool(const nlohmann::json& object, std::string_view field) { + const auto found = object.find(std::string(field)); + if (found == object.end() || !found->is_boolean()) { + fail(); + } + return found->get(); +} + +[[nodiscard]] const nlohmann::json& required_array( + const nlohmann::json& object, std::string_view field) { + const auto found = object.find(std::string(field)); + if (found == object.end() || !found->is_array() + || found->size() > maximum_values_per_page) { + fail(); + } + return *found; +} + +void validate_credentials(const MicrosoftGraphCredentials& credentials) { + const auto unsafe = [](std::string_view value) { + const bool blank = value.empty() + || std::all_of(value.begin(), value.end(), [](unsigned char character) { + return character == ' ' || character == '\t' || character == '\r' + || character == '\n' || character == '\f' || character == '\v'; + }); + return blank || value.find_first_of("\r\n\0", 0, 3) != std::string_view::npos; + }; + if (unsafe(credentials.account_id) || unsafe(credentials.access_token)) { + fail(); + } +} + +[[nodiscard]] HttpRequest graph_get( + std::string url, const MicrosoftGraphCredentials& credentials) { + return {HttpMethod::get, std::move(url), + {{"Authorization", "Bearer " + credentials.access_token}, {"Accept", "application/json"}, + {"Content-Type", "application/json"}, + {"Prefer", "outlook.timezone=\"UTC\", IdType=\"ImmutableId\""}}, + {}}; +} + +[[nodiscard]] HttpResponse send_graph(HttpTransport& transport, std::string url, + const MicrosoftGraphCredentials& credentials) { + HttpResponse response = transport.send(graph_get(std::move(url), credentials)); + if (response.status < 200 || response.status >= 300) { + fail(); + } + if (response.body.size() > maximum_response_bytes) { + fail(); + } + return response; +} + +enum class ContinuationRoute { calendars, delta }; + +void validate_continuation(std::string_view url, ContinuationRoute route) { + const std::string_view required_prefix = route == ContinuationRoute::calendars + ? "https://graph.microsoft.com/v1.0/me/calendars" + : "https://graph.microsoft.com/v1.0/me/calendarView/delta"; + if (url.empty() || url.size() > maximum_continuation_bytes + || !url.starts_with(required_prefix) + || (url.size() > required_prefix.size() + && url[required_prefix.size()] != '?') + || url.find('#') != std::string_view::npos + || std::any_of(url.begin(), url.end(), [](unsigned char character) { + return character <= 0x20U || character == 0x7fU; + })) { + fail(); + } +} + +[[nodiscard]] std::string optional_next_link( + const nlohmann::json& object, ContinuationRoute route) { + const auto found = object.find("@odata.nextLink"); + if (found == object.end()) { + return {}; + } + if (!found->is_string()) { + fail(); + } + const std::string value = found->get(); + validate_continuation(value, route); + return value; +} + +[[nodiscard]] std::string sha256_hex(std::string_view input) { + std::array digest{}; + unsigned int size = 0; + if (EVP_Digest(input.data(), input.size(), digest.data(), &size, EVP_sha256(), nullptr) != 1 + || size != digest.size()) { + fail(); + } + static constexpr char hexadecimal[] = "0123456789abcdef"; + std::string output; + output.reserve(digest.size() * 2U); + for (const unsigned char byte : digest) { + output.push_back(hexadecimal[byte >> 4U]); + output.push_back(hexadecimal[byte & 0x0fU]); + } + return output; +} + +[[nodiscard]] std::string stable_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) + sha256_hex(input); +} + +[[nodiscard]] std::string calendar_id( + std::string_view account_id, std::string_view remote_id) { + return stable_id("graph-calendar-", "calendar", account_id, remote_id); +} + +[[nodiscard]] std::string event_id( + std::string_view local_calendar_id, std::string_view remote_id) { + return stable_id("graph-event-", "event", local_calendar_id, remote_id); +} + +[[nodiscard]] std::string instance_id(std::string_view local_event_id) { + return stable_id("graph-instance-", "instance", local_event_id); +} + +[[nodiscard]] bool supported_epoch(std::int64_t value) { + const auto minimum = duration_cast(sys_days{year{1} / January / 1}.time_since_epoch()) + .count(); + const auto maximum = + duration_cast(sys_days{year{10000} / January / 1}.time_since_epoch()).count(); + return value >= minimum && value < maximum; +} + +[[nodiscard]] std::string canonical_utc(std::int64_t epoch_microseconds) { + if (!supported_epoch(epoch_microseconds)) { + fail(); + } + const sys_time value{microseconds{epoch_microseconds}}; + const sys_days day_point = floor(value); + const year_month_day date{day_point}; + const hh_mm_ss time{value - day_point}; + std::array buffer{}; + const int length = std::snprintf(buffer.data(), buffer.size(), "%04d-%02u-%02uT%02lld:%02lld:%02lld.%06lldZ", + static_cast(date.year()), static_cast(date.month()), + static_cast(date.day()), static_cast(time.hours().count()), + static_cast(time.minutes().count()), + static_cast(time.seconds().count()), + static_cast(time.subseconds().count())); + if (length <= 0 || static_cast(length) >= buffer.size()) { + fail(); + } + return {buffer.data(), static_cast(length)}; +} + +[[nodiscard]] std::string percent_encode(std::string_view value) { + static constexpr char hexadecimal[] = "0123456789ABCDEF"; + std::string output; + for (const unsigned char character : value) { + const bool unreserved = (character >= 'A' && character <= 'Z') + || (character >= 'a' && character <= 'z') + || (character >= '0' && character <= '9') || character == '-' || character == '.' + || character == '_' || character == '~'; + if (unreserved) { + output.push_back(static_cast(character)); + } else { + output.push_back('%'); + output.push_back(hexadecimal[character >> 4U]); + output.push_back(hexadecimal[character & 0x0fU]); + } + } + return output; +} + +[[nodiscard]] int parse_digits(std::string_view value, std::size_t offset, std::size_t count) { + if (offset + count > value.size()) { + fail(); + } + int result = 0; + const char* first = value.data() + offset; + const char* last = first + count; + const auto [end, error] = std::from_chars(first, last, result); + if (error != std::errc{} || end != last) { + fail(); + } + return result; +} + +[[nodiscard]] std::int64_t parse_graph_datetime(const nlohmann::json& parent, + std::string_view field) { + const auto found = parent.find(std::string(field)); + if (found == parent.end() || !found->is_object()) { + fail(); + } + const std::string text = required_string(*found, "dateTime"); + const std::string zone = optional_string(*found, "timeZone"); + if (text.size() < 19 || text[4] != '-' || text[7] != '-' || text[10] != 'T' + || text[13] != ':' || text[16] != ':') { + fail(); + } + const int year_value = parse_digits(text, 0, 4); + const int month_value = parse_digits(text, 5, 2); + const int day_value = parse_digits(text, 8, 2); + const int hour_value = parse_digits(text, 11, 2); + const int minute_value = parse_digits(text, 14, 2); + const int second_value = parse_digits(text, 17, 2); + const year_month_day date{year{year_value}, month{static_cast(month_value)}, + day{static_cast(day_value)}}; + if (year_value < 1 || year_value > 9999 || !date.ok() || hour_value > 23 + || minute_value > 59 || second_value > 59) { + fail(); + } + + std::size_t offset = 19; + std::int64_t fractional_microseconds = 0; + if (offset < text.size() && text[offset] == '.') { + ++offset; + const std::size_t start = offset; + while (offset < text.size() && text[offset] >= '0' && text[offset] <= '9') { + ++offset; + } + const std::size_t digits = offset - start; + if (digits == 0 || digits > 7) { + fail(); + } + for (std::size_t index = 0; index < std::min(digits, 6); ++index) { + fractional_microseconds = fractional_microseconds * 10 + (text[start + index] - '0'); + } + for (std::size_t index = digits; index < 6; ++index) { + fractional_microseconds *= 10; + } + } + + int offset_minutes = 0; + bool has_suffix = false; + if (offset < text.size() && text[offset] == 'Z' && offset + 1 == text.size()) { + has_suffix = true; + } else if (offset < text.size() && (text[offset] == '+' || text[offset] == '-')) { + if (offset + 6 != text.size() || text[offset + 3] != ':') { + fail(); + } + const int offset_hours = parse_digits(text, offset + 1, 2); + const int offset_minute_part = parse_digits(text, offset + 4, 2); + if (offset_hours > 23 || offset_minute_part > 59) { + fail(); + } + offset_minutes = offset_hours * 60 + offset_minute_part; + if (text[offset] == '-') { + offset_minutes = -offset_minutes; + } + has_suffix = true; + } else if (offset != text.size()) { + fail(); + } + if (!has_suffix && zone != "UTC") { + fail(); + } + + const sys_time local = sys_days{date} + hours{hour_value} + + minutes{minute_value} + seconds{second_value} + microseconds{fractional_microseconds}; + const sys_time utc = local - minutes{offset_minutes}; + const std::int64_t result = utc.time_since_epoch().count(); + if (!supported_epoch(result)) { + fail(); + } + return result; +} + +[[nodiscard]] std::string raw_json(const nlohmann::json& object) { + if (!object.is_object()) { + fail(); + } + const std::string raw = object.dump(); + if (raw.empty() || raw.size() > maximum_response_bytes) { + fail(); + } + return raw; +} + +[[nodiscard]] std::string calendar_color(const nlohmann::json& object) { + const std::string hexadecimal = optional_string(object, "hexColor"); + return hexadecimal.empty() ? optional_string(object, "color") : hexadecimal; +} + +[[nodiscard]] bool raw_calendar_is_primary(std::string_view raw) { + const nlohmann::json object = parse_json_object(raw); + return required_bool(object, "isDefaultCalendar"); +} + +[[nodiscard]] CachedCalendar primary_calendar( + const nocal::storage::CacheSnapshot& snapshot, std::string_view account_id) { + std::vector primary; + for (const CachedCalendar& calendar : snapshot.calendars) { + if (calendar.account_id == account_id && calendar.active + && raw_calendar_is_primary(calendar.raw_payload)) { + primary.push_back(calendar); + } + } + if (primary.size() != 1) { + fail(); + } + return primary.front(); +} + +[[nodiscard]] SyncCheckpoint checkpoint_for(const nocal::storage::CacheSnapshot& snapshot, + std::string_view calendar, std::string_view window_start, std::string_view window_end) { + for (const SyncCheckpoint& checkpoint : snapshot.checkpoints) { + if (checkpoint.calendar_id == calendar && checkpoint.window_start == window_start + && checkpoint.window_end == window_end) { + return checkpoint; + } + } + return {}; +} + +[[nodiscard]] CachedEvent parse_delta_event(const nlohmann::json& object, + std::string_view local_calendar_id, std::vector& instances) { + if (!object.is_object()) { + fail(); + } + const std::string remote_id = required_string(object, "id"); + const std::string local_event_id = event_id(local_calendar_id, remote_id); + const auto removed = object.find("@removed"); + if (removed != object.end()) { + if (!removed->is_object()) { + fail(); + } + return {local_event_id, std::string(local_calendar_id), remote_id, {}, {}, {}, {}, true}; + } + + const std::int64_t start = parse_graph_datetime(object, "start"); + const std::int64_t end = parse_graph_datetime(object, "end"); + if (end <= start) { + fail(); + } + const bool all_day = required_bool(object, "isAllDay"); + std::string location; + const auto location_field = object.find("location"); + if (location_field != object.end() && !location_field->is_null()) { + if (!location_field->is_object()) { + fail(); + } + location = optional_string(*location_field, "displayName"); + } + CachedEvent event{local_event_id, std::string(local_calendar_id), remote_id, + optional_string(object, "iCalUId"), optional_string(object, "@odata.etag"), + optional_string(object, "changeKey"), raw_json(object), false}; + instances.push_back({instance_id(local_event_id), local_event_id, start, end, all_day, + optional_string(object, "subject"), std::move(location), + optional_string(object, "bodyPreview"), "UTC"}); + return event; +} + +struct DeltaContinuation { + std::string cursor; + bool complete{false}; +}; + +[[nodiscard]] DeltaContinuation delta_continuation(const nlohmann::json& object) { + const auto next = object.find("@odata.nextLink"); + const auto delta = object.find("@odata.deltaLink"); + if ((next == object.end()) == (delta == object.end())) { + fail(); + } + const nlohmann::json& selected = next != object.end() ? *next : *delta; + if (!selected.is_string()) { + fail(); + } + const std::string cursor = selected.get(); + validate_continuation(cursor, ContinuationRoute::delta); + return {cursor, delta != object.end()}; +} + +} // namespace + +MicrosoftGraphSync::MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache) + : transport_(transport), cache_(cache) {} + +CachedAccount MicrosoftGraphSync::refresh_account( + const MicrosoftGraphCredentials& credentials) { + try { + validate_credentials(credentials); + const nlohmann::json object = + parse_json_object(send_graph(transport_, std::string(graph_v1) + "/me", credentials).body); + const std::string remote_subject = required_string(object, "id"); + std::string display_name = optional_string(object, "displayName"); + if (display_name.empty()) { + display_name = required_string(object, "userPrincipalName"); + } + CachedAccount account{ + credentials.account_id, "microsoft-graph", remote_subject, std::move(display_name)}; + cache_.upsert_account(account); + return account; + } catch (...) { + throw MicrosoftGraphError("Microsoft Graph account refresh failed"); + } +} + +void MicrosoftGraphSync::refresh_calendars(const MicrosoftGraphCredentials& credentials) { + try { + validate_credentials(credentials); + std::vector calendars; + std::unordered_set remote_ids; + std::unordered_set local_ids; + std::unordered_set cursors; + std::string url = std::string(graph_v1) + "/me/calendars"; + std::size_t primary_count = 0; + for (std::size_t page = 0; page < maximum_pages; ++page) { + if (!cursors.insert(url).second) { + fail(); + } + const nlohmann::json object = + parse_json_object(send_graph(transport_, url, credentials).body); + const nlohmann::json& values = required_array(object, "value"); + for (const nlohmann::json& value : values) { + if (!value.is_object()) { + fail(); + } + const std::string remote_id = required_string(value, "id"); + const std::string local_id = calendar_id(credentials.account_id, remote_id); + if (!remote_ids.insert(remote_id).second || !local_ids.insert(local_id).second) { + fail(); + } + const bool primary = required_bool(value, "isDefaultCalendar"); + primary_count += primary ? 1U : 0U; + if (primary_count > 1) { + fail(); + } + calendars.push_back({local_id, credentials.account_id, remote_id, + required_string(value, "name", false), calendar_color(value), + !required_bool(value, "canEdit"), true, raw_json(value)}); + if (calendars.size() > maximum_accumulated_calendars) { + fail(); + } + } + if (object.contains("@odata.deltaLink")) { + fail(); + } + const std::string next = optional_next_link(object, ContinuationRoute::calendars); + if (next.empty()) { + cache_.replace_calendars_after_complete_listing(credentials.account_id, calendars); + return; + } + if (cursors.contains(next)) { + fail(); + } + url = next; + } + fail(); + } catch (...) { + throw MicrosoftGraphError("Microsoft Graph calendar refresh failed"); + } +} + +void MicrosoftGraphSync::sync_primary_calendar_window( + const MicrosoftGraphCredentials& credentials, const MicrosoftGraphWindow& window) { + try { + validate_credentials(credentials); + if (window.end_epoch_microseconds <= window.start_epoch_microseconds) { + fail(); + } + const std::string window_start = canonical_utc(window.start_epoch_microseconds); + const std::string window_end = canonical_utc(window.end_epoch_microseconds); + const nocal::storage::CacheSnapshot snapshot = cache_.snapshot(); + const CachedCalendar calendar = primary_calendar(snapshot, credentials.account_id); + const SyncCheckpoint existing = + checkpoint_for(snapshot, calendar.id, window_start, window_end); + std::string url; + if (!existing.cursor.empty()) { + validate_continuation(existing.cursor, ContinuationRoute::delta); + url = existing.cursor; + } else { + url = std::string(graph_v1) + "/me/calendarView/delta?startDateTime=" + + percent_encode(window_start) + "&endDateTime=" + percent_encode(window_end); + } + + std::unordered_set cursors; + for (std::size_t page = 0; page < maximum_pages; ++page) { + if (!cursors.insert(url).second) { + fail(); + } + const nlohmann::json object = + parse_json_object(send_graph(transport_, url, credentials).body); + const nlohmann::json& values = required_array(object, "value"); + PullPage pull; + for (const nlohmann::json& value : values) { + pull.events.push_back(parse_delta_event(value, calendar.id, pull.instances)); + } + const DeltaContinuation continuation = delta_continuation(object); + if (!continuation.complete && cursors.contains(continuation.cursor)) { + fail(); + } + pull.checkpoint = {calendar.id, window_start, window_end, continuation.cursor, + continuation.complete}; + cache_.apply_pull_page(pull); + if (continuation.complete) { + return; + } + url = continuation.cursor; + } + fail(); + } catch (...) { + throw MicrosoftGraphError("Microsoft Graph calendar synchronization failed"); + } +} + +} // namespace nocal::sync diff --git a/tests/microsoft_graph_security_tests.cpp b/tests/microsoft_graph_security_tests.cpp new file mode 100644 index 0000000..44fdb43 --- /dev/null +++ b/tests/microsoft_graph_security_tests.cpp @@ -0,0 +1,696 @@ +#include "nocal/sync/microsoft_graph.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +using namespace std::chrono; +using nocal::storage::CacheSnapshot; +using nocal::storage::CachedCalendar; +using nocal::storage::CachedEvent; +using nocal::storage::PullPage; +using nocal::storage::SyncCache; +using nocal::storage::SyncCheckpoint; +using nocal::sync::HttpHeader; +using nocal::sync::HttpMethod; +using nocal::sync::HttpRequest; +using nocal::sync::HttpResponse; +using nocal::sync::HttpTransport; +using nocal::sync::MicrosoftGraphCredentials; +using nocal::sync::MicrosoftGraphSync; +using nocal::sync::MicrosoftGraphWindow; + +constexpr std::string_view graph_prefix = "https://graph.microsoft.com/"; +constexpr std::string_view primary_remote_id = "remote-primary-calendar"; +constexpr std::string_view primary_local_prefix = "graph-calendar-"; +constexpr std::string_view event_local_prefix = "graph-event-"; + +void require(bool condition, std::string_view message) { + if (!condition) { + throw std::runtime_error(std::string{message}); + } +} + +template +std::string require_throws_redacted(Function&& function, std::string_view message, + std::span sensitive = {}) { + try { + function(); + } catch (const std::exception& error) { + const std::string description = error.what(); + for (const auto value : sensitive) { + require(value.empty() || description.find(value) == std::string::npos, + std::string{message}.append(": sensitive data leaked in exception")); + } + return description; + } + throw std::runtime_error(std::string{message}.append(": no exception")); +} + +class TempDirectory { +public: + TempDirectory() { + std::string pattern = + (std::filesystem::temp_directory_path() / "nocal-graph-security.XXXXXX").string(); + std::vector writable(pattern.begin(), pattern.end()); + writable.push_back('\0'); + const char* created = ::mkdtemp(writable.data()); + if (created == nullptr) { + throw std::runtime_error("mkdtemp failed"); + } + path_ = created; + } + + TempDirectory(const TempDirectory&) = delete; + TempDirectory& operator=(const TempDirectory&) = delete; + ~TempDirectory() { + std::error_code ignored; + std::filesystem::remove_all(path_, ignored); + } + + [[nodiscard]] const std::filesystem::path& path() const noexcept { return path_; } + +private: + std::filesystem::path path_; +}; + +struct ScriptStep { + HttpResponse response; + std::optional exception; +}; + +class ScriptedTransport final : public HttpTransport { +public: + HttpResponse send(const HttpRequest& request) override { + requests.push_back(request); + if (position >= steps.size()) { + throw std::runtime_error("unscripted Graph request"); + } + ScriptStep& step = steps[position++]; + if (step.exception) { + throw std::runtime_error(*step.exception); + } + return step.response; + } + + void respond(std::string body, int status = 200) { + steps.push_back({HttpResponse{status, {}, std::move(body)}, std::nullopt}); + } + + void fail(std::string description) { + steps.push_back({HttpResponse{}, std::move(description)}); + } + + std::vector steps; + std::vector requests; + std::size_t position{0}; +}; + +struct Rig { + explicit Rig(const std::filesystem::path& root) + : cache(root / ("graph-" + std::to_string(sequence++) + ".db")), graph(transport, cache) {} + + static inline int sequence = 0; + ScriptedTransport transport; + SyncCache cache; + MicrosoftGraphSync graph; + MicrosoftGraphCredentials credentials{"local-account", "SENSITIVE_ACCESS_TOKEN"}; +}; + +[[nodiscard]] std::string profile_body( + std::string_view remote_subject = "remote-user", std::string_view display = "Alice") { + return nlohmann::json{{"id", remote_subject}, {"displayName", display}, + {"userPrincipalName", "alice@example.test"}} + .dump(); +} + +[[nodiscard]] nlohmann::json calendar_json(std::string_view id, std::string_view name, + bool primary, bool can_edit = true) { + return {{"id", id}, {"name", name}, {"color", "auto"}, {"hexColor", "#123456"}, + {"canEdit", can_edit}, {"isDefaultCalendar", primary}}; +} + +[[nodiscard]] std::string calendar_page(const nlohmann::json& calendars, + std::optional next = std::nullopt) { + nlohmann::json page{{"value", calendars}}; + if (next) { + page["@odata.nextLink"] = *next; + } + return page.dump(); +} + +[[nodiscard]] nlohmann::json live_event(std::string_view id = "remote-event-1", + std::string_view start = "2026-07-18T09:00:00.1234567", + std::string_view end = "2026-07-18T10:00:00.1234567", + std::string_view zone = "UTC") { + return {{"id", id}, {"iCalUId", "uid-1"}, {"subject", "Planning"}, + {"@odata.etag", "etag-1"}, {"changeKey", "change-1"}, {"isAllDay", false}, + {"start", {{"dateTime", start}, {"timeZone", zone}}}, + {"end", {{"dateTime", end}, {"timeZone", zone}}}, + {"location", {{"displayName", "Room 1"}}}, {"bodyPreview", "Agenda"}}; +} + +[[nodiscard]] std::string delta_page(const nlohmann::json& events, + std::optional next, std::optional delta) { + nlohmann::json page{{"value", events}}; + if (next) { + page["@odata.nextLink"] = *next; + } + if (delta) { + page["@odata.deltaLink"] = *delta; + } + return page.dump(); +} + +[[nodiscard]] MicrosoftGraphWindow july_window() { + const auto start = sys_days{year{2026} / July / 18}; + return {duration_cast(start.time_since_epoch()).count(), + duration_cast((start + days{1}).time_since_epoch()).count()}; +} + +[[nodiscard]] const HttpHeader* header(const HttpRequest& request, std::string_view name) { + const auto found = std::ranges::find_if(request.headers, [&](const HttpHeader& value) { + if (value.name.size() != name.size()) { + return false; + } + for (std::size_t index = 0; index < name.size(); ++index) { + const auto lower = [](unsigned char character) { + return character >= 'A' && character <= 'Z' + ? static_cast(character + ('a' - 'A')) + : character; + }; + if (lower(static_cast(value.name[index])) + != lower(static_cast(name[index]))) { + return false; + } + } + return true; + }); + return found == request.headers.end() ? nullptr : &*found; +} + +void require_safe_request(const HttpRequest& request, std::string_view token) { + require(request.method == HttpMethod::get, "Graph read adapter sent a non-GET request"); + require(request.body.empty(), "Graph GET request unexpectedly contains a body"); + const HttpHeader* authorization = header(request, "Authorization"); + require(authorization != nullptr && authorization->value == "Bearer " + std::string{token}, + "Graph request is missing the exact bearer credential"); + require(request.url.starts_with(graph_prefix), "Graph request left the trusted origin"); + require(request.url.find("/beta/") == std::string::npos, + "Graph adapter called the beta API"); +} + +void seed_account(Rig& rig) { + rig.transport.respond(profile_body()); + static_cast(rig.graph.refresh_account(rig.credentials)); +} + +void seed_calendars(Rig& rig, bool include_other = true) { + seed_account(rig); + nlohmann::json values = nlohmann::json::array( + {calendar_json(primary_remote_id, "Primary", true)}); + if (include_other) { + values.push_back(calendar_json("remote-other-calendar", "Other", false, false)); + } + rig.transport.respond(calendar_page(values)); + rig.graph.refresh_calendars(rig.credentials); +} + +[[nodiscard]] std::string sha256_hex(std::string_view value) { + std::array digest{}; + unsigned int size = 0; + require(EVP_Digest(value.data(), value.size(), digest.data(), &size, EVP_sha256(), nullptr) == 1 + && size == digest.size(), + "could not hash Graph local identifier fixture"); + constexpr char hexadecimal[] = "0123456789abcdef"; + std::string result; + result.reserve(digest.size() * 2); + for (const unsigned char byte : digest) { + result.push_back(hexadecimal[byte >> 4U]); + result.push_back(hexadecimal[byte & 0x0fU]); + } + return result; +} + +[[nodiscard]] std::string local_calendar_id( + std::string_view account, std::string_view remote) { + std::string input{"calendar", 8}; + input.push_back('\0'); + input += account; + input.push_back('\0'); + input += remote; + return std::string{primary_local_prefix} + sha256_hex(input); +} + +[[nodiscard]] std::string local_event_id( + std::string_view calendar_id, std::string_view remote) { + std::string input{"event", 5}; + input.push_back('\0'); + input += calendar_id; + input.push_back('\0'); + input += remote; + return std::string{event_local_prefix} + sha256_hex(input); +} + +void test_credentials_and_request_security(const std::filesystem::path& root) { + const std::vector invalid{ + {"", "token"}, {"bad\naccount", "token"}, {"SENSITIVE_ACCOUNT_ID_123", ""}, + {"SENSITIVE_ACCOUNT_ID_123", " "}, + {"SENSITIVE_ACCOUNT_ID_123", "token\r\nInjected: yes"}}; + for (const auto& credentials : invalid) { + Rig rig{root}; + const CacheSnapshot before = rig.cache.snapshot(); + const std::array sensitive{ + credentials.account_id, credentials.access_token}; + require_throws_redacted( + [&] { static_cast(rig.graph.refresh_account(credentials)); }, + "invalid Graph credentials were accepted", sensitive); + require(rig.transport.requests.empty(), "invalid credentials reached the transport"); + require(rig.cache.snapshot() == before, "invalid credentials changed the cache"); + } + + Rig rig{root}; + rig.transport.respond(profile_body()); + const auto account = rig.graph.refresh_account(rig.credentials); + require(account.id == rig.credentials.account_id && account.provider == "microsoft-graph" + && account.remote_subject == "remote-user" && account.display_name == "Alice", + "valid profile did not map to the frozen account contract"); + require(rig.transport.requests.size() == 1, "profile refresh sent the wrong request count"); + require_safe_request(rig.transport.requests[0], rig.credentials.access_token); + + rig.transport.respond(profile_body("remote-user", "")); + const auto fallback = rig.graph.refresh_account(rig.credentials); + require(fallback.display_name == "alice@example.test", + "empty displayName did not fall back to userPrincipalName"); +} + +void test_account_failures_are_atomic_and_redacted(const std::filesystem::path& root) { + Rig rig{root}; + rig.transport.respond(profile_body()); + static_cast(rig.graph.refresh_account(rig.credentials)); + const CacheSnapshot baseline = rig.cache.snapshot(); + + const std::string response_secret = "SENSITIVE_PROFILE_BODY"; + rig.transport.respond(response_secret, 401); + const std::array response_sensitive{ + rig.credentials.access_token, response_secret}; + require_throws_redacted([&] { static_cast(rig.graph.refresh_account(rig.credentials)); }, + "non-2xx profile response was accepted", response_sensitive); + require(rig.cache.snapshot() == baseline, "non-2xx profile response mutated account cache"); + + rig.transport.fail("transport detail " + rig.credentials.access_token); + const std::array token_sensitive{rig.credentials.access_token}; + require_throws_redacted([&] { static_cast(rig.graph.refresh_account(rig.credentials)); }, + "profile transport failure did not propagate safely", token_sensitive); + require(rig.cache.snapshot() == baseline, "profile transport failure mutated account cache"); + + const std::vector malformed{"{", "[]", R"({"id":"x"})", + R"({"id":"first","id":"second","displayName":"name"})", + std::string(8U * 1024U * 1024U + 1U, 'x')}; + for (const auto& body : malformed) { + rig.transport.respond(body); + const std::array sensitive{body, rig.credentials.access_token}; + require_throws_redacted( + [&] { static_cast(rig.graph.refresh_account(rig.credentials)); }, + "malformed profile body was accepted", sensitive); + require(rig.cache.snapshot() == baseline, "malformed profile body mutated account cache"); + } +} + +void test_calendar_listing_atomicity_and_duplicates(const std::filesystem::path& root) { + Rig rig{root}; + seed_calendars(rig); + const CacheSnapshot baseline = rig.cache.snapshot(); + const std::string next = + "https://graph.microsoft.com/v1.0/me/calendars?$skiptoken=partial-cursor"; + rig.transport.respond(calendar_page( + nlohmann::json::array({calendar_json(primary_remote_id, "Changed", true)}), next)); + rig.transport.fail("later calendar page failed SENSITIVE_ACCESS_TOKEN"); + const std::array sensitive{ + rig.credentials.access_token, next, primary_remote_id}; + require_throws_redacted([&] { rig.graph.refresh_calendars(rig.credentials); }, + "partial calendar listing was accepted", sensitive); + require(rig.cache.snapshot() == baseline, + "partial calendar listing changed or deactivated cached calendars"); + + const std::vector invalid_pages{ + calendar_page(nlohmann::json::array( + {calendar_json("duplicate", "One", true), + calendar_json("duplicate", "Two", false)})), + calendar_page(nlohmann::json::array( + {calendar_json("primary-a", "One", true), + calendar_json("primary-b", "Two", true)})), + R"({"value":{},"@odata.nextLink":null})", + R"({"value":[],"value":[]})", + "{", + std::string(8U * 1024U * 1024U + 1U, 'x')}; + for (const auto& body : invalid_pages) { + rig.transport.respond(body); + const std::array page_sensitive{body, rig.credentials.access_token}; + require_throws_redacted([&] { rig.graph.refresh_calendars(rig.credentials); }, + "invalid calendar listing was accepted", page_sensitive); + require(rig.cache.snapshot() == baseline, "invalid calendar listing mutated cache"); + } +} + +void test_continuation_url_rejection(const std::filesystem::path& root) { + const std::vector unsafe{ + "http://graph.microsoft.com/v1.0/next", + "https://evil.example.test/v1.0/next", + "https://graph.microsoft.com.evil.test/v1.0/next", + "https://user@graph.microsoft.com/v1.0/next", + "https://graph.microsoft.com:443/v1.0/next", + "https://graph.microsoft.com/v1.0/next#fragment", + "https://graph.microsoft.com/v1.0/next\nInjected: yes", + std::string{"https://graph.microsoft.com/"} + std::string(16U * 1024U, 'x')}; + for (const auto& url : unsafe) { + Rig rig{root}; + seed_calendars(rig); + const std::size_t requests_before = rig.transport.requests.size(); + const CacheSnapshot baseline = rig.cache.snapshot(); + rig.transport.respond(calendar_page(nlohmann::json::array(), url)); + const std::array sensitive{ + url, rig.credentials.access_token, primary_remote_id}; + require_throws_redacted([&] { rig.graph.refresh_calendars(rig.credentials); }, + "unsafe Graph continuation was accepted", sensitive); + require(rig.transport.requests.size() == requests_before + 1, + "unsafe continuation reached a credentialed request"); + require(rig.cache.snapshot() == baseline, "unsafe continuation mutated cache"); + } + + Rig rig{root}; + seed_calendars(rig); + const std::string repeated = + "https://graph.microsoft.com/v1.0/me/calendars?$skiptoken=repeated"; + const std::size_t before = rig.transport.requests.size(); + rig.transport.respond(calendar_page(nlohmann::json::array(), repeated)); + rig.transport.respond(calendar_page(nlohmann::json::array(), repeated)); + const std::array sensitive{repeated, rig.credentials.access_token}; + require_throws_redacted([&] { rig.graph.refresh_calendars(rig.credentials); }, + "repeated Graph continuation was accepted", sensitive); + require(rig.transport.requests.size() == before + 2, + "repeated continuation triggered another credentialed request"); +} + +void test_window_and_primary_preconditions(const std::filesystem::path& root) { + const std::vector invalid{{0, 0}, {1, 0}, + {std::numeric_limits::min(), 0}, + {0, std::numeric_limits::max()}}; + for (const auto window : invalid) { + Rig rig{root}; + seed_calendars(rig); + const std::size_t before = rig.transport.requests.size(); + require_throws_redacted( + [&] { rig.graph.sync_primary_calendar_window(rig.credentials, window); }, + "invalid Graph window was accepted"); + require(rig.transport.requests.size() == before, + "invalid Graph window reached the transport"); + } + + Rig no_primary{root}; + seed_account(no_primary); + no_primary.transport.respond(calendar_page( + nlohmann::json::array({calendar_json("not-primary", "Other", false)}))); + no_primary.graph.refresh_calendars(no_primary.credentials); + const std::size_t before = no_primary.transport.requests.size(); + require_throws_redacted( + [&] { no_primary.graph.sync_primary_calendar_window( + no_primary.credentials, july_window()); }, + "sync without a primary calendar was accepted"); + require(no_primary.transport.requests.size() == before, + "missing-primary sync reached the transport"); +} + +void test_delta_request_and_strict_page_shape(const std::filesystem::path& root) { + Rig rig{root}; + seed_calendars(rig); + const CacheSnapshot baseline = rig.cache.snapshot(); + const std::vector invalid{ + R"({"value":[]})", + R"({"value":[],"@odata.nextLink":"https://graph.microsoft.com/v1.0/next","@odata.deltaLink":"https://graph.microsoft.com/v1.0/delta"})", + R"({"value":{},"@odata.deltaLink":"https://graph.microsoft.com/v1.0/delta"})", + R"({"value":[],"value":[],"@odata.deltaLink":"https://graph.microsoft.com/v1.0/delta"})", + "{", std::string(8U * 1024U * 1024U + 1U, 'x')}; + for (const auto& body : invalid) { + rig.transport.respond(body); + const std::array sensitive{body, rig.credentials.access_token}; + require_throws_redacted( + [&] { rig.graph.sync_primary_calendar_window(rig.credentials, july_window()); }, + "invalid delta page was accepted", sensitive); + require(rig.cache.snapshot() == baseline, + "invalid delta page advanced cursor or changed cache"); + } + + const std::string delta = "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=done"; + rig.transport.respond(delta_page(nlohmann::json::array(), std::nullopt, delta)); + const std::size_t request_index = rig.transport.requests.size(); + rig.graph.sync_primary_calendar_window(rig.credentials, july_window()); + const HttpRequest& request = rig.transport.requests[request_index]; + require_safe_request(request, rig.credentials.access_token); + require(request.url.find("/v1.0/me/calendarView/delta?") != std::string::npos, + "initial sync did not use stable primary calendarView/delta"); + require(request.url.find("startDateTime=2026-07-18T00%3A00%3A00.000000Z") + != std::string::npos + && request.url.find("endDateTime=2026-07-19T00%3A00%3A00.000000Z") + != std::string::npos, + "initial delta window was not canonically encoded"); + require(request.url.find("/beta/") == std::string::npos + && request.url.find("/calendars/") == std::string::npos, + "adapter used beta or undocumented per-calendar delta"); + const HttpHeader* prefer = header(request, "Prefer"); + require(prefer != nullptr && prefer->value.find("UTC") != std::string::npos, + "delta request is missing its UTC Prefer header"); +} + +void test_delta_continuation_security(const std::filesystem::path& root) { + { + Rig rig{root}; + seed_calendars(rig); + const CacheSnapshot baseline = rig.cache.snapshot(); + const std::string unsafe = "https://evil.example.test/v1.0/delta?SENSITIVE_CURSOR"; + const std::string body = delta_page( + nlohmann::json::array({live_event("SENSITIVE_REMOTE_ID")}), unsafe, std::nullopt); + const std::size_t before = rig.transport.requests.size(); + rig.transport.respond(body); + const std::array sensitive{ + unsafe, body, "SENSITIVE_REMOTE_ID", rig.credentials.access_token}; + require_throws_redacted( + [&] { rig.graph.sync_primary_calendar_window(rig.credentials, july_window()); }, + "unsafe delta continuation was accepted", sensitive); + require(rig.transport.requests.size() == before + 1, + "unsafe delta continuation reached a credentialed request"); + require(rig.cache.snapshot() == baseline, + "page with unsafe delta continuation was committed"); + } + + { + Rig rig{root}; + seed_calendars(rig); + const std::string repeated = + "https://graph.microsoft.com/v1.0/me/calendarView/delta?$skiptoken=repeated"; + const std::size_t before = rig.transport.requests.size(); + rig.transport.respond(delta_page(nlohmann::json::array(), repeated, std::nullopt)); + rig.transport.respond(delta_page(nlohmann::json::array(), repeated, std::nullopt)); + const std::array sensitive{repeated, rig.credentials.access_token}; + require_throws_redacted( + [&] { rig.graph.sync_primary_calendar_window(rig.credentials, july_window()); }, + "repeated delta continuation was accepted", sensitive); + require(rig.transport.requests.size() == before + 2, + "repeated delta continuation triggered a third credentialed request"); + } +} + +void test_authoritative_timestamp_offsets(const std::filesystem::path& root) { + Rig rig{root}; + seed_calendars(rig); + const auto event = live_event("offset-event", "2026-07-18T09:00:00.1234567Z", + "2026-07-18T11:00:00.1234567+01:00", "Unknown/But-Ignored"); + rig.transport.respond(delta_page(nlohmann::json::array({event}), std::nullopt, + "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=offset")); + rig.graph.sync_primary_calendar_window(rig.credentials, july_window()); + const CacheSnapshot snapshot = rig.cache.snapshot(); + require(snapshot.instances.size() == 1, "valid explicit-offset event was not cached"); + const auto day = sys_days{year{2026} / July / 18}; + const auto expected_start = + duration_cast((day + 9h).time_since_epoch()).count() + 123'456; + const auto expected_end = + duration_cast((day + 10h).time_since_epoch()).count() + 123'456; + require(snapshot.instances[0].start_epoch_microseconds == expected_start + && snapshot.instances[0].end_epoch_microseconds == expected_end, + "explicit offset authority or seventh-digit truncation is incorrect"); +} + +void test_timestamp_identity_and_interval_rejection(const std::filesystem::path& root) { + const std::vector invalid_events{ + live_event("unknown-zone", "2026-07-18T09:00:00", "2026-07-18T10:00:00", + "Europe/London"), + live_event("invalid-date", "2026-02-30T09:00:00", "2026-02-30T10:00:00"), + live_event("leap-second", "2026-07-18T09:00:60Z", "2026-07-18T10:00:00Z", + "Ignored"), + live_event("bad-offset", "2026-07-18T09:00:00+25:00", + "2026-07-18T10:00:00+25:00", "Ignored"), + live_event("backwards", "2026-07-18T10:00:00", "2026-07-18T09:00:00"), + }; + for (const auto& event : invalid_events) { + Rig rig{root}; + seed_calendars(rig); + const CacheSnapshot baseline = rig.cache.snapshot(); + const std::string delta = + "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=invalid"; + const std::string body = + delta_page(nlohmann::json::array({event}), std::nullopt, delta); + rig.transport.respond(body); + const std::array sensitive{ + body, event.at("id").get_ref(), rig.credentials.access_token}; + require_throws_redacted( + [&] { rig.graph.sync_primary_calendar_window(rig.credentials, july_window()); }, + "invalid Graph event timestamp was accepted", sensitive); + require(rig.cache.snapshot() == baseline, + "invalid Graph event advanced cursor or changed cache"); + } + + Rig duplicate{root}; + seed_calendars(duplicate); + const CacheSnapshot baseline = duplicate.cache.snapshot(); + duplicate.transport.respond(delta_page( + nlohmann::json::array({live_event("duplicate"), live_event("duplicate")}), + std::nullopt, + "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=duplicate")); + require_throws_redacted( + [&] { duplicate.graph.sync_primary_calendar_window( + duplicate.credentials, july_window()); }, + "duplicate Graph event identity was accepted"); + require(duplicate.cache.snapshot() == baseline, + "duplicate Graph event advanced cursor or changed cache"); +} + +void test_resume_after_later_failure(const std::filesystem::path& root) { + Rig rig{root}; + seed_calendars(rig); + const std::string next = "https://graph.microsoft.com/v1.0/me/calendarView/delta?$skiptoken=page-2"; + rig.transport.respond( + delta_page(nlohmann::json::array({live_event()}), next, std::nullopt)); + rig.transport.fail("later transport failed " + next + " remote-event-1 SENSITIVE_ACCESS_TOKEN"); + const std::size_t start_request = rig.transport.requests.size(); + const std::array sensitive{ + next, "remote-event-1", rig.credentials.access_token}; + require_throws_redacted( + [&] { rig.graph.sync_primary_calendar_window(rig.credentials, july_window()); }, + "later delta transport failure was accepted", sensitive); + const CacheSnapshot committed = rig.cache.snapshot(); + require(committed.events.size() == 1 && committed.instances.size() == 1 + && committed.checkpoints.size() == 1 && committed.checkpoints[0].cursor == next + && !committed.checkpoints[0].complete, + "validated first delta page was not committed atomically before later failure"); + require(rig.transport.requests.size() == start_request + 2, + "paged delta failure sent an unexpected request count"); + + const std::string final = + "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=complete"; + rig.transport.respond(delta_page(nlohmann::json::array(), std::nullopt, final)); + const std::size_t resume_index = rig.transport.requests.size(); + rig.graph.sync_primary_calendar_window(rig.credentials, july_window()); + require(rig.transport.requests[resume_index].url == next, + "later sync did not resume from the last committed opaque cursor"); + require(rig.cache.snapshot().checkpoints[0].cursor == final + && rig.cache.snapshot().checkpoints[0].complete, + "resumed delta did not commit its final cursor"); +} + +void test_tombstone_preserves_payload(const std::filesystem::path& root) { + Rig rig{root}; + seed_calendars(rig); + const std::string first_delta = + "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=first"; + rig.transport.respond( + delta_page(nlohmann::json::array({live_event()}), std::nullopt, first_delta)); + rig.graph.sync_primary_calendar_window(rig.credentials, july_window()); + const std::string raw = rig.cache.snapshot().events[0].raw_payload; + + const std::string second_delta = + "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=second"; + const nlohmann::json removed = + {{"id", "remote-event-1"}, {"@removed", {{"reason", "deleted"}}}}; + rig.transport.respond( + delta_page(nlohmann::json::array({removed}), std::nullopt, second_delta)); + rig.graph.sync_primary_calendar_window(rig.credentials, july_window()); + const CacheSnapshot snapshot = rig.cache.snapshot(); + require(snapshot.events.size() == 1 && snapshot.events[0].deleted + && snapshot.events[0].raw_payload == raw && snapshot.instances.empty(), + "Graph tombstone did not retain raw payload and remove instances"); +} + +void test_cache_failure_does_not_advance_cursor(const std::filesystem::path& root) { + Rig rig{root}; + seed_calendars(rig, false); + const std::string calendar_id = + local_calendar_id(rig.credentials.account_id, primary_remote_id); + const std::string poisoned_id = local_event_id(calendar_id, "target-remote-event"); + const std::string prior = + "https://graph.microsoft.com/v1.0/me/calendarView/delta?$skiptoken=prior"; + CachedEvent poisoned{poisoned_id, calendar_id, "different-remote-id", "uid", "", "", + R"({"poisoned":true})", false}; + rig.cache.apply_pull_page(PullPage{{poisoned}, {}, + SyncCheckpoint{calendar_id, "2026-07-18T00:00:00.000000Z", + "2026-07-19T00:00:00.000000Z", prior, false}}); + const CacheSnapshot baseline = rig.cache.snapshot(); + + const std::string next = + "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=must-not-commit"; + const std::string body = delta_page( + nlohmann::json::array({live_event("target-remote-event")}), std::nullopt, next); + rig.transport.respond(body); + const std::array sensitive{ + prior, next, "target-remote-event", rig.credentials.access_token}; + require_throws_redacted( + [&] { rig.graph.sync_primary_calendar_window(rig.credentials, july_window()); }, + "Graph cache identity failure was accepted", sensitive); + require(rig.cache.snapshot() == baseline, + "cache failure advanced Graph cursor or partially replaced event state"); +} + +} // namespace + +int main() { + try { + TempDirectory temporary; + test_credentials_and_request_security(temporary.path()); + test_account_failures_are_atomic_and_redacted(temporary.path()); + test_calendar_listing_atomicity_and_duplicates(temporary.path()); + test_continuation_url_rejection(temporary.path()); + test_window_and_primary_preconditions(temporary.path()); + test_delta_request_and_strict_page_shape(temporary.path()); + test_delta_continuation_security(temporary.path()); + test_authoritative_timestamp_offsets(temporary.path()); + test_timestamp_identity_and_interval_rejection(temporary.path()); + test_resume_after_later_failure(temporary.path()); + test_tombstone_preserves_payload(temporary.path()); + test_cache_failure_does_not_advance_cursor(temporary.path()); + } catch (const std::exception& error) { + std::cerr << "Microsoft Graph security test failure: " << error.what() << '\n'; + return 1; + } + std::cout << "Microsoft Graph security tests passed\n"; + return 0; +} diff --git a/tests/microsoft_graph_tests.cpp b/tests/microsoft_graph_tests.cpp new file mode 100644 index 0000000..9bfb61a --- /dev/null +++ b/tests/microsoft_graph_tests.cpp @@ -0,0 +1,452 @@ +#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; +}