From 98e07e287ea580a8139f4c84724d4b2dabaa8901 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Sat, 18 Jul 2026 13:05:21 +0100 Subject: [PATCH] feat(sync): reconcile secondary Graph calendars Read documented Graph v1 calendarView pages for secondary calendars and publish each finite window only after the complete bounded listing validates. Add schema-v2 per-window event membership so moved-out occurrences are evicted without being mislabeled as remote tombstones, while overlapping windows retain shared instances. Verified: 18/18 fresh normal, 18/18 -Werror, and 18/18 ASan/UBSan tests. --- README.md | 28 +- docs/ARCHITECTURE.md | 36 +- docs/PRODUCT.md | 27 +- docs/ROADMAP.md | 22 +- include/nocal/storage/sync_cache.hpp | 18 +- include/nocal/sync/microsoft_graph.hpp | 12 +- src/storage/sync_cache.cpp | 201 +++++++++- src/sync/microsoft_graph.cpp | 108 ++++++ tests/microsoft_graph_security_tests.cpp | 444 +++++++++++++++++++++++ tests/microsoft_graph_tests.cpp | 393 ++++++++++++++++++++ tests/sync_cache_tests.cpp | 291 ++++++++++++++- 11 files changed, 1520 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index fcd811b..dd3463b 100644 --- a/README.md +++ b/README.md @@ -169,17 +169,19 @@ 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. +All Graph requests and opaque paging/delta links must use HTTPS on +`graph.microsoft.com`. Event windows are half-open UTC ranges. Primary-calendar +delta pages commit their changes and cursor 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. +Secondary-calendar reconciliation through stable Graph v1.0 reads the +documented per-calendar `calendarView`, finishes every page, and then atomically +replaces durable membership for that complete window. Events omitted from one +window lose cached instances only after no other complete window references +them; omission is not mislabeled as a remote deletion tombstone. This avoids +beta and undocumented delta routes, at the cost of refetching each secondary +window instead of incrementally updating it. + +There is still no account UI/CLI orchestration or live Microsoft credential +integration, and remote writes remain out of scope. Account setup and +orchestration are next. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e04d091..f59d005 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -177,8 +177,9 @@ Microsoft authentication is designed for a public desktop client. It uses the system browser and authorization-code flow, a fresh PKCE verifier and `S256` challenge, and a loopback redirect. The Entra application registration is multitenant and selects the intended organizational and personal Microsoft -account audience. The initial read-only Graph scope is the delegated -`Calendars.Read` permission. It normally permits user consent, while tenant +account audience. The read-only Graph scopes are delegated `User.Read` for +identity and `Calendars.Read` for calendar data. They normally permit user +consent, while tenant consent policy remains authoritative and may require administrator approval or block consent. A desktop binary distributed as open source is not a confidential client and must not embed or depend on a client secret. @@ -268,20 +269,25 @@ credential-bearing response material. Requests use only HTTPS 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. +Event synchronization uses half-open UTC windows. For the primary calendar, +each delta page's normalized events, deletion tombstones, and opaque cursor +commit in one cache transaction. A partial round is durable and resumable, and +the cursor cannot advance beyond its corresponding 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. +Secondary-calendar reconciliation through stable Microsoft Graph v1.0 uses the +documented per-calendar `calendarView` route, accumulates every page within +fixed total bounds, and publishes only a completely validated listing. Schema +version 2 tracks durable event membership by calendar and window. Replacing one +window upserts its returned events and removes instances for omitted events only +when no other complete window still references them. The event envelope remains +live and its raw provider payload is retained, because absence can mean that an +event moved outside the requested range rather than that it was deleted +remotely. + +This design avoids beta and undocumented per-calendar delta routes. Its +tradeoff is bandwidth: every secondary window is fetched completely on each +refresh, while the primary calendar remains incremental. 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. +remain out of scope. Account setup and orchestration are next. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index f2b8be9..0321ee7 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -133,8 +133,9 @@ The Microsoft sign-in design treats Nocal as a public desktop client: sign-in will open the system browser, use the authorization-code flow with PKCE `S256`, and return through a loopback redirect. Its application registration must be multitenant for the intended organizational and personal Microsoft account -audience. Initial synchronization requests only delegated `Calendars.Read`, not -write access. Users can normally consent to that delegated permission, but +audience. Initial synchronization requests delegated `User.Read` and +`Calendars.Read`, not write access. Users can normally consent to those +delegated permissions, but their organization's tenant policy may still require administrator consent or deny the application. Nocal never embeds a client secret in its open-source desktop binary. @@ -183,21 +184,21 @@ 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. +Event windows are half-open UTC intervals. Primary-calendar delta pages cross +the cache boundary with their deletions and continuation cursor in one +transaction, so a partial round resumes safely and tombstones remain explicit. -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. +Stable-v1 secondary-calendar reconciliation reads every page of a complete +per-calendar view before publishing the window. Durable per-window membership +distinguishes an event that moved outside the range from an event Graph actually +deleted: an omitted event loses its cached instance only when no other complete +window references it, while its raw event record remains live. This avoids beta +and undocumented delta routes, but a secondary window requires a complete +refetch on every refresh. 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. +is the next product phase. ## Local file interchange diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7e9011f..fc37981 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -84,11 +84,18 @@ Completed Microsoft Graph read phase: - Half-open UTC event windows and per-page event/tombstone/cursor transactions - Resumable primary-calendar v1 delta synchronization +Completed secondary-calendar stable-v1 reconciliation: + +- Documented per-calendar `calendarView` reads with complete bounded paging +- Schema-v2 durable per-window event membership +- Atomic complete-window replacement after every page validates +- Range omission distinguished from a remote deletion tombstone +- Overlapping windows retain events until their last membership is removed +- No beta or undocumented per-calendar delta dependency + Next provider-boundary slices: - 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 @@ -104,12 +111,11 @@ Next provider-boundary slices: ETag-aware remote writes after the conflict boundary is proven - Account/calendar management UI and per-calendar ANSI identity -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. +The Graph read boundary uses stable v1 delta for the primary calendar and +complete stable-v1 calendar views for secondary calendars; it deliberately +avoids beta and undocumented per-calendar delta routes. No account UI/CLI +orchestration or live Microsoft credential integration exists yet, and remote +writes remain out of scope. ## 1.0 — distribution quality diff --git a/include/nocal/storage/sync_cache.hpp b/include/nocal/storage/sync_cache.hpp index f83be2e..338ad22 100644 --- a/include/nocal/storage/sync_cache.hpp +++ b/include/nocal/storage/sync_cache.hpp @@ -76,6 +76,16 @@ struct PullPage { bool operator==(const PullPage&) const = default; }; +// A provider's complete view of one finite calendar window. Membership is +// replaced only after the provider listing has completed successfully. +struct CompleteWindow { + std::vector events; + std::vector instances; + SyncCheckpoint checkpoint; + + bool operator==(const CompleteWindow&) const = default; +}; + struct CacheSnapshot { std::vector accounts; std::vector calendars; @@ -88,7 +98,7 @@ struct CacheSnapshot { class SyncCache { public: - static constexpr int current_schema_version = 1; + static constexpr int current_schema_version = 2; explicit SyncCache(std::filesystem::path path); ~SyncCache(); @@ -112,6 +122,12 @@ public: // events retain their last raw payload and have no instances. void apply_pull_page(const PullPage& page); + // Atomically replaces membership for one complete, finite provider window. + // Returned events become live and have their instances replaced. Events + // omitted from the window lose cached instances only when no other complete + // window references them; omission is not treated as a remote tombstone. + void replace_complete_window(const CompleteWindow& window); + [[nodiscard]] CacheSnapshot snapshot() const; private: diff --git a/include/nocal/sync/microsoft_graph.hpp b/include/nocal/sync/microsoft_graph.hpp index ce1435f..adc77de 100644 --- a/include/nocal/sync/microsoft_graph.hpp +++ b/include/nocal/sync/microsoft_graph.hpp @@ -29,9 +29,9 @@ public: }; // 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. +// every calendar returned by /me/calendars. Stable v1.0 event delta is used for +// the primary calendar and complete calendarView reconciliation for secondary +// calendars; this class does not call beta or undocumented delta routes. class MicrosoftGraphSync { public: MicrosoftGraphSync(HttpTransport& transport, storage::SyncCache& cache); @@ -51,6 +51,12 @@ public: void sync_primary_calendar_window(const MicrosoftGraphCredentials& credentials, const MicrosoftGraphWindow& window); + // Reads every page of the documented Graph v1.0 calendarView for one + // active, non-primary calendar. The cache window is replaced only after the + // listing completes, so a failed or malformed page leaves it unchanged. + void sync_secondary_calendar_window(const MicrosoftGraphCredentials& credentials, + std::string calendar_id, const MicrosoftGraphWindow& window); + private: HttpTransport& transport_; storage::SyncCache& cache_; diff --git a/src/storage/sync_cache.cpp b/src/storage/sync_cache.cpp index 9bf4986..4b49047 100644 --- a/src/storage/sync_cache.cpp +++ b/src/storage/sync_cache.cpp @@ -326,6 +326,22 @@ CREATE TABLE conflicts ( ) STRICT; )sql"; +constexpr const char* schema_v2 = R"sql( +CREATE UNIQUE INDEX events_id_calendar_id_unique_idx ON events(id, calendar_id); +CREATE TABLE event_window_membership ( + calendar_id TEXT NOT NULL, + window_start TEXT NOT NULL CHECK(length(window_start) > 0), + window_end TEXT NOT NULL CHECK(length(window_end) > 0), + event_id TEXT NOT NULL CHECK(length(event_id) > 0), + PRIMARY KEY(calendar_id, window_start, window_end, event_id), + FOREIGN KEY(calendar_id) REFERENCES calendars(id) + ON UPDATE RESTRICT ON DELETE CASCADE, + FOREIGN KEY(event_id, calendar_id) REFERENCES events(id, calendar_id) + ON UPDATE RESTRICT ON DELETE CASCADE +) STRICT; +CREATE INDEX event_window_membership_event_id_idx ON event_window_membership(event_id); +)sql"; + void validate_nonempty(std::string_view value, std::string_view field) { if (value.empty()) { throw std::invalid_argument(std::string(field) + " must not be empty"); @@ -430,13 +446,20 @@ struct SyncCache::Impl { } execute(database, "PRAGMA foreign_keys = ON", "enabling foreign keys"); require_pragma_integer(database, "PRAGMA foreign_keys", 1, "enabling foreign keys"); - if (version == 0) { + if (version < SyncCache::current_schema_version) { Transaction migration(database); - const int locked_version = user_version(database); + int locked_version = user_version(database); if (locked_version == 0) { execute(database, schema_v1, "creating schema version 1"); execute(database, "PRAGMA user_version = 1", "recording schema version 1"); - } else if (locked_version > SyncCache::current_schema_version) { + locked_version = 1; + } + if (locked_version == 1) { + execute(database, schema_v2, "creating schema version 2"); + execute(database, "PRAGMA user_version = 2", "recording schema version 2"); + locked_version = 2; + } + if (locked_version > SyncCache::current_schema_version) { throw std::runtime_error("sync cache schema changed to an unsupported version"); } migration.commit(); @@ -675,6 +698,178 @@ ON CONFLICT(calendar_id, window_start, window_end) DO UPDATE SET transaction.commit(); } +void SyncCache::replace_complete_window(const CompleteWindow& window) { + const SyncCheckpoint& checkpoint = window.checkpoint; + validate_nonempty(checkpoint.calendar_id, "checkpoint calendar id"); + validate_nonempty(checkpoint.window_start, "checkpoint window start"); + validate_nonempty(checkpoint.window_end, "checkpoint window end"); + validate_nonempty(checkpoint.cursor, "checkpoint cursor"); + if (!checkpoint.complete) { + throw std::invalid_argument("complete window checkpoint must be complete"); + } + + std::unordered_set event_ids; + std::unordered_set event_remote_ids; + for (const CachedEvent& event : window.events) { + validate_nonempty(event.id, "event id"); + validate_nonempty(event.remote_id, "event remote id"); + validate_nonempty(event.raw_payload, "event raw payload"); + if (event.calendar_id != checkpoint.calendar_id) { + throw std::invalid_argument("complete-window event belongs to a different calendar"); + } + if (event.deleted) { + throw std::invalid_argument("complete window cannot contain a deleted event"); + } + if (!event_ids.insert(event.id).second + || !event_remote_ids.insert(event.remote_id).second) { + throw std::invalid_argument("complete window contains duplicate event identities"); + } + } + + std::unordered_set instance_ids; + for (const CachedEventInstance& instance : window.instances) { + validate_nonempty(instance.id, "event instance id"); + validate_nonempty(instance.event_id, "event instance event id"); + if (instance.start_epoch_microseconds > instance.end_epoch_microseconds + || (instance.all_day + && instance.start_epoch_microseconds == instance.end_epoch_microseconds)) { + throw std::invalid_argument("event instance must have a valid half-open interval"); + } + if (!instance_ids.insert(instance.id).second) { + throw std::invalid_argument("complete window contains duplicate event instance ids"); + } + if (!event_ids.contains(instance.event_id)) { + throw std::invalid_argument( + "event instance must refer to an event returned in the complete window"); + } + } + + std::scoped_lock lock(impl_->mutex); + Transaction transaction(impl_->database); + require_active_calendar(impl_->database, checkpoint.calendar_id); + for (const CachedEvent& event : window.events) { + ensure_event_identity(impl_->database, event); + } + + std::vector previous_members; + { + Statement query(impl_->database, R"sql( +SELECT event_id FROM event_window_membership +WHERE calendar_id = ? AND window_start = ? AND window_end = ? +ORDER BY event_id +)sql", "reading prior complete-window membership"); + query.bind_text(1, checkpoint.calendar_id); + query.bind_text(2, checkpoint.window_start); + query.bind_text(3, checkpoint.window_end); + while (query.next()) { + previous_members.push_back(query.text_column(0)); + } + } + + for (const CachedEvent& event : window.events) { + Statement upsert(impl_->database, R"sql( +INSERT INTO events( + id, calendar_id, remote_id, uid, etag, change_key, raw_payload, deleted) +VALUES(?, ?, ?, ?, ?, ?, ?, 0) +ON CONFLICT(id) DO UPDATE SET + uid = excluded.uid, + etag = excluded.etag, + change_key = excluded.change_key, + raw_payload = excluded.raw_payload, + deleted = 0 +)sql", "upserting complete-window event"); + upsert.bind_text(1, event.id); + upsert.bind_text(2, event.calendar_id); + upsert.bind_text(3, event.remote_id); + upsert.bind_text(4, event.uid); + upsert.bind_text(5, event.etag); + upsert.bind_text(6, event.change_key); + upsert.bind_text(7, event.raw_payload); + upsert.run(); + } + for (const CachedEvent& event : window.events) { + Statement remove(impl_->database, + "DELETE FROM event_instances WHERE event_id = ?", + "replacing complete-window event instances"); + remove.bind_text(1, event.id); + remove.run(); + } + for (const CachedEventInstance& instance : window.instances) { + Statement insert(impl_->database, R"sql( +INSERT INTO event_instances( + id, event_id, start_epoch_microseconds, end_epoch_microseconds, all_day, + title, location, description, time_zone) +VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) +)sql", "inserting complete-window event instance"); + insert.bind_text(1, instance.id); + insert.bind_text(2, instance.event_id); + insert.bind_int64(3, instance.start_epoch_microseconds); + insert.bind_int64(4, instance.end_epoch_microseconds); + insert.bind_bool(5, instance.all_day); + insert.bind_text(6, instance.title); + insert.bind_text(7, instance.location); + insert.bind_text(8, instance.description); + insert.bind_text(9, instance.time_zone); + insert.run(); + } + { + Statement remove(impl_->database, R"sql( +DELETE FROM event_window_membership +WHERE calendar_id = ? AND window_start = ? AND window_end = ? +)sql", "replacing complete-window membership"); + remove.bind_text(1, checkpoint.calendar_id); + remove.bind_text(2, checkpoint.window_start); + remove.bind_text(3, checkpoint.window_end); + remove.run(); + } + for (const CachedEvent& event : window.events) { + Statement insert(impl_->database, R"sql( +INSERT INTO event_window_membership(calendar_id, window_start, window_end, event_id) +VALUES(?, ?, ?, ?) +)sql", "inserting complete-window membership"); + insert.bind_text(1, checkpoint.calendar_id); + insert.bind_text(2, checkpoint.window_start); + insert.bind_text(3, checkpoint.window_end); + insert.bind_text(4, event.id); + insert.run(); + } + for (const std::string& previous_event_id : previous_members) { + if (event_ids.contains(previous_event_id)) { + continue; + } + bool retained = false; + { + Statement remaining(impl_->database, + "SELECT 1 FROM event_window_membership WHERE event_id = ? LIMIT 1", + "checking remaining complete-window membership"); + remaining.bind_text(1, previous_event_id); + retained = remaining.next(); + } + if (!retained) { + Statement remove(impl_->database, + "DELETE FROM event_instances WHERE event_id = ?", + "evicting omitted complete-window instances"); + remove.bind_text(1, previous_event_id); + remove.run(); + } + } + { + Statement upsert(impl_->database, R"sql( +INSERT INTO sync_state(calendar_id, window_start, window_end, cursor, complete) +VALUES(?, ?, ?, ?, 1) +ON CONFLICT(calendar_id, window_start, window_end) DO UPDATE SET + cursor = excluded.cursor, + complete = 1 +)sql", "recording complete-window checkpoint"); + upsert.bind_text(1, checkpoint.calendar_id); + upsert.bind_text(2, checkpoint.window_start); + upsert.bind_text(3, checkpoint.window_end); + upsert.bind_text(4, checkpoint.cursor); + upsert.run(); + } + transaction.commit(); +} + CacheSnapshot SyncCache::snapshot() const { std::scoped_lock lock(impl_->mutex); CacheSnapshot result; diff --git a/src/sync/microsoft_graph.cpp b/src/sync/microsoft_graph.cpp index bfef6c7..2ac95a5 100644 --- a/src/sync/microsoft_graph.cpp +++ b/src/sync/microsoft_graph.cpp @@ -26,6 +26,7 @@ using nocal::storage::CachedAccount; using nocal::storage::CachedCalendar; using nocal::storage::CachedEvent; using nocal::storage::CachedEventInstance; +using nocal::storage::CompleteWindow; using nocal::storage::PullPage; using nocal::storage::SyncCheckpoint; @@ -34,6 +35,8 @@ 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_complete_window_bytes = 64U * 1024U * 1024U; +constexpr std::size_t maximum_complete_window_events = 100'000U; constexpr std::size_t maximum_mapped_string_bytes = 1024U * 1024U; constexpr std::string_view graph_v1 = "https://graph.microsoft.com/v1.0"; @@ -192,6 +195,32 @@ void validate_continuation(std::string_view url, ContinuationRoute route) { return value; } +void validate_secondary_continuation( + std::string_view url, std::string_view calendar_view_route) { + if (url.empty() || url.size() > maximum_continuation_bytes + || !url.starts_with(calendar_view_route) || url.size() <= calendar_view_route.size() + || url[calendar_view_route.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 secondary_next_link( + const nlohmann::json& object, std::string_view calendar_view_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_secondary_continuation(value, calendar_view_route); + return value; +} + [[nodiscard]] std::string sha256_hex(std::string_view input) { std::array digest{}; unsigned int size = 0; @@ -413,6 +442,23 @@ void validate_continuation(std::string_view url, ContinuationRoute route) { return primary.front(); } +[[nodiscard]] CachedCalendar secondary_calendar( + const nocal::storage::CacheSnapshot& snapshot, std::string_view account_id, + std::string_view local_calendar_id) { + if (local_calendar_id.empty() + || std::any_of(local_calendar_id.begin(), local_calendar_id.end(), + [](unsigned char character) { return character <= 0x20U || character == 0x7fU; })) { + fail(); + } + const auto found = std::find_if(snapshot.calendars.begin(), snapshot.calendars.end(), + [&](const CachedCalendar& calendar) { return calendar.id == local_calendar_id; }); + if (found == snapshot.calendars.end() || found->account_id != account_id || !found->active + || raw_calendar_is_primary(found->raw_payload)) { + fail(); + } + return *found; +} + [[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) { @@ -615,4 +661,66 @@ void MicrosoftGraphSync::sync_primary_calendar_window( } } +void MicrosoftGraphSync::sync_secondary_calendar_window(const MicrosoftGraphCredentials& credentials, + std::string local_calendar_id, 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 CachedCalendar calendar = + secondary_calendar(cache_.snapshot(), credentials.account_id, local_calendar_id); + const std::string calendar_view_route = std::string(graph_v1) + "/me/calendars/" + + percent_encode(calendar.remote_id) + "/calendarView"; + std::string url = calendar_view_route + "?startDateTime=" + percent_encode(window_start) + + "&endDateTime=" + percent_encode(window_end) + "&$top=1000"; + + CompleteWindow complete; + std::unordered_set remote_ids; + std::unordered_set local_ids; + std::unordered_set cursors; + std::size_t accumulated_bytes = 0; + for (std::size_t page = 0; page < maximum_pages; ++page) { + if (!cursors.insert(url).second) { + fail(); + } + const HttpResponse response = send_graph(transport_, url, credentials); + if (response.body.size() > maximum_complete_window_bytes - accumulated_bytes) { + fail(); + } + accumulated_bytes += response.body.size(); + const nlohmann::json object = parse_json_object(response.body); + const nlohmann::json& values = required_array(object, "value"); + for (const nlohmann::json& value : values) { + CachedEvent event = parse_delta_event(value, calendar.id, complete.instances); + if (event.deleted || !remote_ids.insert(event.remote_id).second + || !local_ids.insert(event.id).second + || complete.events.size() >= maximum_complete_window_events) { + fail(); + } + complete.events.push_back(std::move(event)); + } + if (object.contains("@odata.deltaLink")) { + fail(); + } + const std::string next = secondary_next_link(object, calendar_view_route); + if (next.empty()) { + complete.checkpoint = {calendar.id, window_start, window_end, + "graph-calendar-view-v1", true}; + cache_.replace_complete_window(complete); + return; + } + if (cursors.contains(next)) { + fail(); + } + url = next; + } + fail(); + } catch (...) { + throw MicrosoftGraphError("Microsoft Graph secondary calendar synchronization failed"); + } +} + } // namespace nocal::sync diff --git a/tests/microsoft_graph_security_tests.cpp b/tests/microsoft_graph_security_tests.cpp index 44fdb43..4f23f8b 100644 --- a/tests/microsoft_graph_security_tests.cpp +++ b/tests/microsoft_graph_security_tests.cpp @@ -27,8 +27,10 @@ namespace { using namespace std::chrono; using nocal::storage::CacheSnapshot; +using nocal::storage::CachedAccount; using nocal::storage::CachedCalendar; using nocal::storage::CachedEvent; +using nocal::storage::CachedEventInstance; using nocal::storage::PullPage; using nocal::storage::SyncCache; using nocal::storage::SyncCheckpoint; @@ -43,8 +45,10 @@ 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 secondary_remote_id = "remote-secondary/id +?%"; constexpr std::string_view primary_local_prefix = "graph-calendar-"; constexpr std::string_view event_local_prefix = "graph-event-"; +constexpr std::string_view instance_local_prefix = "graph-instance-"; void require(bool condition, std::string_view message) { if (!condition) { @@ -183,12 +187,31 @@ struct Rig { return page.dump(); } +[[nodiscard]] std::string view_page(const nlohmann::json& events, + std::optional next = std::nullopt, + std::optional delta = std::nullopt) { + 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]] MicrosoftGraphWindow window_from_days( + year_month_day start, year_month_day end) { + return {duration_cast(sys_days{start}.time_since_epoch()).count(), + duration_cast(sys_days{end}.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()) { @@ -273,6 +296,70 @@ void seed_calendars(Rig& rig, bool include_other = true) { return std::string{event_local_prefix} + sha256_hex(input); } +[[nodiscard]] std::string local_instance_id(std::string_view event_id) { + std::string input{"instance", 8}; + input.push_back('\0'); + input += event_id; + return std::string{instance_local_prefix} + sha256_hex(input); +} + +[[nodiscard]] std::string percent_encode(std::string_view value) { + constexpr char hexadecimal[] = "0123456789ABCDEF"; + std::string encoded; + 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) { + encoded.push_back(static_cast(character)); + } else { + encoded.push_back('%'); + encoded.push_back(hexadecimal[character >> 4U]); + encoded.push_back(hexadecimal[character & 0x0fU]); + } + } + return encoded; +} + +[[nodiscard]] std::string secondary_view_prefix(std::string_view remote) { + return "https://graph.microsoft.com/v1.0/me/calendars/" + percent_encode(remote) + + "/calendarView"; +} + +[[nodiscard]] std::string secondary_next( + std::string_view remote, std::string_view marker = "page-2") { + return secondary_view_prefix(remote) + "?$skiptoken=" + std::string{marker}; +} + +[[nodiscard]] const CachedCalendar& calendar_with_remote( + const CacheSnapshot& snapshot, std::string_view remote) { + const auto found = std::ranges::find_if(snapshot.calendars, + [&](const CachedCalendar& calendar) { return calendar.remote_id == remote; }); + if (found == snapshot.calendars.end()) { + throw std::runtime_error("calendar fixture was not cached"); + } + return *found; +} + +[[nodiscard]] std::string seed_secondary_calendar( + Rig& rig, std::string_view remote = secondary_remote_id) { + seed_account(rig); + const nlohmann::json values = nlohmann::json::array( + {calendar_json(primary_remote_id, "Primary", true), + calendar_json(remote, "Secondary", false, false)}); + rig.transport.respond(calendar_page(values)); + rig.graph.refresh_calendars(rig.credentials); + return calendar_with_remote(rig.cache.snapshot(), remote).id; +} + +void sync_secondary_page(Rig& rig, std::string_view calendar_id, + const nlohmann::json& events, const MicrosoftGraphWindow& window = july_window()) { + rig.transport.respond(view_page(events)); + rig.graph.sync_secondary_calendar_window( + rig.credentials, std::string{calendar_id}, window); +} + void test_credentials_and_request_security(const std::filesystem::path& root) { const std::vector invalid{ {"", "token"}, {"bad\naccount", "token"}, {"SENSITIVE_ACCOUNT_ID_123", ""}, @@ -670,6 +757,356 @@ void test_cache_failure_does_not_advance_cursor(const std::filesystem::path& roo "cache failure advanced Graph cursor or partially replaced event state"); } +void test_secondary_exact_route_and_preconditions(const std::filesystem::path& root) { + { + Rig rig{root}; + const std::string secondary_id = seed_secondary_calendar(rig); + const std::size_t request_index = rig.transport.requests.size(); + sync_secondary_page(rig, secondary_id, nlohmann::json::array()); + require(rig.transport.requests.size() == request_index + 1, + "secondary full-window sync sent an unexpected request count"); + const HttpRequest& request = rig.transport.requests[request_index]; + require_safe_request(request, rig.credentials.access_token); + const std::string expected = secondary_view_prefix(secondary_remote_id) + + "?startDateTime=2026-07-18T00%3A00%3A00.000000Z&" + "endDateTime=2026-07-19T00%3A00%3A00.000000Z&$top=1000"; + require(request.url == expected, + "secondary sync did not use the exact encoded Graph v1 calendarView route"); + require(request.url.find("/beta/") == std::string::npos + && request.url.find("/delta") == std::string::npos, + "secondary sync used beta or a delta route"); + const HttpHeader* prefer = header(request, "Prefer"); + require(prefer != nullptr && prefer->value.find("UTC") != std::string::npos + && prefer->value.find("ImmutableId") != std::string::npos, + "secondary sync omitted the UTC/immutable-ID preference"); + } + + auto require_no_request = [](Rig& rig, std::string calendar_id, + std::string_view message) { + const std::size_t before_requests = rig.transport.requests.size(); + const CacheSnapshot before = rig.cache.snapshot(); + const std::array sensitive{ + rig.credentials.access_token, calendar_id}; + require_throws_redacted( + [&] { + rig.graph.sync_secondary_calendar_window( + rig.credentials, calendar_id, july_window()); + }, + message, sensitive); + require(rig.transport.requests.size() == before_requests, + std::string{message}.append(": precondition reached the transport")); + require(rig.cache.snapshot() == before, + std::string{message}.append(": precondition mutated cache")); + }; + + { + Rig rig{root}; + static_cast(seed_secondary_calendar(rig)); + const std::string primary_id = + calendar_with_remote(rig.cache.snapshot(), primary_remote_id).id; + require_no_request(rig, primary_id, "primary calendar accepted as secondary"); + require_no_request(rig, "missing-calendar", "missing secondary calendar accepted"); + } + + { + Rig rig{root}; + static_cast(seed_secondary_calendar(rig)); + const CachedAccount other{ + "other-account", "microsoft-graph", "other-subject", "Other"}; + rig.cache.upsert_account(other); + const std::string other_id = local_calendar_id(other.id, "other-remote"); + const CachedCalendar other_calendar{other_id, other.id, "other-remote", "Other", "", + true, true, calendar_json("other-remote", "Other", false, false).dump()}; + rig.cache.replace_calendars_after_complete_listing(other.id, {&other_calendar, 1}); + require_no_request(rig, other_id, "wrong-account secondary calendar accepted"); + } + + { + Rig rig{root}; + const std::string secondary_id = seed_secondary_calendar(rig); + rig.transport.respond(calendar_page( + nlohmann::json::array({calendar_json(primary_remote_id, "Primary", true)}))); + rig.graph.refresh_calendars(rig.credentials); + require_no_request(rig, secondary_id, "inactive secondary calendar accepted"); + } + + { + Rig rig{root}; + seed_account(rig); + const std::string malformed_id = + local_calendar_id(rig.credentials.account_id, "malformed-remote"); + const CachedCalendar malformed{malformed_id, rig.credentials.account_id, + "malformed-remote", "Malformed", "", true, true, "{"}; + rig.cache.replace_calendars_after_complete_listing( + rig.credentials.account_id, {&malformed, 1}); + require_no_request(rig, malformed_id, "malformed cached calendar accepted"); + } +} + +void test_secondary_partial_pages_are_atomic_and_redacted( + const std::filesystem::path& root) { + for (int failure = 0; failure < 3; ++failure) { + Rig rig{root}; + const std::string secondary_id = seed_secondary_calendar(rig); + sync_secondary_page(rig, secondary_id, + nlohmann::json::array({live_event("baseline-secondary")})); + const CacheSnapshot baseline = rig.cache.snapshot(); + const std::string next = secondary_next(secondary_remote_id); + const std::string partial_remote = "SENSITIVE_PARTIAL_REMOTE_ID"; + const std::string first_body = + view_page(nlohmann::json::array({live_event(partial_remote)}), next); + rig.transport.respond(first_body); + const std::string later_body = "SENSITIVE_LATER_PAGE_BODY"; + if (failure == 0) { + rig.transport.fail("transport leaked " + rig.credentials.access_token + " " + + std::string{secondary_remote_id} + " " + next); + } else if (failure == 1) { + rig.transport.respond(later_body, 503); + } else { + rig.transport.respond("{\"value\":["); + } + const std::size_t requests_before = rig.transport.requests.size(); + const std::string encoded_remote = percent_encode(secondary_remote_id); + const std::array sensitive{rig.credentials.access_token, + secondary_remote_id, encoded_remote, partial_remote, first_body, later_body, next}; + require_throws_redacted( + [&] { + rig.graph.sync_secondary_calendar_window( + rig.credentials, secondary_id, july_window()); + }, + "partial secondary listing failure was accepted", sensitive); + require(rig.transport.requests.size() == requests_before + 2, + "partial secondary listing failure sent the wrong request count"); + require(rig.cache.snapshot() == baseline, + "partial secondary listing changed event, membership, or checkpoint state"); + } +} + +void test_secondary_continuation_security(const std::filesystem::path& root) { + const std::string exact_prefix = secondary_view_prefix(secondary_remote_id); + const std::vector unsafe{ + "http://graph.microsoft.com/v1.0/me/calendars/" + + percent_encode(secondary_remote_id) + "/calendarView?$skiptoken=x", + "https://evil.example.test/v1.0/me/calendars/" + + percent_encode(secondary_remote_id) + "/calendarView?$skiptoken=x", + "https://user@graph.microsoft.com/v1.0/me/calendars/" + + percent_encode(secondary_remote_id) + "/calendarView?$skiptoken=x", + "https://graph.microsoft.com:443/v1.0/me/calendars/" + + percent_encode(secondary_remote_id) + "/calendarView?$skiptoken=x", + exact_prefix + "?$skiptoken=x#fragment", + exact_prefix + "?$skiptoken=x\nInjected: yes", + secondary_next("cross-calendar", "cross"), + exact_prefix + "/subpath?$skiptoken=x", + exact_prefix + "/delta?$skiptoken=x", + exact_prefix + "?$skiptoken=" + std::string(16U * 1024U, 'x')}; + + for (const std::string& next : unsafe) { + Rig rig{root}; + const std::string secondary_id = seed_secondary_calendar(rig); + const CacheSnapshot baseline = rig.cache.snapshot(); + const std::string body = view_page( + nlohmann::json::array({live_event("SENSITIVE_UNCOMMITTED_EVENT")}), next); + rig.transport.respond(body); + const std::size_t before = rig.transport.requests.size(); + const std::array sensitive{rig.credentials.access_token, + secondary_remote_id, "SENSITIVE_UNCOMMITTED_EVENT", next, body}; + require_throws_redacted( + [&] { + rig.graph.sync_secondary_calendar_window( + rig.credentials, secondary_id, july_window()); + }, + "unsafe secondary continuation was accepted", sensitive); + require(rig.transport.requests.size() == before + 1, + "unsafe secondary continuation reached a credentialed follow-up"); + require(rig.cache.snapshot() == baseline, + "unsafe secondary continuation changed cache state"); + } + + Rig repeated{root}; + const std::string secondary_id = seed_secondary_calendar(repeated); + const CacheSnapshot baseline = repeated.cache.snapshot(); + const std::string next = secondary_next(secondary_remote_id, "repeated"); + repeated.transport.respond(view_page(nlohmann::json::array(), next)); + repeated.transport.respond(view_page(nlohmann::json::array(), next)); + const std::size_t before = repeated.transport.requests.size(); + const std::array sensitive{ + repeated.credentials.access_token, secondary_remote_id, next}; + require_throws_redacted( + [&] { + repeated.graph.sync_secondary_calendar_window( + repeated.credentials, secondary_id, july_window()); + }, + "repeated secondary continuation was accepted", sensitive); + require(repeated.transport.requests.size() == before + 2, + "repeated secondary continuation triggered another credentialed follow-up"); + require(repeated.cache.snapshot() == baseline, + "repeated secondary continuation committed a partial listing"); +} + +void test_secondary_page_shape_is_strict_and_atomic(const std::filesystem::path& root) { + const std::string next = secondary_next(secondary_remote_id, "valid-next"); + const std::string other = secondary_next(secondary_remote_id, "other-link"); + const std::vector invalid{ + view_page(nlohmann::json::array(), std::nullopt, other), + view_page(nlohmann::json::array(), next, other), + R"({"value":[],"value":[]})", + R"({"value":{}})", + view_page(nlohmann::json::array( + {live_event("duplicate-secondary"), live_event("duplicate-secondary")})), + view_page(nlohmann::json::array( + {{{"id", "tombstone-secondary"}, {"@removed", {{"reason", "deleted"}}}}})), + }; + + for (const std::string& body : invalid) { + Rig rig{root}; + const std::string secondary_id = seed_secondary_calendar(rig); + sync_secondary_page(rig, secondary_id, + nlohmann::json::array({live_event("baseline-shape")})); + const CacheSnapshot baseline = rig.cache.snapshot(); + rig.transport.respond(body); + const std::array sensitive{ + rig.credentials.access_token, secondary_remote_id, body, "tombstone-secondary"}; + require_throws_redacted( + [&] { + rig.graph.sync_secondary_calendar_window( + rig.credentials, secondary_id, july_window()); + }, + "invalid complete-view page shape was accepted", sensitive); + require(rig.cache.snapshot() == baseline, + "invalid complete-view page changed event, membership, or checkpoint state"); + } +} + +void test_secondary_omission_and_overlapping_membership(const std::filesystem::path& root) { + { + Rig rig{root}; + const std::string secondary_id = seed_secondary_calendar(rig); + sync_secondary_page(rig, secondary_id, + nlohmann::json::array({live_event("omitted-secondary")})); + const CacheSnapshot present = rig.cache.snapshot(); + require(present.events.size() == 1 && present.instances.size() == 1, + "secondary complete view did not cache its event"); + + sync_secondary_page(rig, secondary_id, nlohmann::json::array()); + const CacheSnapshot omitted = rig.cache.snapshot(); + require(omitted.events.size() == 1 && !omitted.events[0].deleted + && omitted.instances.empty(), + "completed omission tombstoned the event or retained an orphan instance"); + } + + { + Rig rig{root}; + const std::string secondary_id = seed_secondary_calendar(rig); + const MicrosoftGraphWindow first = + window_from_days(year{2026} / July / 18, year{2026} / July / 21); + const MicrosoftGraphWindow second = + window_from_days(year{2026} / July / 19, year{2026} / July / 22); + const nlohmann::json shared = live_event("shared-secondary", + "2026-07-19T09:00:00.0000000", "2026-07-19T10:00:00.0000000"); + sync_secondary_page(rig, secondary_id, nlohmann::json::array({shared}), first); + sync_secondary_page(rig, secondary_id, nlohmann::json::array({shared}), second); + require(rig.cache.snapshot().instances.size() == 1, + "overlapping windows duplicated a shared event instance"); + + sync_secondary_page(rig, secondary_id, nlohmann::json::array(), first); + require(rig.cache.snapshot().instances.size() == 1, + "omission from one window evicted an instance retained by another window"); + sync_secondary_page(rig, secondary_id, nlohmann::json::array(), second); + const CacheSnapshot removed = rig.cache.snapshot(); + require(removed.instances.empty() && removed.events.size() == 1 + && !removed.events[0].deleted, + "last membership removal did not evict the instance without tombstoning"); + } +} + +void test_secondary_cache_collisions_roll_back(const std::filesystem::path& root) { + { + Rig rig{root}; + const std::string secondary_id = seed_secondary_calendar(rig); + const std::string target_remote = "target-secondary-identity"; + const std::string target_id = local_event_id(secondary_id, target_remote); + const CachedEvent poison{target_id, secondary_id, "different-secondary-remote", "", "", + "", R"({"poison":"identity"})", false}; + rig.cache.apply_pull_page(PullPage{{poison}, {}, + SyncCheckpoint{secondary_id, "2026-07-18T00:00:00.000000Z", + "2026-07-19T00:00:00.000000Z", "identity-baseline", false}}); + const CacheSnapshot baseline = rig.cache.snapshot(); + const std::string body = + view_page(nlohmann::json::array({live_event(target_remote)})); + rig.transport.respond(body); + const std::array sensitive{ + rig.credentials.access_token, secondary_remote_id, target_remote, body}; + require_throws_redacted( + [&] { + rig.graph.sync_secondary_calendar_window( + rig.credentials, secondary_id, july_window()); + }, + "secondary event identity collision was accepted", sensitive); + require(rig.cache.snapshot() == baseline, + "secondary identity collision changed snapshot or checkpoint"); + } + + { + Rig rig{root}; + const std::string secondary_id = seed_secondary_calendar(rig); + const std::string target_remote = "target-secondary-instance"; + const std::string target_event = local_event_id(secondary_id, target_remote); + const std::string poison_event = local_event_id(secondary_id, "poison-owner"); + const CachedEvent poison{poison_event, secondary_id, "poison-owner", "", "", "", + R"({"poison":"instance"})", false}; + const auto start = sys_days{year{2026} / July / 18} + 7h; + const CachedEventInstance colliding{local_instance_id(target_event), poison_event, + duration_cast(start.time_since_epoch()).count(), + duration_cast((start + 1h).time_since_epoch()).count(), false, + "Poison", "", "", "UTC"}; + rig.cache.apply_pull_page(PullPage{{poison}, {colliding}, + SyncCheckpoint{secondary_id, "2026-07-18T00:00:00.000000Z", + "2026-07-19T00:00:00.000000Z", "instance-baseline", false}}); + const CacheSnapshot baseline = rig.cache.snapshot(); + rig.transport.respond( + view_page(nlohmann::json::array({live_event(target_remote)}))); + const std::array sensitive{ + rig.credentials.access_token, secondary_remote_id, target_remote}; + require_throws_redacted( + [&] { + rig.graph.sync_secondary_calendar_window( + rig.credentials, secondary_id, july_window()); + }, + "secondary instance identity collision was accepted", sensitive); + require(rig.cache.snapshot() == baseline, + "secondary instance collision changed snapshot or checkpoint"); + } +} + +void test_primary_delta_after_secondary_full_sync(const std::filesystem::path& root) { + Rig rig{root}; + const std::string secondary_id = seed_secondary_calendar(rig); + sync_secondary_page(rig, secondary_id, + nlohmann::json::array({live_event("secondary-before-primary")})); + + const std::string delta = + "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=after-secondary"; + rig.transport.respond(delta_page( + nlohmann::json::array({live_event("primary-after-secondary")}), std::nullopt, delta)); + const std::size_t request_index = rig.transport.requests.size(); + rig.graph.sync_primary_calendar_window(rig.credentials, july_window()); + require(rig.transport.requests[request_index].url.find("/v1.0/me/calendarView/delta?") + != std::string::npos, + "primary delta did not use its documented route after secondary sync"); + require(rig.transport.requests[request_index].url.find("/calendars/") == std::string::npos, + "primary delta reused the secondary calendarView route"); + const CacheSnapshot snapshot = rig.cache.snapshot(); + require(snapshot.events.size() == 2 && snapshot.instances.size() == 2, + "primary delta after secondary sync lost one provider view"); + require(std::ranges::any_of(snapshot.checkpoints, + [&](const SyncCheckpoint& checkpoint) { + return checkpoint.calendar_id + == calendar_with_remote(snapshot, primary_remote_id).id + && checkpoint.cursor == delta && checkpoint.complete; + }), + "primary delta did not commit its checkpoint after secondary sync"); +} + } // namespace int main() { @@ -687,6 +1124,13 @@ int main() { test_resume_after_later_failure(temporary.path()); test_tombstone_preserves_payload(temporary.path()); test_cache_failure_does_not_advance_cursor(temporary.path()); + test_secondary_exact_route_and_preconditions(temporary.path()); + test_secondary_partial_pages_are_atomic_and_redacted(temporary.path()); + test_secondary_continuation_security(temporary.path()); + test_secondary_page_shape_is_strict_and_atomic(temporary.path()); + test_secondary_omission_and_overlapping_membership(temporary.path()); + test_secondary_cache_collisions_roll_back(temporary.path()); + test_primary_delta_after_secondary_full_sync(temporary.path()); } catch (const std::exception& error) { std::cerr << "Microsoft Graph security test failure: " << error.what() << '\n'; return 1; diff --git a/tests/microsoft_graph_tests.cpp b/tests/microsoft_graph_tests.cpp index 9bfb61a..74cc977 100644 --- a/tests/microsoft_graph_tests.cpp +++ b/tests/microsoft_graph_tests.cpp @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -23,6 +24,10 @@ namespace { using namespace std::chrono; using nocal::storage::CachedAccount; using nocal::storage::CachedCalendar; +using nocal::storage::CachedEvent; +using nocal::storage::CachedEventInstance; +using nocal::storage::CompleteWindow; +using nocal::storage::SyncCheckpoint; using nocal::storage::SyncCache; using nocal::sync::HttpMethod; using nocal::sync::HttpRequest; @@ -170,6 +175,52 @@ void seed_primary(SyncCache& cache, std::string remote = "primary-remote") { cache.replace_calendars_after_complete_listing("account-local", {&calendar, 1}); } +[[nodiscard]] std::string seed_secondary( + SyncCache& cache, std::string remote = "Secondary /Case?x") { + seed_account(cache); + const std::string local_id = calendar_id(remote); + const CachedCalendar calendar{local_id, "account-local", std::move(remote), "Secondary", + "#445566", false, true, "{\"isDefaultCalendar\":false}"}; + cache.replace_calendars_after_complete_listing("account-local", {&calendar, 1}); + return local_id; +} + +[[nodiscard]] std::string event_object(std::string_view id, std::string_view subject, + std::string_view start = "2026-07-18T09:00:00.1234567Z", + std::string_view end = "2026-07-18T10:00:00.1234567Z") { + return "{\"id\":\"" + std::string(id) + + "\",\"iCalUId\":\"series-uid\",\"@odata.etag\":\"etag\"," + "\"changeKey\":\"change\",\"subject\":\"" + + std::string(subject) + + "\",\"location\":{\"displayName\":\"Room\"}," + "\"bodyPreview\":\"Preview\",\"isAllDay\":false,\"type\":\"occurrence\"," + "\"start\":{\"dateTime\":\"" + + std::string(start) + + "\",\"timeZone\":\"Ignored\"},\"end\":{\"dateTime\":\"" + + std::string(end) + "\",\"timeZone\":\"Ignored\"}}"; +} + +[[nodiscard]] std::string view_page( + std::string events, std::optional next = std::nullopt) { + std::string body = "{\"value\":[" + std::move(events) + "]"; + if (next) { + body += ",\"@odata.nextLink\":\"" + *next + "\""; + } + return body + "}"; +} + +[[nodiscard]] MicrosoftGraphWindow july_window() { + return {epoch_microseconds(2026y / July / 1), epoch_microseconds(2026y / August / 1)}; +} + +[[nodiscard]] const CachedEvent& event_by_remote( + const nocal::storage::CacheSnapshot& snapshot, std::string_view remote) { + const auto found = std::find_if(snapshot.events.begin(), snapshot.events.end(), + [&](const CachedEvent& event) { return event.remote_id == remote; }); + require(found != snapshot.events.end(), "expected cached event is missing"); + return *found; +} + void test_profile_mapping_headers_and_atomic_validation(const TemporaryDirectory& temporary) { SyncCache cache(temporary.database("profile.db")); ScriptedTransport transport; @@ -433,6 +484,340 @@ void test_invalid_inputs_continuations_and_timestamps(const TemporaryDirectory& "invalid timestamp leaked or committed event data"); } +void test_secondary_route_pagination_and_occurrence_mapping( + const TemporaryDirectory& temporary) { + SyncCache cache(temporary.database("secondary-route.db")); + const std::string local_calendar = seed_secondary(cache); + const std::string route = "https://graph.microsoft.com/v1.0/me/calendars/" + "Secondary%20%2FCase%3Fx/calendarView"; + const std::string next = route + "?$skiptoken=page-two"; + ScriptedTransport transport; + transport.steps = {{json_response(view_page(event_object("occurrence-one", "First"), next))}, + {json_response(view_page(event_object("occurrence-two", "Second", + "2026-07-19T11:00:00Z", "2026-07-19T12:00:00Z")))}}; + MicrosoftGraphSync graph(transport, cache); + graph.sync_secondary_calendar_window(credentials(), local_calendar, july_window()); + + require(transport.requests.size() == 2 + && transport.requests[0].url + == route + + "?startDateTime=2026-07-01T00%3A00%3A00.000000Z&" + "endDateTime=2026-08-01T00%3A00%3A00.000000Z&$top=1000" + && transport.requests[1].url == next, + "secondary sync used the wrong encoded calendarView route or pagination URL"); + for (const HttpRequest& request : transport.requests) { + require_graph_headers(request); + require(request.url.find("/beta/") == std::string::npos + && request.url.find("/delta") == std::string::npos, + "secondary sync used beta or a delta route"); + } + + const auto snapshot = cache.snapshot(); + require(snapshot.events.size() == 2 && snapshot.instances.size() == 2, + "two-page secondary view was not committed completely"); + const CachedEvent& first = event_by_remote(snapshot, "occurrence-one"); + require(first.id + == expected_id("graph-event-", "event", local_calendar, "occurrence-one") + && first.uid == "series-uid" && !first.deleted, + "secondary recurrence occurrence mapping or stable ID is wrong"); + const auto first_instance = std::find_if(snapshot.instances.begin(), snapshot.instances.end(), + [&](const CachedEventInstance& instance) { return instance.event_id == first.id; }); + require(first_instance != snapshot.instances.end() + && first_instance->id == expected_id("graph-instance-", "instance", first.id) + && first_instance->title == "First", + "secondary occurrence instance mapping is wrong"); + require(snapshot.checkpoints.size() == 1 && snapshot.checkpoints[0].calendar_id == local_calendar + && snapshot.checkpoints[0].window_start == "2026-07-01T00:00:00.000000Z" + && snapshot.checkpoints[0].window_end == "2026-08-01T00:00:00.000000Z" + && snapshot.checkpoints[0].cursor == "graph-calendar-view-v1" + && snapshot.checkpoints[0].complete, + "secondary complete-window checkpoint is wrong"); +} + +void test_secondary_update_empty_and_moved_out(const TemporaryDirectory& temporary) { + SyncCache cache(temporary.database("secondary-replace.db")); + const std::string calendar = seed_secondary(cache, "secondary-replace"); + ScriptedTransport transport; + transport.steps.push_back({json_response(view_page(event_object("event-a", "Old A") + "," + + event_object("event-b", "Moved B")))}); + transport.steps.push_back( + {json_response(view_page(event_object("event-a", "Updated A")))}); + transport.steps.push_back({json_response(view_page({}))}); + MicrosoftGraphSync graph(transport, cache); + graph.sync_secondary_calendar_window(credentials(), calendar, july_window()); + graph.sync_secondary_calendar_window(credentials(), calendar, july_window()); + auto snapshot = cache.snapshot(); + const CachedEvent& updated = event_by_remote(snapshot, "event-a"); + const CachedEvent& moved = event_by_remote(snapshot, "event-b"); + require(!updated.deleted && !moved.deleted, + "secondary update or moved-out omission was treated as a tombstone"); + require(std::ranges::any_of(snapshot.instances, [&](const CachedEventInstance& instance) { + return instance.event_id == updated.id && instance.title == "Updated A"; + }), "secondary event update did not replace its instance"); + require(std::ranges::none_of(snapshot.instances, [&](const CachedEventInstance& instance) { + return instance.event_id == moved.id; + }), "moved-out event retained an instance in the completed window"); + + graph.sync_secondary_calendar_window(credentials(), calendar, july_window()); + snapshot = cache.snapshot(); + require(snapshot.instances.empty(), "empty secondary window did not evict membership"); + require(std::ranges::all_of(snapshot.events, + [](const CachedEvent& event) { return !event.deleted; }), + "empty window marked omitted secondary events deleted"); +} + +void test_secondary_overlapping_window_retention(const TemporaryDirectory& temporary) { + SyncCache cache(temporary.database("secondary-overlap.db")); + const std::string calendar = seed_secondary(cache, "secondary-overlap"); + ScriptedTransport transport; + transport.steps.push_back( + {json_response(view_page(event_object("shared-event", "Shared")))}); + transport.steps.push_back({json_response(view_page({}))}); + MicrosoftGraphSync graph(transport, cache); + graph.sync_secondary_calendar_window(credentials(), calendar, july_window()); + const auto seeded = cache.snapshot(); + require(seeded.events.size() == 1 && seeded.instances.size() == 1, + "overlap fixture was not cached"); + + CompleteWindow overlap{{seeded.events[0]}, {seeded.instances[0]}, + SyncCheckpoint{calendar, "2026-07-15T00:00:00.000000Z", + "2026-08-15T00:00:00.000000Z", "graph-calendar-view-v1", true}}; + cache.replace_complete_window(overlap); + graph.sync_secondary_calendar_window(credentials(), calendar, july_window()); + const auto retained = cache.snapshot(); + require(retained.instances.size() == 1 + && retained.instances[0].event_id == seeded.events[0].id, + "empty overlapping window removed an instance still referenced by another window"); +} + +void test_secondary_paged_failures_are_atomic_and_redacted( + const TemporaryDirectory& temporary) { + SyncCache cache(temporary.database("secondary-failure.db")); + const std::string calendar = seed_secondary(cache, "secondary-failure"); + ScriptedTransport initial; + initial.steps.push_back( + {json_response(view_page(event_object("existing-event", "Existing")))}); + MicrosoftGraphSync initial_graph(initial, cache); + initial_graph.sync_secondary_calendar_window(credentials(), calendar, july_window()); + const auto baseline = cache.snapshot(); + const std::string route = + "https://graph.microsoft.com/v1.0/me/calendars/secondary-failure/calendarView"; + const std::string next = route + "?$skiptoken=SENSITIVE_CURSOR"; + + ScriptedTransport transport_failure; + transport_failure.steps = { + {json_response(view_page(event_object("SENSITIVE_REMOTE_EVENT", "New"), next))}, + {{}, true}}; + MicrosoftGraphSync transport_graph(transport_failure, cache); + const std::string transport_message = expect_graph_error([&] { + transport_graph.sync_secondary_calendar_window(credentials(), calendar, july_window()); + }); + require(cache.snapshot() == baseline, "later secondary transport failure changed prior window"); + require(transport_message.find("SENSITIVE") == std::string::npos + && transport_message.find("ACCESS_TOKEN_SECRET") == std::string::npos + && transport_message.find("secondary-failure") == std::string::npos, + "secondary transport failure leaked token, cursor, remote ID, or calendar ID"); + + ScriptedTransport malformed; + malformed.steps = { + {json_response(view_page(event_object("new-first-page", "New"), next))}, + {json_response("{\"value\":\"SENSITIVE_MALFORMED_BODY\"")}}; + MicrosoftGraphSync malformed_graph(malformed, cache); + const std::string malformed_message = expect_graph_error([&] { + malformed_graph.sync_secondary_calendar_window(credentials(), calendar, july_window()); + }); + require(cache.snapshot() == baseline, "later malformed secondary page changed prior window"); + require(malformed_message.find("SENSITIVE") == std::string::npos, + "secondary malformed response leaked provider body"); + + ScriptedTransport duplicate; + duplicate.steps = { + {json_response(view_page(event_object("duplicate-event", "First"), next))}, + {json_response(view_page(event_object("duplicate-event", "Second")))}}; + MicrosoftGraphSync duplicate_graph(duplicate, cache); + expect_graph_error([&] { + duplicate_graph.sync_secondary_calendar_window(credentials(), calendar, july_window()); + }); + require(cache.snapshot() == baseline, + "duplicate event across secondary pages changed prior window"); + + ScriptedTransport rejected; + rejected.steps.push_back({json_response("SENSITIVE_NON_2XX_BODY", 429)}); + MicrosoftGraphSync rejected_graph(rejected, cache); + const std::string rejected_message = expect_graph_error([&] { + rejected_graph.sync_secondary_calendar_window(credentials(), calendar, july_window()); + }); + require(cache.snapshot() == baseline, "secondary non-2xx response changed prior window"); + require(rejected_message.find("SENSITIVE") == std::string::npos, + "secondary non-2xx error leaked its response body"); +} + +void test_secondary_preconditions_do_not_use_network(const TemporaryDirectory& temporary) { + const auto require_no_network_failure = [&](SyncCache& cache, + MicrosoftGraphCredentials operation_credentials, + std::string calendar_id_value, + std::string_view message) { + ScriptedTransport transport; + MicrosoftGraphSync graph(transport, cache); + expect_graph_error([&] { + graph.sync_secondary_calendar_window( + operation_credentials, std::move(calendar_id_value), july_window()); + }); + require(transport.requests.empty(), message); + }; + + SyncCache primary(temporary.database("secondary-primary-reject.db")); + seed_primary(primary); + require_no_network_failure(primary, credentials(), calendar_id("primary-remote"), + "primary calendar secondary sync reached network"); + + SyncCache wrong_account(temporary.database("secondary-account-reject.db")); + const std::string wrong_account_calendar = seed_secondary(wrong_account, "wrong-account"); + require_no_network_failure(wrong_account, {"different-account", "ACCESS_TOKEN_SECRET"}, + wrong_account_calendar, "wrong-account secondary sync reached network"); + + SyncCache inactive(temporary.database("secondary-inactive-reject.db")); + const std::string inactive_id = seed_secondary(inactive, "inactive"); + inactive.replace_calendars_after_complete_listing("account-local", {}); + require_no_network_failure(inactive, credentials(), inactive_id, + "inactive secondary sync reached network"); + + SyncCache missing(temporary.database("secondary-missing-reject.db")); + seed_account(missing); + require_no_network_failure(missing, credentials(), "missing-calendar", + "missing secondary sync reached network"); + + SyncCache malformed(temporary.database("secondary-malformed-reject.db")); + seed_account(malformed); + const CachedCalendar malformed_calendar{calendar_id("malformed"), "account-local", + "malformed", "Malformed", "", false, true, "{"}; + malformed.replace_calendars_after_complete_listing( + "account-local", {&malformed_calendar, 1}); + require_no_network_failure(malformed, credentials(), malformed_calendar.id, + "malformed-calendar secondary sync reached network"); + + SyncCache invalid_window(temporary.database("secondary-window-reject.db")); + const std::string valid_secondary = seed_secondary(invalid_window, "invalid-window"); + ScriptedTransport transport; + MicrosoftGraphSync graph(transport, invalid_window); + expect_graph_error([&] { + graph.sync_secondary_calendar_window(credentials(), valid_secondary, + {july_window().start_epoch_microseconds, july_window().start_epoch_microseconds}); + }); + require(transport.requests.empty(), "invalid secondary window reached network"); +} + +void test_secondary_continuation_security(const TemporaryDirectory& temporary) { + const std::string route = + "https://graph.microsoft.com/v1.0/me/calendars/secondary-links/calendarView"; + const std::vector unsafe{ + "http://graph.microsoft.com/v1.0/me/calendars/secondary-links/calendarView?$skip=x", + "https://evil.example/v1.0/me/calendars/secondary-links/calendarView?$skip=x", + "https://user@graph.microsoft.com/v1.0/me/calendars/secondary-links/calendarView?$skip=x", + "https://graph.microsoft.com:443/v1.0/me/calendars/secondary-links/calendarView?$skip=x", + "https://graph.microsoft.com/v1.0/me/calendars/other/calendarView?$skip=x", + route + "?$skip=x#fragment", route + "?$skip=x\\nInjected:true", + route + "?" + std::string(16U * 1024U, 'x')}; + int sequence = 0; + for (const std::string& next : unsafe) { + SyncCache cache(temporary.database( + "secondary-link-" + std::to_string(sequence++) + ".db")); + const std::string calendar = seed_secondary(cache, "secondary-links"); + const auto baseline = cache.snapshot(); + ScriptedTransport transport; + transport.steps.push_back({json_response( + view_page(event_object("SENSITIVE_REMOTE_ID", "Sensitive"), next))}); + MicrosoftGraphSync graph(transport, cache); + const std::string message = expect_graph_error([&] { + graph.sync_secondary_calendar_window(credentials(), calendar, july_window()); + }); + require(transport.requests.size() == 1, + "unsafe secondary continuation received credentials"); + require(cache.snapshot() == baseline, "unsafe secondary continuation changed cache"); + require(message.find("SENSITIVE") == std::string::npos + && message.find("ACCESS_TOKEN_SECRET") == std::string::npos, + "unsafe secondary continuation error leaked secrets"); + } + + SyncCache repeated_cache(temporary.database("secondary-link-repeated.db")); + const std::string repeated_calendar = seed_secondary(repeated_cache, "secondary-links"); + const auto repeated_baseline = repeated_cache.snapshot(); + const std::string repeated = route + "?$skiptoken=repeated"; + ScriptedTransport repeated_transport; + repeated_transport.steps = { + {json_response(view_page({}, repeated))}, {json_response(view_page({}, repeated))}}; + MicrosoftGraphSync repeated_graph(repeated_transport, repeated_cache); + expect_graph_error([&] { + repeated_graph.sync_secondary_calendar_window( + credentials(), repeated_calendar, july_window()); + }); + require(repeated_transport.requests.size() == 2, + "repeated secondary continuation triggered another credentialed request"); + require(repeated_cache.snapshot() == repeated_baseline, + "repeated secondary continuation changed cache"); + + SyncCache delta_cache(temporary.database("secondary-delta-reject.db")); + const std::string delta_calendar = seed_secondary(delta_cache, "secondary-links"); + const auto delta_baseline = delta_cache.snapshot(); + ScriptedTransport delta_transport; + delta_transport.steps.push_back({json_response( + "{\"value\":[],\"@odata.deltaLink\":\"" + route + "?$delta=x\"}")}); + MicrosoftGraphSync delta_graph(delta_transport, delta_cache); + expect_graph_error([&] { + delta_graph.sync_secondary_calendar_window(credentials(), delta_calendar, july_window()); + }); + require(delta_cache.snapshot() == delta_baseline, + "secondary calendarView accepted or committed a deltaLink"); +} + +void test_secondary_aggregate_response_bound(const TemporaryDirectory& temporary) { + SyncCache cache(temporary.database("secondary-aggregate.db")); + const std::string calendar = seed_secondary(cache, "secondary-aggregate"); + const auto baseline = cache.snapshot(); + const std::string route = + "https://graph.microsoft.com/v1.0/me/calendars/secondary-aggregate/calendarView"; + ScriptedTransport transport; + const std::string padding(7U * 1024U * 1024U, 'x'); + for (int page = 0; page < 10; ++page) { + const std::string next = route + "?$skiptoken=aggregate-" + std::to_string(page + 1); + transport.steps.push_back({json_response("{\"value\":[],\"padding\":\"" + padding + + "\",\"@odata.nextLink\":\"" + next + "\"}")}); + } + MicrosoftGraphSync graph(transport, cache); + expect_graph_error([&] { + graph.sync_secondary_calendar_window(credentials(), calendar, july_window()); + }); + require(transport.requests.size() == 10, + "secondary aggregate response bound fired at the wrong page"); + require(cache.snapshot() == baseline, + "aggregate secondary response overflow changed prior cache state"); +} + +void test_secondary_cache_failure_is_atomic(const TemporaryDirectory& temporary) { + SyncCache cache(temporary.database("secondary-cache-failure.db")); + const std::string calendar = seed_secondary(cache, "secondary-cache-failure"); + const std::string target_id = + expected_id("graph-event-", "event", calendar, "target-remote"); + CompleteWindow poisoned{{CachedEvent{target_id, calendar, "different-remote", "uid", "", "", + "{\"poisoned\":true}", false}}, + {}, SyncCheckpoint{calendar, "2026-07-01T00:00:00.000000Z", + "2026-08-01T00:00:00.000000Z", "graph-calendar-view-v1", true}}; + cache.replace_complete_window(poisoned); + const auto baseline = cache.snapshot(); + ScriptedTransport transport; + transport.steps.push_back( + {json_response(view_page(event_object("target-remote", "Target")))}); + MicrosoftGraphSync graph(transport, cache); + const std::string message = expect_graph_error([&] { + graph.sync_secondary_calendar_window(credentials(), calendar, july_window()); + }); + require(cache.snapshot() == baseline, + "secondary cache failure partially replaced the complete window"); + require(message.find("target-remote") == std::string::npos + && message.find("ACCESS_TOKEN_SECRET") == std::string::npos, + "secondary cache failure leaked remote ID or token"); +} + } // namespace int main() { @@ -443,6 +828,14 @@ int main() { test_delta_mapping_incremental_tombstone_and_routes(temporary); test_page_resume_and_primary_only_failure(temporary); test_invalid_inputs_continuations_and_timestamps(temporary); + test_secondary_route_pagination_and_occurrence_mapping(temporary); + test_secondary_update_empty_and_moved_out(temporary); + test_secondary_overlapping_window_retention(temporary); + test_secondary_paged_failures_are_atomic_and_redacted(temporary); + test_secondary_preconditions_do_not_use_network(temporary); + test_secondary_continuation_security(temporary); + test_secondary_aggregate_response_bound(temporary); + test_secondary_cache_failure_is_atomic(temporary); } catch (const std::exception& error) { std::cerr << "Microsoft Graph tests failed: " << error.what() << '\n'; return 1; diff --git a/tests/sync_cache_tests.cpp b/tests/sync_cache_tests.cpp index a111bc9..6a1b05c 100644 --- a/tests/sync_cache_tests.cpp +++ b/tests/sync_cache_tests.cpp @@ -30,6 +30,7 @@ using nocal::storage::CachedAccount; using nocal::storage::CachedCalendar; using nocal::storage::CachedEvent; using nocal::storage::CachedEventInstance; +using nocal::storage::CompleteWindow; using nocal::storage::PullPage; using nocal::storage::SyncCache; using nocal::storage::SyncCheckpoint; @@ -211,6 +212,13 @@ PullPage page(CachedEvent changed_event = event(), CachedEventInstance changed_i return PullPage{{std::move(changed_event)}, {std::move(changed_instance)}, std::move(next)}; } +CompleteWindow complete_window(std::vector events = {event()}, + std::vector instances = {instance()}, + SyncCheckpoint complete_checkpoint = checkpoint()) { + complete_checkpoint.complete = true; + return {std::move(events), std::move(instances), std::move(complete_checkpoint)}; +} + void seed_calendar(SyncCache& cache, const CachedAccount& cached_account = account(), const CachedCalendar& cached_calendar = calendar()) { cache.upsert_account(cached_account); @@ -249,11 +257,13 @@ void test_fresh_schema_permissions_and_secrets(const std::filesystem::path& root const auto tables = inspection.first_column( "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"); for (const std::string_view required : {"accounts", "calendars", "events", - "event_instances", "sync_state", "outbox", "conflicts"}) { + "event_instances", "event_window_membership", "sync_state", "outbox", + "conflicts"}) { require(contains(tables, required), std::string{"missing cache table: "}.append(required)); } - for (const std::string_view child : {"calendars", "events", "event_instances", "sync_state"}) { + for (const std::string_view child : {"calendars", "events", "event_instances", + "event_window_membership", "sync_state"}) { require(inspection.scalar_int( std::string{"SELECT COUNT(*) FROM pragma_foreign_key_list('"} .append(child) @@ -261,6 +271,11 @@ void test_fresh_schema_permissions_and_secrets(const std::filesystem::path& root > 0, std::string{"missing foreign key on "}.append(child)); } + require(inspection.scalar_int( + "SELECT COUNT(*) FROM pragma_index_list('event_window_membership') " + "WHERE name='event_window_membership_event_id_idx'") + == 1, + "complete-window event lookup index is missing"); const auto columns = inspection.first_column( "SELECT lower(m.name || '.' || p.name) FROM sqlite_master AS m " @@ -397,6 +412,176 @@ void test_pull_pages_replacement_tombstone_and_windows(const std::filesystem::pa "checkpoints are not returned in deterministic scope order"); } +void test_complete_window_idempotence_revival_and_omission( + const std::filesystem::path& root) { + const auto path = root / "complete-window.db"; + SyncCache cache{path}; + seed_calendar(cache); + auto completed = complete_window(); + cache.replace_complete_window(completed); + const auto initial = cache.snapshot(); + require(initial.events.size() == 1 && initial.instances.size() == 1 + && initial.checkpoints.size() == 1 && initial.checkpoints[0].complete, + "complete window did not persist event, instance, and complete checkpoint"); + cache.replace_complete_window(completed); + require(cache.snapshot() == initial, "complete window replacement was not idempotent"); + + auto tombstone = event(); + tombstone.deleted = true; + tombstone.raw_payload.clear(); + cache.apply_pull_page(PullPage{{tombstone}, {}, checkpoint("delta-tombstone")}); + require(cache.snapshot().events[0].deleted && cache.snapshot().instances.empty(), + "delta tombstone fixture did not delete the event"); + + auto revived = event(); + revived.etag = "revived-etag"; + revived.raw_payload = R"({"event":"revived"})"; + auto revived_instance = instance("instance-revived"); + auto revival_checkpoint = checkpoint("complete-revival"); + revival_checkpoint.complete = true; + cache.replace_complete_window({{revived}, {revived_instance}, revival_checkpoint}); + auto snapshot = cache.snapshot(); + require(!snapshot.events[0].deleted && snapshot.events[0].etag == "revived-etag" + && snapshot.events[0].raw_payload == revived.raw_payload + && snapshot.instances.size() == 1 + && snapshot.instances[0].id == "instance-revived", + "returned complete-window event was not revived and replaced"); + + auto empty_checkpoint = checkpoint("complete-empty"); + empty_checkpoint.complete = true; + cache.replace_complete_window({{}, {}, empty_checkpoint}); + snapshot = cache.snapshot(); + require(snapshot.events.size() == 1 && !snapshot.events[0].deleted + && snapshot.events[0].raw_payload == revived.raw_payload + && snapshot.instances.empty(), + "complete-window omission acted as a tombstone or retained last-window instances"); + require(snapshot.checkpoints[0].cursor == "complete-empty" + && snapshot.checkpoints[0].complete, + "empty completed window did not advance its checkpoint"); + + SqliteConnection inspection{path}; + require(inspection.scalar_int("SELECT COUNT(*) FROM event_window_membership") == 0, + "empty completed window retained membership rows"); +} + +void test_overlapping_complete_windows_preserve_instances( + const std::filesystem::path& root) { + const auto path = root / "overlapping-windows.db"; + SyncCache cache{path}; + seed_calendar(cache); + auto first_checkpoint = checkpoint("first-complete", "calendar-a", "first-start", "first-end"); + first_checkpoint.complete = true; + cache.replace_complete_window({{event()}, {instance()}, first_checkpoint}); + + auto second_instance = instance("instance-second"); + second_instance.title = "Second window representation"; + auto second_checkpoint = + checkpoint("second-complete", "calendar-a", "second-start", "second-end"); + second_checkpoint.complete = true; + cache.replace_complete_window({{event()}, {second_instance}, second_checkpoint}); + require(cache.snapshot().instances.size() == 1 + && cache.snapshot().instances[0].id == "instance-second", + "overlapping returned event did not replace its instances"); + + first_checkpoint.cursor = "first-empty"; + cache.replace_complete_window({{}, {}, first_checkpoint}); + require(cache.snapshot().instances.size() == 1 + && cache.snapshot().instances[0].id == "instance-second", + "omission from one overlapping window evicted a still-referenced event"); + + second_checkpoint.cursor = "second-empty"; + cache.replace_complete_window({{}, {}, second_checkpoint}); + require(cache.snapshot().instances.empty(), + "removing the final complete-window membership did not evict instances"); + require(cache.snapshot().events.size() == 1 && !cache.snapshot().events[0].deleted, + "final membership removal deleted or tombstoned the event row"); + + SqliteConnection inspection{path}; + require_throws( + [&] { + inspection.execute("PRAGMA foreign_keys=ON"); + inspection.execute( + "INSERT INTO accounts VALUES('other-account','provider','subject','Other')"); + inspection.execute( + "INSERT INTO calendars VALUES('other-calendar','other-account','remote','Other','',0,1,'raw')"); + inspection.execute( + "INSERT INTO event_window_membership VALUES('other-calendar','start','end','event-a')"); + }, + "cross-calendar membership bypassed the composite foreign key"); +} + +void test_complete_window_validation_and_sql_rollback(const std::filesystem::path& root) { + SyncCache cache{root / "complete-validation.db"}; + seed_calendar(cache); + auto event_b = event("event-b"); + event_b.remote_id = "remote-event-b"; + event_b.raw_payload = R"({"event":"b"})"; + auto instance_b = instance("instance-b", "event-b"); + cache.replace_complete_window(complete_window({event(), event_b}, {instance(), instance_b})); + const auto baseline = cache.snapshot(); + + auto expect_unchanged = [&](auto&& operation, std::string_view message) { + require_throws(std::forward(operation), message); + require(cache.snapshot() == baseline, "invalid complete window changed cache state"); + }; + + auto incomplete = complete_window(); + incomplete.checkpoint.complete = false; + expect_unchanged([&] { cache.replace_complete_window(incomplete); }, + "incomplete complete-window checkpoint accepted"); + for (const int missing_field : {0, 1, 2, 3}) { + auto invalid = complete_window(); + if (missing_field == 0) invalid.checkpoint.calendar_id.clear(); + if (missing_field == 1) invalid.checkpoint.window_start.clear(); + if (missing_field == 2) invalid.checkpoint.window_end.clear(); + if (missing_field == 3) invalid.checkpoint.cursor.clear(); + expect_unchanged([&] { cache.replace_complete_window(invalid); }, + "empty complete-window checkpoint field accepted"); + } + auto deleted = event(); + deleted.deleted = true; + expect_unchanged([&] { cache.replace_complete_window(complete_window({deleted}, {})); }, + "deleted complete-window event accepted"); + auto cross_calendar = event(); + cross_calendar.calendar_id = "other-calendar"; + expect_unchanged( + [&] { cache.replace_complete_window(complete_window({cross_calendar}, {})); }, + "cross-calendar complete-window event accepted"); + expect_unchanged( + [&] { cache.replace_complete_window(complete_window({event(), event()}, {})); }, + "duplicate complete-window event accepted"); + auto duplicate_remote = event("different-id"); + expect_unchanged( + [&] { cache.replace_complete_window(complete_window({event(), duplicate_remote}, {})); }, + "duplicate complete-window remote event accepted"); + auto orphan = instance(); + orphan.event_id = "not-returned"; + expect_unchanged([&] { cache.replace_complete_window(complete_window({event()}, {orphan})); }, + "orphan complete-window instance accepted"); + auto invalid_interval = instance(); + invalid_interval.end_epoch_microseconds = invalid_interval.start_epoch_microseconds - 1; + expect_unchanged( + [&] { cache.replace_complete_window(complete_window({event()}, {invalid_interval})); }, + "invalid complete-window instance interval accepted"); + + auto changed = event(); + changed.etag = "must-roll-back"; + changed.raw_payload = R"({"event":"must-roll-back"})"; + auto collision = instance("instance-b", "event-a"); + auto collision_checkpoint = checkpoint("must-not-advance"); + collision_checkpoint.complete = true; + expect_unchanged( + [&] { cache.replace_complete_window({{changed}, {collision}, collision_checkpoint}); }, + "complete-window SQL instance collision was accepted"); + + cache.replace_calendars_after_complete_listing("account-a", {}); + const auto inactive_baseline = cache.snapshot(); + require_throws([&] { cache.replace_complete_window(complete_window()); }, + "inactive calendar accepted a complete window"); + require(cache.snapshot() == inactive_baseline, + "inactive-calendar complete window changed cache state"); +} + void test_multiple_connections_serialize(const std::filesystem::path& root) { const auto path = root / "connections.db"; SyncCache first{path}; @@ -721,6 +906,71 @@ void test_foreign_key_failure_rolls_back(const std::filesystem::path& root) { require(cache.snapshot() == baseline, "foreign-key failure changed cached data"); } +void downgrade_fixture_to_v1(const std::filesystem::path& path) { + SqliteConnection database{path}; + database.execute("DROP TABLE event_window_membership"); + database.execute("DROP INDEX events_id_calendar_id_unique_idx"); + database.execute("PRAGMA user_version=1"); +} + +void test_v1_to_v2_migration_preserves_data_and_serializes( + const std::filesystem::path& root) { + const auto path = root / "v1-upgrade.db"; + CacheSnapshot before; + { + SyncCache cache{path}; + seed_calendar(cache); + cache.apply_pull_page(page()); + before = cache.snapshot(); + } + downgrade_fixture_to_v1(path); + { + SyncCache upgraded{path}; + require(upgraded.snapshot() == before, "v1 to v2 migration changed cached data"); + } + { + SqliteConnection inspection{path}; + require(inspection.scalar_int("PRAGMA user_version") == 2, + "v1 migration did not record schema version 2"); + require(inspection.scalar_int( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' " + "AND name='event_window_membership'") + == 1, + "v1 migration did not create complete-window membership"); + } + + const auto concurrent_path = root / "v1-concurrent.db"; + { + SyncCache cache{concurrent_path}; + seed_calendar(cache); + } + downgrade_fixture_to_v1(concurrent_path); + std::atomic begin{false}; + std::exception_ptr first_error; + std::exception_ptr second_error; + auto open = [&](std::exception_ptr& error) { + while (!begin.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + try { + SyncCache cache{concurrent_path}; + (void)cache.snapshot(); + } catch (...) { + error = std::current_exception(); + } + }; + std::thread first{open, std::ref(first_error)}; + std::thread second{open, std::ref(second_error)}; + begin.store(true, std::memory_order_release); + first.join(); + second.join(); + require(first_error == nullptr && second_error == nullptr, + "concurrent v1 opens did not serialize schema migration"); + SqliteConnection concurrent_inspection{concurrent_path}; + require(concurrent_inspection.scalar_int("PRAGMA user_version") == 2, + "concurrent migration did not finish at schema version 2"); +} + void test_migration_failures_are_non_destructive(const std::filesystem::path& root) { const auto collision_path = root / "collision.db"; { @@ -743,17 +993,46 @@ void test_migration_failures_are_non_destructive(const std::filesystem::path& ro "failed migration left partially created v1 tables"); } + const auto v1_collision_path = root / "v1-collision.db"; + { + SyncCache cache{v1_collision_path}; + seed_calendar(cache); + cache.apply_pull_page(page()); + } + downgrade_fixture_to_v1(v1_collision_path); + { + SqliteConnection database{v1_collision_path}; + database.execute("CREATE TABLE event_window_membership (sentinel TEXT NOT NULL)"); + database.execute("INSERT INTO event_window_membership VALUES ('keep-v1')"); + } + require_throws([&] { SyncCache cache{v1_collision_path}; }, + "incompatible v1 to v2 migration collision was accepted"); + { + SqliteConnection database{v1_collision_path}; + require(database.scalar_int("PRAGMA user_version") == 1, + "failed v2 migration changed schema version"); + require(database.scalar_text("SELECT sentinel FROM event_window_membership") == "keep-v1", + "failed v2 migration changed preexisting data"); + require(database.scalar_int( + "SELECT COUNT(*) FROM sqlite_master WHERE type='index' " + "AND name='events_id_calendar_id_unique_idx'") + == 0, + "failed v2 migration left a partially created index"); + require(database.scalar_int("SELECT COUNT(*) FROM events") == 1, + "failed v2 migration changed v1 event data"); + } + const auto future_path = root / "future.db"; { SqliteConnection database{future_path}; database.execute("CREATE TABLE future_sentinel (value TEXT NOT NULL)"); database.execute("INSERT INTO future_sentinel VALUES ('future-data')"); - database.execute("PRAGMA user_version=2"); + database.execute("PRAGMA user_version=3"); } require_throws([&] { SyncCache cache{future_path}; }, "future schema version was accepted"); { SqliteConnection database{future_path}; - require(database.scalar_int("PRAGMA user_version") == 2, + require(database.scalar_int("PRAGMA user_version") == 3, "future schema rejection changed schema version"); require(database.scalar_text("SELECT value FROM future_sentinel") == "future-data", "future schema rejection changed data"); @@ -804,11 +1083,15 @@ int main() { test_existing_permissions_are_tightened(temporary.path()); test_account_calendars_and_deterministic_snapshot(temporary.path()); test_pull_pages_replacement_tombstone_and_windows(temporary.path()); + test_complete_window_idempotence_revival_and_omission(temporary.path()); + test_overlapping_complete_windows_preserve_instances(temporary.path()); + test_complete_window_validation_and_sql_rollback(temporary.path()); test_multiple_connections_serialize(temporary.path()); test_input_validation_and_rollback(temporary.path()); test_sql_failure_rolls_back_snapshot_and_cursor(temporary.path()); test_checkpoint_calendar_and_shape_validation(temporary.path()); test_foreign_key_failure_rolls_back(temporary.path()); + test_v1_to_v2_migration_preserves_data_and_serializes(temporary.path()); test_migration_failures_are_non_destructive(temporary.path()); test_invalid_files_are_refused(temporary.path()); } catch (const std::exception& error) {